diff --git a/.agent/skills/swiftui-expert-skill b/.agent/skills/swiftui-expert-skill new file mode 120000 index 0000000..94339c9 --- /dev/null +++ b/.agent/skills/swiftui-expert-skill @@ -0,0 +1 @@ +../../.agents/skills/swiftui-expert-skill \ No newline at end of file diff --git a/.agents/skills/swiftui-expert-skill/SKILL.md b/.agents/skills/swiftui-expert-skill/SKILL.md new file mode 100644 index 0000000..ce1a826 --- /dev/null +++ b/.agents/skills/swiftui-expert-skill/SKILL.md @@ -0,0 +1,290 @@ +--- +name: swiftui-expert-skill +description: Write, review, or improve SwiftUI code following best practices for state management, view composition, performance, modern APIs, Swift concurrency, and iOS 26+ Liquid Glass adoption. Use when building new SwiftUI features, refactoring existing views, reviewing code quality, or adopting modern SwiftUI patterns. +--- + +# SwiftUI Expert Skill + +## Overview +Use this skill to build, review, or improve SwiftUI features with correct state management, modern API usage, Swift concurrency best practices, optimal view composition, and iOS 26+ Liquid Glass styling. Prioritize native APIs, Apple design guidance, and performance-conscious patterns. This skill focuses on facts and best practices without enforcing specific architectural patterns. + +## Workflow Decision Tree + +### 1) Review existing SwiftUI code +- Check property wrapper usage against the selection guide (see `references/state-management.md`) +- Verify modern API usage (see `references/modern-apis.md`) +- Verify view composition follows extraction rules (see `references/view-structure.md`) +- Check performance patterns are applied (see `references/performance-patterns.md`) +- Verify list patterns use stable identity (see `references/list-patterns.md`) +- Check animation patterns for correctness (see `references/animation-basics.md`, `references/animation-transitions.md`) +- Inspect Liquid Glass usage for correctness and consistency (see `references/liquid-glass.md`) +- Validate iOS 26+ availability handling with sensible fallbacks + +### 2) Improve existing SwiftUI code +- Audit state management for correct wrapper selection (prefer `@Observable` over `ObservableObject`) +- Replace deprecated APIs with modern equivalents (see `references/modern-apis.md`) +- Extract complex views into separate subviews (see `references/view-structure.md`) +- Refactor hot paths to minimize redundant state updates (see `references/performance-patterns.md`) +- Ensure ForEach uses stable identity (see `references/list-patterns.md`) +- Improve animation patterns (use value parameter, proper transitions, see `references/animation-basics.md`, `references/animation-transitions.md`) +- Suggest image downsampling when `UIImage(data:)` is used (as optional optimization, see `references/image-optimization.md`) +- Adopt Liquid Glass only when explicitly requested by the user + +### 3) Implement new SwiftUI feature +- Design data flow first: identify owned vs injected state (see `references/state-management.md`) +- Use modern APIs (no deprecated modifiers or patterns, see `references/modern-apis.md`) +- Use `@Observable` for shared state (with `@MainActor` if not using default actor isolation) +- Structure views for optimal diffing (extract subviews early, keep views small, see `references/view-structure.md`) +- Separate business logic into testable models (see `references/layout-best-practices.md`) +- Use correct animation patterns (implicit vs explicit, transitions, see `references/animation-basics.md`, `references/animation-transitions.md`, `references/animation-advanced.md`) +- Apply glass effects after layout/appearance modifiers (see `references/liquid-glass.md`) +- Gate iOS 26+ features with `#available` and provide fallbacks + +## Core Guidelines + +### State Management +- **Always prefer `@Observable` over `ObservableObject`** for new code +- **Mark `@Observable` classes with `@MainActor`** unless using default actor isolation +- **Always mark `@State` and `@StateObject` as `private`** (makes dependencies clear) +- **Never declare passed values as `@State` or `@StateObject`** (they only accept initial values) +- Use `@State` with `@Observable` classes (not `@StateObject`) +- `@Binding` only when child needs to **modify** parent state +- `@Bindable` for injected `@Observable` objects needing bindings +- Use `let` for read-only values; `var` + `.onChange()` for reactive reads +- Legacy: `@StateObject` for owned `ObservableObject`; `@ObservedObject` for injected +- Nested `ObservableObject` doesn't work (pass nested objects directly); `@Observable` handles nesting fine + +### Modern APIs +- Use `foregroundStyle()` instead of `foregroundColor()` +- Use `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()` +- Use `Tab` API instead of `tabItem()` +- Use `Button` instead of `onTapGesture()` (unless need location/count) +- Use `NavigationStack` instead of `NavigationView` +- Use `navigationDestination(for:)` for type-safe navigation +- Use two-parameter or no-parameter `onChange()` variant +- Use `ImageRenderer` for rendering SwiftUI views +- Use `.sheet(item:)` instead of `.sheet(isPresented:)` for model-based content +- Sheets should own their actions and call `dismiss()` internally +- Use `ScrollViewReader` for programmatic scrolling with stable IDs +- Avoid `UIScreen.main.bounds` for sizing +- Avoid `GeometryReader` when alternatives exist (e.g., `containerRelativeFrame()`) + +### Swift Best Practices +- Use modern Text formatting (`.format` parameters, not `String(format:)`) +- Use `localizedStandardContains()` for user-input filtering (not `contains()`) +- Prefer static member lookup (`.blue` vs `Color.blue`) +- Use `.task` modifier for automatic cancellation of async work +- Use `.task(id:)` for value-dependent tasks + +### View Composition +- **Prefer modifiers over conditional views** for state changes (maintains view identity) +- Extract complex views into separate subviews for better readability and performance +- Keep views small for optimal performance +- Keep view `body` simple and pure (no side effects or complex logic) +- Use `@ViewBuilder` functions only for small, simple sections +- Prefer `@ViewBuilder let content: Content` over closure-based content properties +- Separate business logic into testable models (not about enforcing architectures) +- Action handlers should reference methods, not contain inline logic +- Use relative layout over hard-coded constants +- Views should work in any context (don't assume screen size or presentation style) + +### Performance +- Pass only needed values to views (avoid large "config" or "context" objects) +- Eliminate unnecessary dependencies to reduce update fan-out +- Check for value changes before assigning state in hot paths +- Avoid redundant state updates in `onReceive`, `onChange`, scroll handlers +- Minimize work in frequently executed code paths +- Use `LazyVStack`/`LazyHStack` for large lists +- Use stable identity for `ForEach` (never `.indices` for dynamic content) +- Ensure constant number of views per `ForEach` element +- Avoid inline filtering in `ForEach` (prefilter and cache) +- Avoid `AnyView` in list rows +- Consider POD views for fast diffing (or wrap expensive views in POD parents) +- Suggest image downsampling when `UIImage(data:)` is encountered (as optional optimization) +- Avoid layout thrash (deep hierarchies, excessive `GeometryReader`) +- Gate frequent geometry updates by thresholds +- Use `Self._printChanges()` to debug unexpected view updates + +### Animations +- Use `.animation(_:value:)` with value parameter (deprecated version without value is too broad) +- Use `withAnimation` for event-driven animations (button taps, gestures) +- Prefer transforms (`offset`, `scale`, `rotation`) over layout changes (`frame`) for performance +- Transitions require animations outside the conditional structure +- Custom `Animatable` implementations must have explicit `animatableData` +- Use `.phaseAnimator` for multi-step sequences (iOS 17+) +- Use `.keyframeAnimator` for precise timing control (iOS 17+) +- Animation completion handlers need `.transaction(value:)` for reexecution +- Implicit animations override explicit animations (later in view tree wins) + +### Liquid Glass (iOS 26+) +**Only adopt when explicitly requested by the user.** +- Use native `glassEffect`, `GlassEffectContainer`, and glass button styles +- Wrap multiple glass elements in `GlassEffectContainer` +- Apply `.glassEffect()` after layout and visual modifiers +- Use `.interactive()` only for tappable/focusable elements +- Use `glassEffectID` with `@Namespace` for morphing transitions + +## Quick Reference + +### Property Wrapper Selection (Modern) +| Wrapper | Use When | +|---------|----------| +| `@State` | Internal view state (must be `private`), or owned `@Observable` class | +| `@Binding` | Child modifies parent's state | +| `@Bindable` | Injected `@Observable` needing bindings | +| `let` | Read-only value from parent | +| `var` | Read-only value watched via `.onChange()` | + +**Legacy (Pre-iOS 17):** +| Wrapper | Use When | +|---------|----------| +| `@StateObject` | View owns an `ObservableObject` (use `@State` with `@Observable` instead) | +| `@ObservedObject` | View receives an `ObservableObject` | + +### Modern API Replacements +| Deprecated | Modern Alternative | +|------------|-------------------| +| `foregroundColor()` | `foregroundStyle()` | +| `cornerRadius()` | `clipShape(.rect(cornerRadius:))` | +| `tabItem()` | `Tab` API | +| `onTapGesture()` | `Button` (unless need location/count) | +| `NavigationView` | `NavigationStack` | +| `onChange(of:) { value in }` | `onChange(of:) { old, new in }` or `onChange(of:) { }` | +| `fontWeight(.bold)` | `bold()` | +| `GeometryReader` | `containerRelativeFrame()` or `visualEffect()` | +| `showsIndicators: false` | `.scrollIndicators(.hidden)` | +| `String(format: "%.2f", value)` | `Text(value, format: .number.precision(.fractionLength(2)))` | +| `string.contains(search)` | `string.localizedStandardContains(search)` (for user input) | + +### Liquid Glass Patterns +```swift +// Basic glass effect with fallback +if #available(iOS 26, *) { + content + .padding() + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) +} else { + content + .padding() + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) +} + +// Grouped glass elements +GlassEffectContainer(spacing: 24) { + HStack(spacing: 24) { + GlassButton1() + GlassButton2() + } +} + +// Glass buttons +Button("Confirm") { } + .buttonStyle(.glassProminent) +``` + +## Review Checklist + +### State Management +- [ ] Using `@Observable` instead of `ObservableObject` for new code +- [ ] `@Observable` classes marked with `@MainActor` (if needed) +- [ ] Using `@State` with `@Observable` classes (not `@StateObject`) +- [ ] `@State` and `@StateObject` properties are `private` +- [ ] Passed values NOT declared as `@State` or `@StateObject` +- [ ] `@Binding` only where child modifies parent state +- [ ] `@Bindable` for injected `@Observable` needing bindings +- [ ] Nested `ObservableObject` avoided (or passed directly to child views) + +### Modern APIs (see `references/modern-apis.md`) +- [ ] Using `foregroundStyle()` instead of `foregroundColor()` +- [ ] Using `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()` +- [ ] Using `Tab` API instead of `tabItem()` +- [ ] Using `Button` instead of `onTapGesture()` (unless need location/count) +- [ ] Using `NavigationStack` instead of `NavigationView` +- [ ] Avoiding `UIScreen.main.bounds` +- [ ] Using alternatives to `GeometryReader` when possible +- [ ] Button images include text labels for accessibility + +### Sheets & Navigation (see `references/sheet-navigation-patterns.md`) +- [ ] Using `.sheet(item:)` for model-based sheets +- [ ] Sheets own their actions and dismiss internally +- [ ] Using `navigationDestination(for:)` for type-safe navigation + +### ScrollView (see `references/scroll-patterns.md`) +- [ ] Using `ScrollViewReader` with stable IDs for programmatic scrolling +- [ ] Using `.scrollIndicators(.hidden)` instead of initializer parameter + +### Text & Formatting (see `references/text-formatting.md`) +- [ ] Using modern Text formatting (not `String(format:)`) +- [ ] Using `localizedStandardContains()` for search filtering + +### View Structure (see `references/view-structure.md`) +- [ ] Using modifiers instead of conditionals for state changes +- [ ] Complex views extracted to separate subviews +- [ ] Views kept small for performance +- [ ] Container views use `@ViewBuilder let content: Content` + +### Performance (see `references/performance-patterns.md`) +- [ ] View `body` kept simple and pure (no side effects) +- [ ] Passing only needed values (not large config objects) +- [ ] Eliminating unnecessary dependencies +- [ ] State updates check for value changes before assigning +- [ ] Hot paths minimize state updates +- [ ] No object creation in `body` +- [ ] Heavy computation moved out of `body` + +### List Patterns (see `references/list-patterns.md`) +- [ ] ForEach uses stable identity (not `.indices`) +- [ ] Constant number of views per ForEach element +- [ ] No inline filtering in ForEach +- [ ] No `AnyView` in list rows + +### Layout (see `references/layout-best-practices.md`) +- [ ] Avoiding layout thrash (deep hierarchies, excessive GeometryReader) +- [ ] Gating frequent geometry updates by thresholds +- [ ] Business logic separated into testable models +- [ ] Action handlers reference methods (not inline logic) +- [ ] Using relative layout (not hard-coded constants) +- [ ] Views work in any context (context-agnostic) + +### Animations (see `references/animation-basics.md`, `references/animation-transitions.md`, `references/animation-advanced.md`) +- [ ] Using `.animation(_:value:)` with value parameter +- [ ] Using `withAnimation` for event-driven animations +- [ ] Transitions paired with animations outside conditional structure +- [ ] Custom `Animatable` has explicit `animatableData` implementation +- [ ] Preferring transforms over layout changes for animation performance +- [ ] Phase animations for multi-step sequences (iOS 17+) +- [ ] Keyframe animations for precise timing (iOS 17+) +- [ ] Completion handlers use `.transaction(value:)` for reexecution + +### Liquid Glass (iOS 26+) +- [ ] `#available(iOS 26, *)` with fallback for Liquid Glass +- [ ] Multiple glass views wrapped in `GlassEffectContainer` +- [ ] `.glassEffect()` applied after layout/appearance modifiers +- [ ] `.interactive()` only on user-interactable elements +- [ ] Shapes and tints consistent across related elements + +## References +- `references/state-management.md` - Property wrappers and data flow (prefer `@Observable`) +- `references/view-structure.md` - View composition, extraction, and container patterns +- `references/performance-patterns.md` - Performance optimization techniques and anti-patterns +- `references/list-patterns.md` - ForEach identity, stability, and list best practices +- `references/layout-best-practices.md` - Layout patterns, context-agnostic views, and testability +- `references/modern-apis.md` - Modern API usage and deprecated replacements +- `references/animation-basics.md` - Core animation concepts, implicit/explicit animations, timing, performance +- `references/animation-transitions.md` - Transitions, custom transitions, Animatable protocol +- `references/animation-advanced.md` - Transactions, phase/keyframe animations (iOS 17+), completion handlers (iOS 17+) +- `references/sheet-navigation-patterns.md` - Sheet presentation and navigation patterns +- `references/scroll-patterns.md` - ScrollView patterns and programmatic scrolling +- `references/text-formatting.md` - Modern text formatting and string operations +- `references/image-optimization.md` - AsyncImage, image downsampling, and optimization +- `references/liquid-glass.md` - iOS 26+ Liquid Glass API + +## Philosophy + +This skill focuses on **facts and best practices**, not architectural opinions: +- We don't enforce specific architectures (e.g., MVVM, VIPER) +- We do encourage separating business logic for testability +- We prioritize modern APIs over deprecated ones +- We emphasize thread safety with `@MainActor` and `@Observable` +- We optimize for performance and maintainability +- We follow Apple's Human Interface Guidelines and API design patterns diff --git a/.agents/skills/swiftui-expert-skill/references/animation-advanced.md b/.agents/skills/swiftui-expert-skill/references/animation-advanced.md new file mode 100644 index 0000000..6df634d --- /dev/null +++ b/.agents/skills/swiftui-expert-skill/references/animation-advanced.md @@ -0,0 +1,351 @@ +# SwiftUI Advanced Animations + +Transactions, phase animations (iOS 17+), keyframe animations (iOS 17+), and completion handlers (iOS 17+). + +## Table of Contents +- [Transactions](#transactions) +- [Phase Animations (iOS 17+)](#phase-animations-ios-17) +- [Keyframe Animations (iOS 17+)](#keyframe-animations-ios-17) +- [Animation Completion Handlers (iOS 17+)](#animation-completion-handlers-ios-17) + +--- + +## Transactions + +The underlying mechanism for all animations in SwiftUI. + +### Basic Usage + +```swift +// withAnimation is shorthand for withTransaction +withAnimation(.default) { flag.toggle() } + +// Equivalent explicit transaction +var transaction = Transaction(animation: .default) +withTransaction(transaction) { flag.toggle() } +``` + +### The .transaction Modifier + +```swift +Rectangle() + .frame(width: flag ? 100 : 50, height: 50) + .transaction { t in + t.animation = .default + } +``` + +**Note:** This behaves like the deprecated `.animation(_:)` without value parameter - it animates on every state change. + +### Animation Precedence + +**Implicit animations override explicit animations** (later in view tree wins). + +```swift +Button("Tap") { + withAnimation(.linear) { flag.toggle() } +} +.animation(.bouncy, value: flag) // .bouncy wins! +``` + +### Disabling Animations + +```swift +// Prevent implicit animations from overriding +.transaction { t in + t.disablesAnimations = true +} + +// Remove animation entirely +.transaction { $0.animation = nil } +``` + +### Custom Transaction Keys (iOS 17+) + +Pass metadata through transactions. + +```swift +struct ChangeSourceKey: TransactionKey { + static let defaultValue: String = "unknown" +} + +extension Transaction { + var changeSource: String { + get { self[ChangeSourceKey.self] } + set { self[ChangeSourceKey.self] = newValue } + } +} + +// Set source +var transaction = Transaction(animation: .default) +transaction.changeSource = "server" +withTransaction(transaction) { flag.toggle() } + +// Read in view tree +.transaction { t in + if t.changeSource == "server" { + t.animation = .smooth + } else { + t.animation = .bouncy + } +} +``` + +--- + +## Phase Animations (iOS 17+) + +Cycle through discrete phases automatically. Each phase change is a separate animation. + +### Basic Usage + +```swift +// GOOD - triggered phase animation +Button("Shake") { trigger += 1 } + .phaseAnimator( + [0.0, -10.0, 10.0, -5.0, 5.0, 0.0], + trigger: trigger + ) { content, offset in + content.offset(x: offset) + } + +// Infinite loop (no trigger) +Circle() + .phaseAnimator([1.0, 1.2, 1.0]) { content, scale in + content.scaleEffect(scale) + } +``` + +### Enum Phases (Recommended for Clarity) + +```swift +// GOOD - enum phases are self-documenting +enum BouncePhase: CaseIterable { + case initial, up, down, settle + + var scale: CGFloat { + switch self { + case .initial: 1.0 + case .up: 1.2 + case .down: 0.9 + case .settle: 1.0 + } + } +} + +Circle() + .phaseAnimator(BouncePhase.allCases, trigger: trigger) { content, phase in + content.scaleEffect(phase.scale) + } +``` + +### Custom Timing Per Phase + +```swift +.phaseAnimator([0, -20, 20], trigger: trigger) { content, offset in + content.offset(x: offset) +} animation: { phase in + switch phase { + case -20: .bouncy + case 20: .linear + default: .smooth + } +} +``` + +### Good vs Bad + +```swift +// GOOD - use phaseAnimator for multi-step sequences +.phaseAnimator([0, -10, 10, 0], trigger: trigger) { content, offset in + content.offset(x: offset) +} + +// BAD - manual DispatchQueue sequencing +Button("Animate") { + withAnimation(.easeOut(duration: 0.1)) { offset = -10 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation { offset = 10 } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + withAnimation { offset = 0 } + } +} +``` + +--- + +## Keyframe Animations (iOS 17+) + +Precise timing control with exact values at specific times. + +### Basic Usage + +```swift +Button("Bounce") { trigger += 1 } + .keyframeAnimator( + initialValue: AnimationValues(), + trigger: trigger + ) { content, value in + content + .scaleEffect(value.scale) + .offset(y: value.verticalOffset) + } keyframes: { _ in + KeyframeTrack(\.scale) { + SpringKeyframe(1.2, duration: 0.15) + SpringKeyframe(0.9, duration: 0.1) + SpringKeyframe(1.0, duration: 0.15) + } + KeyframeTrack(\.verticalOffset) { + LinearKeyframe(-20, duration: 0.15) + LinearKeyframe(0, duration: 0.25) + } + } + +struct AnimationValues { + var scale: CGFloat = 1.0 + var verticalOffset: CGFloat = 0 +} +``` + +### Keyframe Types + +| Type | Behavior | +|------|----------| +| `CubicKeyframe` | Smooth interpolation | +| `LinearKeyframe` | Straight-line interpolation | +| `SpringKeyframe` | Spring physics | +| `MoveKeyframe` | Instant jump (no interpolation) | + +### Multiple Synchronized Tracks + +Tracks run **in parallel**, each animating one property. + +```swift +// GOOD - bell shake with synchronized rotation and scale +struct BellAnimation { + var rotation: Double = 0 + var scale: CGFloat = 1.0 +} + +Image(systemName: "bell.fill") + .keyframeAnimator( + initialValue: BellAnimation(), + trigger: trigger + ) { content, value in + content + .rotationEffect(.degrees(value.rotation)) + .scaleEffect(value.scale) + } keyframes: { _ in + KeyframeTrack(\.rotation) { + CubicKeyframe(15, duration: 0.1) + CubicKeyframe(-15, duration: 0.1) + CubicKeyframe(10, duration: 0.1) + CubicKeyframe(-10, duration: 0.1) + CubicKeyframe(0, duration: 0.1) + } + KeyframeTrack(\.scale) { + CubicKeyframe(1.1, duration: 0.25) + CubicKeyframe(1.0, duration: 0.25) + } + } + +// BAD - manual timer-based animation +Image(systemName: "bell.fill") + .onTapGesture { + withAnimation(.easeOut(duration: 0.1)) { rotation = 15 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation { rotation = -15 } + } + // ... more manual timing - error prone + } +``` + +### KeyframeTimeline (iOS 17+) + +Query animation values directly for testing or non-SwiftUI use. + +```swift +let timeline = KeyframeTimeline(initialValue: AnimationValues()) { + KeyframeTrack(\.scale) { + CubicKeyframe(1.2, duration: 0.25) + CubicKeyframe(1.0, duration: 0.25) + } +} + +let midpoint = timeline.value(time: 0.25) +print(midpoint.scale) // Value at 0.25 seconds +``` + +--- + +## Animation Completion Handlers (iOS 17+) + +Execute code when animations finish. + +### With withAnimation + +```swift +// GOOD - completion with withAnimation +Button("Animate") { + withAnimation(.spring) { + isExpanded.toggle() + } completion: { + showNextStep = true + } +} +``` + +### With Transaction (For Reexecution) + +```swift +// GOOD - completion fires on every trigger change +Circle() + .scaleEffect(bounceCount % 2 == 0 ? 1.0 : 1.2) + .transaction(value: bounceCount) { transaction in + transaction.animation = .spring + transaction.addAnimationCompletion { + message = "Bounce \(bounceCount) complete" + } + } + +// BAD - completion only fires ONCE (no value parameter) +Circle() + .scaleEffect(bounceCount % 2 == 0 ? 1.0 : 1.2) + .animation(.spring, value: bounceCount) + .transaction { transaction in // No value! + transaction.addAnimationCompletion { + completionCount += 1 // Only fires once, ever + } + } +``` + +--- + +## Quick Reference + +### Transactions (All iOS versions) +- `withTransaction` is the explicit form of `withAnimation` +- Implicit animations override explicit (later in view tree wins) +- Use `disablesAnimations` to prevent override +- Use `.transaction { $0.animation = nil }` to remove animation + +### Custom Transaction Keys (iOS 17+) +- Pass metadata through animation system via `TransactionKey` + +### Phase Animations (iOS 17+) +- Use for multi-step sequences returning to start +- Prefer enum phases for clarity +- Each phase change is a separate animation +- Use `trigger` parameter for one-shot animations + +### Keyframe Animations (iOS 17+) +- Use for precise timing control +- Tracks run in parallel +- Use `KeyframeTimeline` for testing/advanced use +- Prefer over manual DispatchQueue timing + +### Completion Handlers (iOS 17+) +- Use `withAnimation(.animation) { } completion: { }` for one-shot completion handlers +- Use `.transaction(value:)` for handlers that should refire on every value change +- Without `value:` parameter, completion only fires once diff --git a/.agents/skills/swiftui-expert-skill/references/animation-basics.md b/.agents/skills/swiftui-expert-skill/references/animation-basics.md new file mode 100644 index 0000000..859682a --- /dev/null +++ b/.agents/skills/swiftui-expert-skill/references/animation-basics.md @@ -0,0 +1,284 @@ +# SwiftUI Animation Basics + +Core animation concepts, implicit vs explicit animations, timing curves, and performance patterns. + +## Table of Contents +- [Core Concepts](#core-concepts) +- [Implicit Animations](#implicit-animations) +- [Explicit Animations](#explicit-animations) +- [Animation Placement](#animation-placement) +- [Selective Animation](#selective-animation) +- [Timing Curves](#timing-curves) +- [Animation Performance](#animation-performance) +- [Disabling Animations](#disabling-animations) +- [Debugging](#debugging) + +--- + +## Core Concepts + +State changes trigger view updates. SwiftUI provides mechanisms to animate these changes. + +**Animation Process:** +1. State change triggers view tree re-evaluation +2. SwiftUI compares new tree to current render tree +3. Animatable properties are identified and interpolated (~60 fps) + +**Key Characteristics:** +- Animations are additive and cancelable +- Always start from current render tree state +- Blend smoothly when interrupted + +--- + +## Implicit Animations + +Use `.animation(_:value:)` to animate when a specific value changes. + +```swift +// GOOD - uses value parameter +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring, value: isExpanded) + .onTapGesture { isExpanded.toggle() } + +// BAD - deprecated, animates all changes unexpectedly +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring) // Deprecated! +``` + +--- + +## Explicit Animations + +Use `withAnimation` for event-driven state changes. + +```swift +// GOOD - explicit animation +Button("Toggle") { + withAnimation(.spring) { + isExpanded.toggle() + } +} + +// BAD - no animation context +Button("Toggle") { + isExpanded.toggle() // Abrupt change +} +``` + +**When to use which:** +- **Implicit**: Animations tied to specific value changes, precise view tree scope +- **Explicit**: Event-driven animations (button taps, gestures) + +--- + +## Animation Placement + +Place animation modifiers after the properties they should animate. + +```swift +// GOOD - animation after properties +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .foregroundStyle(isExpanded ? .blue : .red) + .animation(.default, value: isExpanded) // Animates both + +// BAD - animation before properties +Rectangle() + .animation(.default, value: isExpanded) // Too early! + .frame(width: isExpanded ? 200 : 100, height: 50) +``` + +--- + +## Selective Animation + +Animate only specific properties using multiple animation modifiers or scoped animations. + +```swift +// GOOD - selective animation +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring, value: isExpanded) // Animate size + .foregroundStyle(isExpanded ? .blue : .red) + .animation(nil, value: isExpanded) // Don't animate color + +// iOS 17+ scoped animation +Rectangle() + .foregroundStyle(isExpanded ? .blue : .red) // Not animated + .animation(.spring) { + $0.frame(width: isExpanded ? 200 : 100, height: 50) // Animated + } +``` + +--- + +## Timing Curves + +### Built-in Curves + +| Curve | Use Case | +|-------|----------| +| `.spring` | Interactive elements, most UI | +| `.easeInOut` | Appearance changes | +| `.bouncy` | Playful feedback (iOS 17+) | +| `.linear` | Progress indicators only | + +### Modifiers + +```swift +.animation(.default.speed(2.0), value: flag) // 2x faster +.animation(.default.delay(0.5), value: flag) // Delayed start +.animation(.default.repeatCount(3, autoreverses: true), value: flag) +``` + +### Good vs Bad Timing + +```swift +// GOOD - appropriate timing for interaction type +Button("Tap") { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + isActive.toggle() + } +} +.scaleEffect(isActive ? 0.95 : 1.0) + +// BAD - too slow for button feedback +Button("Tap") { + withAnimation(.easeInOut(duration: 1.0)) { // Way too slow! + isActive.toggle() + } +} + +// BAD - linear feels robotic +Rectangle() + .animation(.linear(duration: 0.5), value: isActive) // Mechanical +``` + +--- + +## Animation Performance + +### Prefer Transforms Over Layout + +```swift +// GOOD - GPU accelerated transforms +Rectangle() + .frame(width: 100, height: 100) + .scaleEffect(isActive ? 1.5 : 1.0) // Fast + .offset(x: isActive ? 50 : 0) // Fast + .rotationEffect(.degrees(isActive ? 45 : 0)) // Fast + .animation(.spring, value: isActive) + +// BAD - layout changes are expensive +Rectangle() + .frame(width: isActive ? 150 : 100, height: isActive ? 150 : 100) // Expensive + .padding(isActive ? 50 : 0) // Expensive +``` + +### Narrow Animation Scope + +```swift +// GOOD - animation scoped to specific subview +VStack { + HeaderView() // Not affected + ExpandableContent(isExpanded: isExpanded) + .animation(.spring, value: isExpanded) // Only this + FooterView() // Not affected +} + +// BAD - animation at root +VStack { + HeaderView() + ExpandableContent(isExpanded: isExpanded) + FooterView() +} +.animation(.spring, value: isExpanded) // Animates everything +``` + +### Avoid Animation in Hot Paths + +```swift +// GOOD - gate by threshold +.onPreferenceChange(ScrollOffsetKey.self) { offset in + let shouldShow = offset.y < -50 + if shouldShow != showTitle { // Only when crossing threshold + withAnimation(.easeOut(duration: 0.2)) { + showTitle = shouldShow + } + } +} + +// BAD - animating every scroll change +.onPreferenceChange(ScrollOffsetKey.self) { offset in + withAnimation { // Fires constantly! + self.offset = offset.y + } +} +``` + +--- + +## Disabling Animations + +```swift +// GOOD - disable with transaction +Text("Count: \(count)") + .transaction { $0.animation = nil } + +// GOOD - disable from parent context +DataView() + .transaction { $0.disablesAnimations = true } + +// BAD - hacky zero duration +Text("Count: \(count)") + .animation(.linear(duration: 0), value: count) // Hacky +``` + +--- + +## Debugging + +```swift +// Slow down for inspection +#if DEBUG +.animation(.linear(duration: 3.0).speed(0.2), value: isExpanded) +#else +.animation(.spring, value: isExpanded) +#endif + +// Debug modifier to log values +struct AnimationDebugModifier: ViewModifier, Animatable { + var value: Double + var animatableData: Double { + get { value } + set { + value = newValue + print("Animation: \(newValue)") + } + } + func body(content: Content) -> some View { + content.opacity(value) + } +} +``` + +--- + +## Quick Reference + +### Do +- Use `.animation(_:value:)` with value parameter +- Use `withAnimation` for event-driven animations +- Prefer transforms over layout changes +- Scope animations narrowly +- Choose appropriate timing curves + +### Don't +- Use deprecated `.animation(_:)` without value +- Animate layout properties in hot paths +- Apply broad animations at root level +- Use linear timing for UI (feels robotic) +- Animate on every frame in scroll handlers diff --git a/.agents/skills/swiftui-expert-skill/references/animation-transitions.md b/.agents/skills/swiftui-expert-skill/references/animation-transitions.md new file mode 100644 index 0000000..29b3f98 --- /dev/null +++ b/.agents/skills/swiftui-expert-skill/references/animation-transitions.md @@ -0,0 +1,326 @@ +# SwiftUI Transitions + +Transitions for view insertion/removal, custom transitions, and the Animatable protocol. + +## Table of Contents +- [Property Animations vs Transitions](#property-animations-vs-transitions) +- [Basic Transitions](#basic-transitions) +- [Asymmetric Transitions](#asymmetric-transitions) +- [Custom Transitions](#custom-transitions) +- [Identity and Transitions](#identity-and-transitions) +- [The Animatable Protocol](#the-animatable-protocol) + +--- + +## Property Animations vs Transitions + +**Property animations**: Interpolate values on views that exist before AND after state change. + +**Transitions**: Animate views being inserted or removed from the render tree. + +```swift +// Property animation - same view, different properties +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring, value: isExpanded) + +// Transition - view inserted/removed +if showDetail { + DetailView() + .transition(.scale) +} +``` + +--- + +## Basic Transitions + +### Critical: Transitions Require Animation Context + +```swift +// GOOD - animation outside conditional +VStack { + Button("Toggle") { showDetail.toggle() } + if showDetail { + DetailView() + .transition(.slide) + } +} +.animation(.spring, value: showDetail) + +// GOOD - explicit animation +Button("Toggle") { + withAnimation(.spring) { + showDetail.toggle() + } +} +if showDetail { + DetailView() + .transition(.scale.combined(with: .opacity)) +} + +// BAD - animation inside conditional (removed with view!) +if showDetail { + DetailView() + .transition(.slide) + .animation(.spring, value: showDetail) // Won't work on removal! +} + +// BAD - no animation context +Button("Toggle") { + showDetail.toggle() // No animation +} +if showDetail { + DetailView() + .transition(.slide) // Ignored - just appears/disappears +} +``` + +### Built-in Transitions + +| Transition | Effect | +|------------|--------| +| `.opacity` | Fade in/out (default) | +| `.scale` | Scale up/down | +| `.slide` | Slide from leading edge | +| `.move(edge:)` | Move from specific edge | +| `.offset(x:y:)` | Move by offset amount | + +### Combining Transitions + +```swift +// Parallel - both simultaneously +.transition(.slide.combined(with: .opacity)) + +// Chained +.transition(.scale.combined(with: .opacity).combined(with: .offset(y: 20))) +``` + +--- + +## Asymmetric Transitions + +Different animations for insertion vs removal. + +```swift +// GOOD - different animations for insert/remove +if showCard { + CardView() + .transition( + .asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .move(edge: .bottom).combined(with: .opacity) + ) + ) +} + +// BAD - same transition when different behaviors needed +if showCard { + CardView() + .transition(.slide) // Same both ways - may feel awkward +} +``` + +--- + +## Custom Transitions + +### Pre-iOS 17 + +```swift +struct BlurModifier: ViewModifier { + var radius: CGFloat + func body(content: Content) -> some View { + content.blur(radius: radius) + } +} + +extension AnyTransition { + static func blur(radius: CGFloat) -> AnyTransition { + .modifier( + active: BlurModifier(radius: radius), + identity: BlurModifier(radius: 0) + ) + } +} + +// Usage +.transition(.blur(radius: 10)) +``` + +### iOS 17+ (Transition Protocol) + +```swift +struct BlurTransition: Transition { + var radius: CGFloat + + func body(content: Content, phase: TransitionPhase) -> some View { + content + .blur(radius: phase.isIdentity ? 0 : radius) + .opacity(phase.isIdentity ? 1 : 0) + } +} + +// Usage +.transition(BlurTransition(radius: 10)) +``` + +### Good vs Bad Custom Transitions + +```swift +// GOOD - reusable transition +if showContent { + ContentView() + .transition(BlurTransition(radius: 10)) +} + +// BAD - inline logic (won't animate on removal!) +if showContent { + ContentView() + .blur(radius: showContent ? 0 : 10) // Not a transition + .opacity(showContent ? 1 : 0) +} +``` + +--- + +## Identity and Transitions + +View identity changes trigger transitions, not property animations. + +```swift +// Triggers transition - different branches have different identities +if isExpanded { + Rectangle().frame(width: 200, height: 50) +} else { + Rectangle().frame(width: 100, height: 50) +} + +// Triggers transition - .id() changes identity +Rectangle() + .id(flag) // Different identity when flag changes + .transition(.scale) + +// Property animation - same view, same identity +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring, value: isExpanded) +``` + +--- + +## The Animatable Protocol + +Enables custom property interpolation during animations. + +### Protocol Definition + +```swift +protocol Animatable { + associatedtype AnimatableData: VectorArithmetic + var animatableData: AnimatableData { get set } +} +``` + +### Basic Implementation + +```swift +// GOOD - explicit animatableData +struct ShakeModifier: ViewModifier, Animatable { + var shakeCount: Double + + var animatableData: Double { + get { shakeCount } + set { shakeCount = newValue } + } + + func body(content: Content) -> some View { + content.offset(x: sin(shakeCount * .pi * 2) * 10) + } +} + +extension View { + func shake(count: Int) -> some View { + modifier(ShakeModifier(shakeCount: Double(count))) + } +} + +// Usage +Button("Shake") { shakeCount += 3 } + .shake(count: shakeCount) + .animation(.default, value: shakeCount) + +// BAD - missing animatableData (silent failure!) +struct BadShakeModifier: ViewModifier { + var shakeCount: Double + // Missing animatableData! Uses EmptyAnimatableData + + func body(content: Content) -> some View { + content.offset(x: sin(shakeCount * .pi * 2) * 10) + } +} +// Animation jumps to final value instead of interpolating +``` + +### Multiple Properties with AnimatablePair + +```swift +// GOOD - AnimatablePair for two properties +struct ComplexModifier: ViewModifier, Animatable { + var scale: CGFloat + var rotation: Double + + var animatableData: AnimatablePair { + get { AnimatablePair(scale, rotation) } + set { + scale = newValue.first + rotation = newValue.second + } + } + + func body(content: Content) -> some View { + content + .scaleEffect(scale) + .rotationEffect(.degrees(rotation)) + } +} + +// GOOD - nested AnimatablePair for 3+ properties +struct ThreePropertyModifier: ViewModifier, Animatable { + var x: CGFloat + var y: CGFloat + var rotation: Double + + var animatableData: AnimatablePair, Double> { + get { AnimatablePair(AnimatablePair(x, y), rotation) } + set { + x = newValue.first.first + y = newValue.first.second + rotation = newValue.second + } + } + + func body(content: Content) -> some View { + content + .offset(x: x, y: y) + .rotationEffect(.degrees(rotation)) + } +} +``` + +--- + +## Quick Reference + +### Do +- Place transitions outside conditional structures +- Use `withAnimation` or `.animation` outside the `if` +- Implement `animatableData` explicitly for custom Animatable +- Use `AnimatablePair` for multiple animated properties +- Use asymmetric transitions when insert/remove need different effects + +### Don't +- Put animation modifiers inside conditionals for transitions +- Forget `animatableData` implementation (silent failure) +- Use inline blur/opacity instead of proper transitions +- Expect property animation when view identity changes diff --git a/.agents/skills/swiftui-expert-skill/references/image-optimization.md b/.agents/skills/swiftui-expert-skill/references/image-optimization.md new file mode 100644 index 0000000..4511776 --- /dev/null +++ b/.agents/skills/swiftui-expert-skill/references/image-optimization.md @@ -0,0 +1,286 @@ +# SwiftUI Image Optimization Reference + +## AsyncImage Best Practices + +### Basic AsyncImage with Phase Handling + +```swift +// Good - handles loading and error states +AsyncImage(url: imageURL) { phase in + switch phase { + case .empty: + ProgressView() + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + case .failure: + Image(systemName: "photo") + .foregroundStyle(.secondary) + @unknown default: + EmptyView() + } +} +.frame(width: 200, height: 200) +``` + +### AsyncImage with Custom Placeholder + +```swift +struct ImageView: View { + let url: URL? + + var body: some View { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + ZStack { + Color.gray.opacity(0.2) + ProgressView() + } + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failure: + ZStack { + Color.gray.opacity(0.2) + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.secondary) + } + @unknown default: + EmptyView() + } + } + .clipShape(.rect(cornerRadius: 12)) + } +} +``` + +### AsyncImage with Transition + +```swift +AsyncImage(url: imageURL) { phase in + switch phase { + case .empty: + ProgressView() + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + .transition(.opacity) + case .failure: + Image(systemName: "photo") + @unknown default: + EmptyView() + } +} +.animation(.easeInOut, value: imageURL) +``` + +## Image Decoding and Downsampling (Optional Optimization) + +**When you encounter `UIImage(data:)` usage, consider suggesting image downsampling as a potential performance improvement**, especially for large images in lists or grids. + +### Current Pattern That Could Be Optimized + +```swift +// Current pattern - decodes full image on main thread +// Unsafe - force unwrap can crash if imageData is invalid +Image(uiImage: UIImage(data: imageData)!) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 200, height: 200) +``` + +### Suggested Optimization Pattern + +```swift +// Suggested optimization - decode and downsample off main thread +struct OptimizedImageView: View { + let imageData: Data + let targetSize: CGSize + @State private var processedImage: UIImage? + + var body: some View { + Group { + if let processedImage { + Image(uiImage: processedImage) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + ProgressView() + } + } + .task { + processedImage = await decodeAndDownsample(imageData, targetSize: targetSize) + } + } + + private func decodeAndDownsample(_ data: Data, targetSize: CGSize) async -> UIImage? { + await Task.detached { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + return nil + } + + let options: [CFString: Any] = [ + kCGImageSourceThumbnailMaxPixelSize: max(targetSize.width, targetSize.height), + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true + ] + + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return nil + } + + return UIImage(cgImage: cgImage) + }.value + } +} + +// Usage +OptimizedImageView( + imageData: imageData, + targetSize: CGSize(width: 200, height: 200) +) +``` + +### Reusable Image Downsampling Helper + +```swift +actor ImageProcessor { + func downsample(data: Data, to targetSize: CGSize) -> UIImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + return nil + } + + let maxDimension = max(targetSize.width, targetSize.height) * UIScreen.main.scale + + let options: [CFString: Any] = [ + kCGImageSourceThumbnailMaxPixelSize: maxDimension, + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCache: false + ] + + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return nil + } + + return UIImage(cgImage: cgImage) + } +} + +// Usage in view +struct ImageView: View { + let imageData: Data + let targetSize: CGSize + @State private var image: UIImage? + + private let processor = ImageProcessor() + + var body: some View { + Group { + if let image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + ProgressView() + } + } + .task { + image = await processor.downsample(data: imageData, to: targetSize) + } + } +} +``` + +### When to Suggest This Optimization + +Mention this optimization when you see `UIImage(data:)` usage, particularly in: +- Scrollable content (List, ScrollView with LazyVStack/LazyHStack) +- Grid layouts with many images +- Image galleries or carousels +- Any scenario where large images are displayed at smaller sizes + +**Don't automatically apply it**—present it as an optional improvement for performance-sensitive scenarios. + +## SF Symbols + +### Using SF Symbols + +```swift +// Basic symbol +Image(systemName: "star.fill") + .foregroundStyle(.yellow) + +// With rendering mode +Image(systemName: "heart.fill") + .symbolRenderingMode(.multicolor) + +// With variable color +Image(systemName: "speaker.wave.3.fill") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.blue) + +// Animated symbols (iOS 17+) +Image(systemName: "antenna.radiowaves.left.and.right") + .symbolEffect(.variableColor) +``` + +### SF Symbol Variants + +```swift +// Circle variant +Image(systemName: "star.circle.fill") + +// Square variant +Image(systemName: "star.square.fill") + +// With badge +Image(systemName: "folder.badge.plus") +``` + +## Image Rendering + +### ImageRenderer for Snapshots + +```swift +// Render SwiftUI view to UIImage +let renderer = ImageRenderer(content: myView) +renderer.scale = UIScreen.main.scale + +if let uiImage = renderer.uiImage { + // Use the image (save, share, etc.) +} + +// Render to CGImage +if let cgImage = renderer.cgImage { + // Use CGImage +} +``` + +### Rendering with Custom Size + +```swift +let renderer = ImageRenderer(content: myView) +renderer.proposedSize = ProposedViewSize(width: 400, height: 300) + +if let uiImage = renderer.uiImage { + // Image rendered at 400x300 points +} +``` + +## Summary Checklist + +- [ ] Use `AsyncImage` with proper phase handling +- [ ] Handle empty, success, and failure states +- [ ] Consider downsampling for `UIImage(data:)` in performance-sensitive scenarios +- [ ] Decode and downsample images off the main thread +- [ ] Use appropriate target sizes for downsampling +- [ ] Consider image caching for frequently accessed images +- [ ] Use SF Symbols with appropriate rendering modes +- [ ] Use `ImageRenderer` for rendering SwiftUI views to images + +**Performance Note**: Image downsampling is an optional optimization. Only suggest it when you encounter `UIImage(data:)` usage in performance-sensitive contexts like scrollable lists or grids. diff --git a/.agents/skills/swiftui-expert-skill/references/layout-best-practices.md b/.agents/skills/swiftui-expert-skill/references/layout-best-practices.md new file mode 100644 index 0000000..57c8f74 --- /dev/null +++ b/.agents/skills/swiftui-expert-skill/references/layout-best-practices.md @@ -0,0 +1,312 @@ +# SwiftUI Layout Best Practices Reference + +## Relative Layout Over Constants + +**Use dynamic layout calculations instead of hard-coded values.** + +```swift +// Good - relative to actual layout +GeometryReader { geometry in + VStack { + HeaderView() + .frame(height: geometry.size.height * 0.2) + ContentView() + } +} + +// Avoid - magic numbers that don't adapt +VStack { + HeaderView() + .frame(height: 150) // Doesn't adapt to different screens + ContentView() +} +``` + +**Why**: Hard-coded values don't account for different screen sizes, orientations, or dynamic content (like status bars during phone calls). + +## Context-Agnostic Views + +**Views should work in any context.** Never assume presentation style or screen size. + +```swift +// Good - adapts to given space +struct ProfileCard: View { + let user: User + + var body: some View { + VStack { + Image(user.avatar) + .resizable() + .aspectRatio(contentMode: .fit) + Text(user.name) + Spacer() + } + .padding() + } +} + +// Avoid - assumes full screen +struct ProfileCard: View { + let user: User + + var body: some View { + VStack { + Image(user.avatar) + .frame(width: UIScreen.main.bounds.width) // Wrong! + Text(user.name) + } + } +} +``` + +**Why**: Views should work as full screens, modals, sheets, popovers, or embedded content. + +## Own Your Container + +**Custom views should own static containers but not lazy/repeatable ones.** + +```swift +// Good - owns static container +struct HeaderView: View { + var body: some View { + HStack { + Image(systemName: "star") + Text("Title") + Spacer() + } + } +} + +// Avoid - missing container +struct HeaderView: View { + var body: some View { + Image(systemName: "star") + Text("Title") + // Caller must wrap in HStack + } +} + +// Good - caller owns lazy container +struct FeedView: View { + let items: [Item] + + var body: some View { + LazyVStack { + ForEach(items) { item in + ItemRow(item: item) + } + } + } +} +``` + +## Layout Performance + +### Avoid Layout Thrash + +**Minimize deep view hierarchies and excessive layout dependencies.** + +```swift +// Bad - deep nesting, excessive layout passes +VStack { + HStack { + VStack { + HStack { + VStack { + Text("Deep") + } + } + } + } +} + +// Good - flatter hierarchy +VStack { + Text("Shallow") + Text("Structure") +} +``` + +**Avoid excessive `GeometryReader` and preference chains:** + +```swift +// Bad - multiple geometry readers cause layout thrash +GeometryReader { outerGeometry in + VStack { + GeometryReader { innerGeometry in + // Layout recalculates multiple times + } + } +} + +// Good - single geometry reader or use alternatives (iOS 17+) +containerRelativeFrame(.horizontal) { width, _ in + width * 0.8 +} +``` + +**Gate frequent geometry updates:** + +```swift +// Bad - updates on every pixel change +.onPreferenceChange(ViewSizeKey.self) { size in + currentSize = size +} + +// Good - gate by threshold +.onPreferenceChange(ViewSizeKey.self) { size in + let difference = abs(size.width - currentSize.width) + if difference > 10 { // Only update if significant change + currentSize = size + } +} +``` + +## View Logic and Testability + +### Separate View Logic from Views + +**Place view logic into view models or similar, so it can be tested.** + +> **iOS 17+**: Use `@Observable` macro with `@State` for view models. + +```swift +// Good - logic in testable model (iOS 17+) +@Observable +@MainActor +final class LoginViewModel { + var email = "" + var password = "" + var isValid: Bool { + !email.isEmpty && password.count >= 8 + } + + func login() async throws { + // Business logic here + } +} + +struct LoginView: View { + @State private var viewModel = LoginViewModel() + + var body: some View { + Form { + TextField("Email", text: $viewModel.email) + SecureField("Password", text: $viewModel.password) + Button("Login") { + Task { + try? await viewModel.login() + } + } + .disabled(!viewModel.isValid) + } + } +} +``` + +> **iOS 16 and earlier**: Use `ObservableObject` protocol with `@StateObject`. + +```swift +// Good - logic in testable model (iOS 16 and earlier) +@MainActor +final class LoginViewModel: ObservableObject { + @Published var email = "" + @Published var password = "" + var isValid: Bool { + !email.isEmpty && password.count >= 8 + } + + func login() async throws { + // Business logic here + } +} + +struct LoginView: View { + @StateObject private var viewModel = LoginViewModel() + + var body: some View { + Form { + TextField("Email", text: $viewModel.email) + SecureField("Password", text: $viewModel.password) + Button("Login") { + Task { + try? await viewModel.login() + } + } + .disabled(!viewModel.isValid) + } + } +} +``` + +```swift +// Bad - logic embedded in view +struct LoginView: View { + @State private var email = "" + @State private var password = "" + + var body: some View { + Form { + TextField("Email", text: $email) + SecureField("Password", text: $password) + Button("Login") { + // Business logic directly in view - hard to test + Task { + if !email.isEmpty && password.count >= 8 { + // Login logic... + } + } + } + } + } +} +``` + +**Note**: This is about separating business logic for testability, not about enforcing specific architectures like MVVM. The goal is to make logic testable while keeping views simple. + +## Action Handlers + +**Separate layout from logic.** View body should reference action methods, not contain logic. + +```swift +// Good - action references method +struct PublishView: View { + @State private var viewModel = PublishViewModel() + + var body: some View { + Button("Publish Project", action: viewModel.handlePublish) + } +} + +// Avoid - logic in closure +struct PublishView: View { + @State private var isLoading = false + @State private var showError = false + + var body: some View { + Button("Publish Project") { + isLoading = true + apiService.publish(project) { result in + if case .error = result { + showError = true + } + isLoading = false + } + } + } +} +``` + +**Why**: Separating logic from layout improves readability, testability, and maintainability. + +## Summary Checklist + +- [ ] Use relative layout over hard-coded constants +- [ ] Views work in any context (don't assume screen size) +- [ ] Custom views own static containers +- [ ] Avoid deep view hierarchies (layout thrash) +- [ ] Gate frequent geometry updates by thresholds +- [ ] View logic separated into testable models/classes +- [ ] Action handlers reference methods, not inline logic +- [ ] Avoid excessive `GeometryReader` usage +- [ ] Use `containerRelativeFrame()` when appropriate diff --git a/.agents/skills/swiftui-expert-skill/references/liquid-glass.md b/.agents/skills/swiftui-expert-skill/references/liquid-glass.md new file mode 100644 index 0000000..fb86c52 --- /dev/null +++ b/.agents/skills/swiftui-expert-skill/references/liquid-glass.md @@ -0,0 +1,377 @@ +# SwiftUI Liquid Glass Reference (iOS 26+) + +## Overview + +Liquid Glass is Apple's new design language introduced in iOS 26. It provides translucent, dynamic surfaces that respond to content and user interaction. This reference covers the native SwiftUI APIs for implementing Liquid Glass effects. + +## Availability + +All Liquid Glass APIs require iOS 26 or later. Always provide fallbacks: + +```swift +if #available(iOS 26, *) { + // Liquid Glass implementation +} else { + // Fallback using materials +} +``` + +## Core APIs + +### glassEffect Modifier + +The primary modifier for applying glass effects to views: + +```swift +.glassEffect(_ style: GlassEffectStyle = .regular, in shape: some Shape = .rect) +``` + +#### Basic Usage + +```swift +Text("Hello") + .padding() + .glassEffect() // Default regular style, rect shape +``` + +#### With Shape + +```swift +Text("Rounded Glass") + .padding() + .glassEffect(in: .rect(cornerRadius: 16)) + +Image(systemName: "star") + .padding() + .glassEffect(in: .circle) + +Text("Capsule") + .padding(.horizontal, 20) + .padding(.vertical, 10) + .glassEffect(in: .capsule) +``` + +### GlassEffectStyle + +#### Prominence Levels + +```swift +.glassEffect(.regular) // Standard glass appearance +.glassEffect(.prominent) // More visible, higher contrast +``` + +#### Tinting + +Add color tint to the glass: + +```swift +.glassEffect(.regular.tint(.blue)) +.glassEffect(.prominent.tint(.red.opacity(0.3))) +``` + +#### Interactivity + +Make glass respond to touch/pointer hover: + +```swift +// Interactive glass - responds to user interaction +.glassEffect(.regular.interactive()) + +// Combined with tint +.glassEffect(.regular.tint(.blue).interactive()) +``` + +**Important**: Only use `.interactive()` on elements that actually respond to user input (buttons, tappable views, focusable elements). + +## GlassEffectContainer + +Wraps multiple glass elements for proper visual grouping and spacing: + +```swift +GlassEffectContainer { + HStack { + Button("One") { } + .glassEffect() + Button("Two") { } + .glassEffect() + } +} +``` + +### With Spacing + +Control the visual spacing between glass elements: + +```swift +GlassEffectContainer(spacing: 24) { + HStack(spacing: 24) { + GlassChip(icon: "pencil") + GlassChip(icon: "eraser") + GlassChip(icon: "trash") + } +} +``` + +**Note**: The container's `spacing` parameter should match the actual spacing in your layout for proper glass effect rendering. + +## Glass Button Styles + +Built-in button styles for glass appearance: + +```swift +// Standard glass button +Button("Action") { } + .buttonStyle(.glass) + +// Prominent glass button (higher visibility) +Button("Primary Action") { } + .buttonStyle(.glassProminent) +``` + +### Custom Glass Buttons + +For more control, apply glass effect manually: + +```swift +Button(action: { }) { + Label("Settings", systemImage: "gear") + .padding() +} +.glassEffect(.regular.interactive(), in: .capsule) +``` + +## Morphing Transitions + +Create smooth transitions between glass elements using `glassEffectID` and `@Namespace`: + +```swift +struct MorphingExample: View { + @Namespace private var animation + @State private var isExpanded = false + + var body: some View { + GlassEffectContainer { + if isExpanded { + ExpandedCard() + .glassEffect() + .glassEffectID("card", in: animation) + } else { + CompactCard() + .glassEffect() + .glassEffectID("card", in: animation) + } + } + .animation(.smooth, value: isExpanded) + } +} +``` + +### Requirements for Morphing + +1. Both views must have the same `glassEffectID` +2. Use the same `@Namespace` +3. Wrap in `GlassEffectContainer` +4. Apply animation to the container or parent + +## Modifier Order + +**Critical**: Apply `glassEffect` after layout and visual modifiers: + +```swift +// CORRECT order +Text("Label") + .font(.headline) // 1. Typography + .foregroundStyle(.primary) // 2. Color + .padding() // 3. Layout + .glassEffect() // 4. Glass effect LAST + +// WRONG order - glass applied too early +Text("Label") + .glassEffect() // Wrong position + .padding() + .font(.headline) +``` + +## Complete Examples + +### Toolbar with Glass Buttons + +```swift +struct GlassToolbar: View { + var body: some View { + if #available(iOS 26, *) { + GlassEffectContainer(spacing: 16) { + HStack(spacing: 16) { + ToolbarButton(icon: "pencil", action: { }) + ToolbarButton(icon: "eraser", action: { }) + ToolbarButton(icon: "scissors", action: { }) + Spacer() + ToolbarButton(icon: "square.and.arrow.up", action: { }) + } + .padding(.horizontal) + } + } else { + // Fallback toolbar + HStack(spacing: 16) { + // ... fallback implementation + } + } + } +} + +struct ToolbarButton: View { + let icon: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.title2) + .frame(width: 44, height: 44) + } + .glassEffect(.regular.interactive(), in: .circle) + } +} +``` + +### Card with Glass Effect + +```swift +struct GlassCard: View { + let title: String + let subtitle: String + + var body: some View { + if #available(iOS 26, *) { + cardContent + .glassEffect(.regular, in: .rect(cornerRadius: 20)) + } else { + cardContent + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20)) + } + } + + private var cardContent: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } +} +``` + +### Segmented Control + +```swift +struct GlassSegmentedControl: View { + @Binding var selection: Int + let options: [String] + @Namespace private var animation + + var body: some View { + if #available(iOS 26, *) { + GlassEffectContainer(spacing: 4) { + HStack(spacing: 4) { + ForEach(options.indices, id: \.self) { index in + Button(options[index]) { + withAnimation(.smooth) { + selection = index + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .glassEffect( + selection == index ? .prominent.interactive() : .regular.interactive(), + in: .capsule + ) + .glassEffectID(selection == index ? "selected" : "option\(index)", in: animation) + } + } + .padding(4) + } + } else { + Picker("Options", selection: $selection) { + ForEach(options.indices, id: \.self) { index in + Text(options[index]).tag(index) + } + } + .pickerStyle(.segmented) + } + } +} +``` + +## Fallback Strategies + +### Using Materials + +```swift +if #available(iOS 26, *) { + content.glassEffect() +} else { + content.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) +} +``` + +### Available Materials for Fallback + +- `.ultraThinMaterial` - Closest to glass appearance +- `.thinMaterial` - Slightly more opaque +- `.regularMaterial` - Standard blur +- `.thickMaterial` - More opaque +- `.ultraThickMaterial` - Most opaque + +### Conditional Modifier Extension + +```swift +extension View { + @ViewBuilder + func glassEffectWithFallback( + _ style: GlassEffectStyle = .regular, + in shape: some Shape = .rect, + fallbackMaterial: Material = .ultraThinMaterial + ) -> some View { + if #available(iOS 26, *) { + self.glassEffect(style, in: shape) + } else { + self.background(fallbackMaterial, in: shape) + } + } +} +``` + +## Best Practices + +### Do + +- Use `GlassEffectContainer` for grouped glass elements +- Apply glass after layout modifiers +- Use `.interactive()` only on tappable elements +- Match container spacing with layout spacing +- Provide material-based fallbacks for older iOS +- Keep glass shapes consistent within a feature + +### Don't + +- Apply glass to every element (use sparingly) +- Use `.interactive()` on static content +- Mix different corner radii arbitrarily +- Forget iOS version checks +- Apply glass before padding/frame modifiers +- Nest `GlassEffectContainer` unnecessarily + +## Checklist + +- [ ] `#available(iOS 26, *)` with fallback +- [ ] `GlassEffectContainer` wraps grouped elements +- [ ] `.glassEffect()` applied after layout modifiers +- [ ] `.interactive()` only on user-interactable elements +- [ ] `glassEffectID` with `@Namespace` for morphing +- [ ] Consistent shapes and spacing across feature +- [ ] Container spacing matches layout spacing +- [ ] Appropriate prominence levels used diff --git a/.agents/skills/swiftui-expert-skill/references/list-patterns.md b/.agents/skills/swiftui-expert-skill/references/list-patterns.md new file mode 100644 index 0000000..ef384a2 --- /dev/null +++ b/.agents/skills/swiftui-expert-skill/references/list-patterns.md @@ -0,0 +1,153 @@ +# SwiftUI List Patterns Reference + +## ForEach Identity and Stability + +**Always provide stable identity for `ForEach`.** Never use `.indices` for dynamic content. + +```swift +// Good - stable identity via Identifiable +extension User: Identifiable { + var id: String { userId } +} + +ForEach(users) { user in + UserRow(user: user) +} + +// Good - stable identity via keypath +ForEach(users, id: \.userId) { user in + UserRow(user: user) +} + +// Wrong - indices create static content +ForEach(users.indices, id: \.self) { index in + UserRow(user: users[index]) // Can crash on removal! +} + +// Wrong - unstable identity +ForEach(users, id: \.self) { user in + UserRow(user: user) // Only works if User is Hashable and stable +} +``` + +**Critical**: Ensure **constant number of views per element** in `ForEach`: + +```swift +// Good - consistent view count +ForEach(items) { item in + ItemRow(item: item) +} + +// Bad - variable view count breaks identity +ForEach(items) { item in + if item.isSpecial { + SpecialRow(item: item) + DetailRow(item: item) + } else { + RegularRow(item: item) + } +} +``` + +**Avoid inline filtering:** + +```swift +// Bad - unstable identity, changes on every update +ForEach(items.filter { $0.isEnabled }) { item in + ItemRow(item: item) +} + +// Good - prefilter and cache +@State private var enabledItems: [Item] = [] + +var body: some View { + ForEach(enabledItems) { item in + ItemRow(item: item) + } + .onChange(of: items) { _, newItems in + enabledItems = newItems.filter { $0.isEnabled } + } +} +``` + +**Avoid `AnyView` in list rows:** + +```swift +// Bad - hides identity, increases cost +ForEach(items) { item in + AnyView(item.isSpecial ? SpecialRow(item: item) : RegularRow(item: item)) +} + +// Good - Create a unified row view +ForEach(items) { item in + ItemRow(item: item) +} + +struct ItemRow: View { + let item: Item + + var body: some View { + if item.isSpecial { + SpecialRow(item: item) + } else { + RegularRow(item: item) + } + } +} +``` + +**Why**: Stable identity is critical for performance and animations. Unstable identity causes excessive diffing, broken animations, and potential crashes. + +## Enumerated Sequences + +**Always convert enumerated sequences to arrays. To be able to use them in a ForEach.** + +```swift +let items = ["A", "B", "C"] + +// Correct +ForEach(Array(items.enumerated()), id: \.offset) { index, item in + Text("\(index): \(item)") +} + +// Wrong - Doesn't compile, enumerated() isn't an array +ForEach(items.enumerated(), id: \.offset) { index, item in + Text("\(index): \(item)") +} +``` + +## List with Custom Styling + +```swift +// Remove default background and separators +List(items) { item in + ItemRow(item: item) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowSeparator(.hidden) +} +.listStyle(.plain) +.scrollContentBackground(.hidden) +.background(Color.customBackground) +.environment(\.defaultMinListRowHeight, 1) // Allows custom row heights +``` + +## List with Pull-to-Refresh + +```swift +List(items) { item in + ItemRow(item: item) +} +.refreshable { + await loadItems() +} +``` + +## Summary Checklist + +- [ ] ForEach uses stable identity (never `.indices` for dynamic content) +- [ ] Constant number of views per ForEach element +- [ ] No inline filtering in ForEach (prefilter and cache instead) +- [ ] No `AnyView` in list rows +- [ ] Don't convert enumerated sequences to arrays +- [ ] Use `.refreshable` for pull-to-refresh +- [ ] Custom list styling uses appropriate modifiers diff --git a/.agents/skills/swiftui-expert-skill/references/modern-apis.md b/.agents/skills/swiftui-expert-skill/references/modern-apis.md new file mode 100644 index 0000000..20a5457 --- /dev/null +++ b/.agents/skills/swiftui-expert-skill/references/modern-apis.md @@ -0,0 +1,400 @@ +# Modern SwiftUI APIs Reference + +## Overview + +This reference covers modern SwiftUI API usage patterns and deprecated API replacements. Always use the latest APIs to ensure forward compatibility and access to new features. + +## Styling and Appearance + +### foregroundStyle() vs foregroundColor() + +**Always use `foregroundStyle()` instead of `foregroundColor()`.** + +```swift +// Modern (Correct) +Text("Hello") + .foregroundStyle(.primary) + +Image(systemName: "star") + .foregroundStyle(.blue) + +// Legacy (Avoid) +Text("Hello") + .foregroundColor(.primary) +``` + +**Why**: `foregroundStyle()` supports hierarchical styles, gradients, and materials, making it more flexible and future-proof. + +### clipShape() vs cornerRadius() + +**Always use `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()`.** + +```swift +// Modern (Correct) +Image("photo") + .clipShape(.rect(cornerRadius: 12)) + +VStack { + // content +} +.clipShape(.rect(cornerRadius: 16)) + +// Legacy (Avoid) +Image("photo") + .cornerRadius(12) +``` + +**Why**: `cornerRadius()` is deprecated. `clipShape()` is more explicit and supports all shape types. + +### fontWeight() vs bold() + +**Don't apply `fontWeight()` unless there's a good reason. Always use `bold()` for bold text.** + +```swift +// Correct +Text("Important") + .bold() + +// Avoid (unless you need a specific weight) +Text("Important") + .fontWeight(.bold) + +// Acceptable (specific weight needed) +Text("Semibold") + .fontWeight(.semibold) +``` + +## Navigation + +### NavigationStack vs NavigationView + +**Always use `NavigationStack` instead of `NavigationView`.** + +```swift +// Modern (Correct) +NavigationStack { + List(items) { item in + NavigationLink(value: item) { + Text(item.name) + } + } + .navigationDestination(for: Item.self) { item in + DetailView(item: item) + } +} + +// Legacy (Avoid) +NavigationView { + List(items) { item in + NavigationLink(destination: DetailView(item: item)) { + Text(item.name) + } + } +} +``` + +### navigationDestination(for:) + +**Use `navigationDestination(for:)` for type-safe navigation.** + +```swift +struct ContentView: View { + var body: some View { + NavigationStack { + List { + NavigationLink("Profile", value: Route.profile) + NavigationLink("Settings", value: Route.settings) + } + .navigationDestination(for: Route.self) { route in + switch route { + case .profile: + ProfileView() + case .settings: + SettingsView() + } + } + } + } +} + +enum Route: Hashable { + case profile + case settings +} +``` + +## Tabs + +### Tab API vs tabItem() + +**For iOS 18 and later, prefer the `Tab` API over `tabItem()` to access modern tab features, and use availability checks or `tabItem()` for earlier OS versions.** + +```swift +// Modern (Correct) - iOS 18+ +TabView { + Tab("Home", systemImage: "house") { + HomeView() + } + + Tab("Search", systemImage: "magnifyingglass") { + SearchView() + } + + Tab("Profile", systemImage: "person") { + ProfileView() + } +} + +// Legacy (Avoid) +TabView { + HomeView() + .tabItem { + Label("Home", systemImage: "house") + } +} +``` + +**Important**: When using `Tab(role:)` with roles, you must use the new `Tab { } label: { }` syntax for all tabs. Mixing with `.tabItem()` causes compilation errors. + +```swift +// Correct - all tabs use Tab syntax +TabView { + Tab(role: .search) { + SearchView() + } label: { + Label("Search", systemImage: "magnifyingglass") + } + + Tab { + HomeView() + } label: { + Label("Home", systemImage: "house") + } +} + +// Wrong - mixing Tab and tabItem causes errors +TabView { + Tab(role: .search) { + SearchView() + } label: { + Label("Search", systemImage: "magnifyingglass") + } + + HomeView() // Error: can't mix with Tab(role:) + .tabItem { + Label("Home", systemImage: "house") + } +} +``` + +## Interactions + +### Button vs onTapGesture() + +**Never use `onTapGesture()` unless you specifically need tap location or tap count. Always use `Button` otherwise.** + +```swift +// Correct - standard tap action +Button("Tap me") { + performAction() +} + +// Correct - need tap location +Text("Tap anywhere") + .onTapGesture { location in + handleTap(at: location) + } + +// Correct - need tap count +Image("photo") + .onTapGesture(count: 2) { + handleDoubleTap() + } + +// Wrong - use Button instead +Text("Tap me") + .onTapGesture { + performAction() + } +``` + +**Why**: `Button` provides proper accessibility, visual feedback, and semantic meaning. Use `onTapGesture()` only when you need its specific features. + +### Button with Images + +**Always specify text alongside images in buttons for accessibility.** + +```swift +// Correct - includes text label +Button("Add Item", systemImage: "plus") { + addItem() +} + +// Also correct - custom label +Button { + addItem() +} label: { + Label("Add Item", systemImage: "plus") +} + +// Wrong - image only, no text +Button { + addItem() +} label: { + Image(systemName: "plus") +} +``` + +## Layout and Sizing + +### Avoid UIScreen.main.bounds + +**Never use `UIScreen.main.bounds` to read available space.** + +```swift +// Wrong - uses UIKit, doesn't respect safe areas +let screenWidth = UIScreen.main.bounds.width + +// Correct - use GeometryReader +GeometryReader { geometry in + Text("Width: \(geometry.size.width)") +} + +// Better - use containerRelativeFrame (iOS 17+) +Text("Full width") + .containerRelativeFrame(.horizontal) + +// Best - let SwiftUI handle sizing +Text("Auto-sized") + .frame(maxWidth: .infinity) +``` + +### GeometryReader Alternatives + +> **iOS 17+**: `containerRelativeFrame` and `visualEffect` require iOS 17 or later. + +**Don't use `GeometryReader` if a newer alternative works.** + +```swift +// Modern - containerRelativeFrame +Image("hero") + .resizable() + .containerRelativeFrame(.horizontal) { length, axis in + length * 0.8 + } + +// Modern - visualEffect for position-based effects +Text("Parallax") + .visualEffect { content, geometry in + content.offset(y: geometry.frame(in: .global).minY * 0.5) + } + +// Legacy - only use if necessary +GeometryReader { geometry in + Image("hero") + .frame(width: geometry.size.width * 0.8) +} +``` + +## Type Erasure + +### Avoid AnyView + +**Avoid `AnyView` unless absolutely required.** + +```swift +// Prefer - use @ViewBuilder +@ViewBuilder +func content() -> some View { + if condition { + Text("Option A") + } else { + Image(systemName: "photo") + } +} + +// Avoid - type erasure has performance cost +func content() -> AnyView { + if condition { + return AnyView(Text("Option A")) + } else { + return AnyView(Image(systemName: "photo")) + } +} + +// Acceptable - when protocol conformance requires it +var body: some View { + // Complex conditional logic that requires type erasure +} +``` + +## Styling Best Practices + +### Dynamic Type + +**Don't force specific font sizes. Prefer Dynamic Type.** + +```swift +// Correct - respects user's text size preferences +Text("Title") + .font(.title) + +Text("Body") + .font(.body) + +// Avoid - fixed size doesn't scale +Text("Title") + .font(.system(size: 24)) +``` + +### UIKit Colors + +**Avoid using UIKit colors in SwiftUI code.** + +```swift +// Correct - SwiftUI colors +Text("Hello") + .foregroundStyle(.blue) + .background(.gray.opacity(0.2)) + +// Wrong - UIKit colors +Text("Hello") + .foregroundColor(Color(UIColor.systemBlue)) + .background(Color(UIColor.systemGray)) +``` + +## Static Member Lookup + +**Prefer static member lookup to struct instances.** + +```swift +// Correct - static member lookup +Circle() + .fill(.blue) +Button("Action") { } + .buttonStyle(.borderedProminent) + +// Verbose - unnecessary struct instantiation +Circle() + .fill(Color.blue) +Button("Action") { } + .buttonStyle(BorderedProminentButtonStyle()) +``` + +## Summary Checklist + +- [ ] Use `foregroundStyle()` instead of `foregroundColor()` +- [ ] Use `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()` +- [ ] Use `Tab` API instead of `tabItem()` +- [ ] Use `Button` instead of `onTapGesture()` (unless need location/count) +- [ ] Use `NavigationStack` instead of `NavigationView` +- [ ] Use `navigationDestination(for:)` for type-safe navigation +- [ ] Avoid `AnyView` unless required +- [ ] Avoid `UIScreen.main.bounds` +- [ ] Avoid `GeometryReader` when alternatives exist +- [ ] Use Dynamic Type instead of fixed font sizes +- [ ] Avoid hard-coded padding/spacing unless requested +- [ ] Avoid UIKit colors in SwiftUI +- [ ] Use static member lookup (`.blue` vs `Color.blue`) +- [ ] Include text labels with button images +- [ ] Use `bold()` instead of `fontWeight(.bold)` diff --git a/.agents/skills/swiftui-expert-skill/references/performance-patterns.md b/.agents/skills/swiftui-expert-skill/references/performance-patterns.md new file mode 100644 index 0000000..5fd4864 --- /dev/null +++ b/.agents/skills/swiftui-expert-skill/references/performance-patterns.md @@ -0,0 +1,377 @@ +# SwiftUI Performance Patterns Reference + +## Performance Optimization + +### 1. Avoid Redundant State Updates + +SwiftUI doesn't compare values before triggering updates: + +```swift +// BAD - triggers update even if value unchanged +.onReceive(publisher) { value in + self.currentValue = value // Always triggers body re-evaluation +} + +// GOOD - only update when different +.onReceive(publisher) { value in + if self.currentValue != value { + self.currentValue = value + } +} +``` + +### 2. Optimize Hot Paths + +Hot paths are frequently executed code (scroll handlers, animations, gestures): + +```swift +// BAD - updates state on every scroll position change +.onPreferenceChange(ScrollOffsetKey.self) { offset in + shouldShowTitle = offset.y <= -32 // Fires constantly during scroll! +} + +// GOOD - only update when threshold crossed +.onPreferenceChange(ScrollOffsetKey.self) { offset in + let shouldShow = offset.y <= -32 + if shouldShow != shouldShowTitle { + shouldShowTitle = shouldShow // Fires only when crossing threshold + } +} +``` + +### 3. Pass Only What Views Need + +**Avoid passing large "config" or "context" objects.** Pass only the specific values each view needs. + +```swift +// Good - pass specific values +@Observable +@MainActor +final class AppConfig { + var theme: Theme + var fontSize: CGFloat + var notifications: Bool +} + +struct SettingsView: View { + @State private var config = AppConfig() + + var body: some View { + VStack { + ThemeSelector(theme: config.theme) + FontSizeSlider(fontSize: config.fontSize) + } + } +} + +// Avoid - passing entire config +struct SettingsView: View { + @State private var config = AppConfig() + + var body: some View { + VStack { + ThemeSelector(config: config) // Gets notified of ALL config changes + FontSizeSlider(config: config) // Gets notified of ALL config changes + } + } +} +``` + +**Why**: When using `ObservableObject`, any `@Published` property change triggers updates in all views observing the object. With `@Observable`, views update when properties they access change, but passing entire objects still creates unnecessary dependencies. + +### 4. Use Equatable Views + +For views with expensive bodies, conform to `Equatable`: + +```swift +struct ExpensiveView: View, Equatable { + let data: SomeData + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.data.id == rhs.data.id // Custom equality check + } + + var body: some View { + // Expensive computation + } +} + +// Usage +ExpensiveView(data: data) + .equatable() // Use custom equality +``` + +**Caution**: If you add new state or dependencies to your view, remember to update your `==` function! + +### 5. POD Views for Fast Diffing + +**POD (Plain Old Data) views use `memcmp` for fastest diffing.** A view is POD if it only contains simple value types and no property wrappers. + +```swift +// POD view - fastest diffing +struct FastView: View { + let title: String + let count: Int + + var body: some View { + Text("\(title): \(count)") + } +} + +// Non-POD view - uses reflection or custom equality +struct SlowerView: View { + let title: String + @State private var isExpanded = false // Property wrapper makes it non-POD + + var body: some View { + Text(title) + } +} +``` + +**Advanced Pattern**: Wrap expensive non-POD views in POD parent views: + +```swift +// POD wrapper for fast diffing +struct ExpensiveView: View { + let value: Int + + var body: some View { + ExpensiveViewInternal(value: value) + } +} + +// Internal view with state +private struct ExpensiveViewInternal: View { + let value: Int + @State private var item: Item? + + var body: some View { + // Expensive rendering + } +} +``` + +**Why**: The POD parent uses fast `memcmp` comparison. Only when `value` changes does the internal view get diffed. + +### 6. Lazy Loading + +Use lazy containers for large collections: + +```swift +// BAD - creates all views immediately +ScrollView { + VStack { + ForEach(items) { item in + ExpensiveRow(item: item) + } + } +} + +// GOOD - creates views on demand +ScrollView { + LazyVStack { + ForEach(items) { item in + ExpensiveRow(item: item) + } + } +} +``` + +### 7. Task Cancellation + +Cancel async work when view disappears: + +```swift +struct DataView: View { + @State private var data: [Item] = [] + + var body: some View { + List(data) { item in + Text(item.name) + } + .task { + // Automatically cancelled when view disappears + data = await fetchData() + } + } +} +``` + +### 8. Debug View Updates + +**Use `Self._printChanges()` to debug unexpected view updates.** + +```swift +struct DebugView: View { + @State private var count = 0 + @State private var name = "" + + var body: some View { + let _ = Self._printChanges() // Prints what caused body to be called + + VStack { + Text("Count: \(count)") + Text("Name: \(name)") + } + } +} +``` + +**Why**: This helps identify which state changes are causing view updates. Even if a parent updates, a child's body shouldn't be called if the child's dependencies didn't change. + +### 9. Eliminate Unnecessary Dependencies + +**Narrow state scope to reduce update fan-out.** + +```swift +// Bad - broad dependency +@Observable +@MainActor +final class AppModel { + var items: [Item] = [] + var settings: Settings = .init() + var theme: Theme = .light +} + +struct ItemRow: View { + @Environment(AppModel.self) private var model + let item: Item + + var body: some View { + // Updates when ANY property of model changes + Text(item.name) + .foregroundStyle(model.theme.primaryColor) + } +} + +// Good - narrow dependency +struct ItemRow: View { + let item: Item + let themeColor: Color // Only depends on what it needs + + var body: some View { + Text(item.name) + .foregroundStyle(themeColor) + } +} +``` + +**Why**: With `ObservableObject`, any `@Published` property change triggers all observers. With `@Observable`, views update when accessed properties change, but passing entire models still creates broader dependencies than necessary. + +### 10. Common Performance Issues + +**Be aware of common performance bottlenecks in SwiftUI:** + +- View invalidation storms from broad state changes +- Unstable identity in lists causing excessive diffing +- Heavy work in `body` (formatting, sorting, image decoding) +- Layout thrash from deep stacks or preference chains + +**When performance issues arise**, suggest the user profile with Instruments (SwiftUI template) to identify specific bottlenecks. + +## Anti-Patterns + +### 1. Creating Objects in Body + +```swift +// BAD - creates new formatter every body call +var body: some View { + let formatter = DateFormatter() + formatter.dateStyle = .long + return Text(formatter.string(from: date)) +} + +// GOOD - static or stored formatter +private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .long + return f +}() + +var body: some View { + Text(Self.dateFormatter.string(from: date)) +} +``` + +### 2. Heavy Computation in Body + +**Keep view body simple and pure.** Avoid side effects, dispatching, or complex logic. + +```swift +// BAD - sorts array every body call +var body: some View { + List(items.sorted { $0.name < $1.name }) { item in + Text(item.name) + } +} + +// GOOD - compute once, store result +@State private var sortedItems: [Item] = [] + +var body: some View { + List(sortedItems) { item in + Text(item.name) + } + .onChange(of: items) { _, newItems in + sortedItems = newItems.sorted { $0.name < $1.name } + } +} + +// Better - compute in model +@Observable +@MainActor +final class ItemsViewModel { + var items: [Item] = [] + + var sortedItems: [Item] { + items.sorted { $0.name < $1.name } + } + + func loadItems() async { + items = await fetchItems() + } +} + +struct ItemsView: View { + @State private var viewModel = ItemsViewModel() + + var body: some View { + List(viewModel.sortedItems) { item in + Text(item.name) + } + .task { + await viewModel.loadItems() + } + } +} +``` + +**Why**: Complex logic in `body` slows down view updates and can cause frame drops. The `body` should be a pure structural representation of state. + +### 3. Unnecessary State + +```swift +// BAD - derived state stored separately +@State private var items: [Item] = [] +@State private var itemCount: Int = 0 // Unnecessary! + +// GOOD - compute derived values +@State private var items: [Item] = [] + +var itemCount: Int { items.count } // Computed property +``` + +## Summary Checklist + +- [ ] State updates check for value changes before assigning +- [ ] Hot paths minimize state updates +- [ ] Pass only needed values to views (avoid large config objects) +- [ ] Large lists use `LazyVStack`/`LazyHStack` +- [ ] No object creation in `body` +- [ ] Heavy computation moved out of `body` +- [ ] Body kept simple and pure (no side effects) +- [ ] Derived state computed, not stored +- [ ] Use `Self._printChanges()` to debug unexpected updates +- [ ] Equatable conformance for expensive views (when appropriate) +- [ ] Consider POD view wrappers for advanced optimization diff --git a/.agents/skills/swiftui-expert-skill/references/scroll-patterns.md b/.agents/skills/swiftui-expert-skill/references/scroll-patterns.md new file mode 100644 index 0000000..a234267 --- /dev/null +++ b/.agents/skills/swiftui-expert-skill/references/scroll-patterns.md @@ -0,0 +1,305 @@ +# SwiftUI ScrollView Patterns Reference + +## ScrollView Modifiers + +### Hiding Scroll Indicators + +**Use `.scrollIndicators(.hidden)` modifier instead of initializer parameter.** + +```swift +// Modern (Correct) +ScrollView { + content +} +.scrollIndicators(.hidden) + +// Legacy (Avoid) +ScrollView(showsIndicators: false) { + content +} +``` + +## ScrollViewReader for Programmatic Scrolling + +**Use `ScrollViewReader` for scroll-to-top, scroll-to-bottom, and anchor-based jumps.** + +```swift +struct ChatView: View { + @State private var messages: [Message] = [] + private let bottomID = "bottom" + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack { + ForEach(messages) { message in + MessageRow(message: message) + .id(message.id) + } + Color.clear + .frame(height: 1) + .id(bottomID) + } + } + .onChange(of: messages.count) { _, _ in + withAnimation { + proxy.scrollTo(bottomID, anchor: .bottom) + } + } + .onAppear { + proxy.scrollTo(bottomID, anchor: .bottom) + } + } + } +} +``` + +### Scroll-to-Top Pattern + +```swift +struct FeedView: View { + @State private var items: [Item] = [] + @State private var scrollToTop = false + private let topID = "top" + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack { + Color.clear + .frame(height: 1) + .id(topID) + + ForEach(items) { item in + ItemRow(item: item) + } + } + } + .onChange(of: scrollToTop) { _, shouldScroll in + if shouldScroll { + withAnimation { + proxy.scrollTo(topID, anchor: .top) + } + scrollToTop = false + } + } + } + } +} +``` + +**Why**: `ScrollViewReader` provides programmatic scroll control with stable anchors. Always use stable IDs and explicit animations. + +## Scroll Position Tracking + +### Basic Scroll Position + +**Avoid** - Storing scroll position directly triggers view updates on every scroll frame: + +```swift +// ❌ Bad Practice - causes unnecessary re-renders +struct ContentView: View { + @State private var scrollPosition: CGFloat = 0 + + var body: some View { + ScrollView { + content + .background( + GeometryReader { geometry in + Color.clear + .preference( + key: ScrollOffsetPreferenceKey.self, + value: geometry.frame(in: .named("scroll")).minY + ) + } + ) + } + .coordinateSpace(name: "scroll") + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in + scrollPosition = value + } + } +} +``` + +**Preferred** - Check scroll position and update a flag based on thresholds for smoother, more efficient scrolling: + +```swift +// ✅ Good Practice - only updates state when crossing threshold +struct ContentView: View { + @State private var startAnimation: Bool = false + + var body: some View { + ScrollView { + content + .background( + GeometryReader { geometry in + Color.clear + .preference( + key: ScrollOffsetPreferenceKey.self, + value: geometry.frame(in: .named("scroll")).minY + ) + } + ) + } + .coordinateSpace(name: "scroll") + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in + if value < -100 { + startAnimation = true + } else { + startAnimation = false + } + } + } +} + +struct ScrollOffsetPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} +``` + +### Scroll-Based Header Visibility + +```swift +struct ContentView: View { + @State private var showHeader = true + + var body: some View { + VStack(spacing: 0) { + if showHeader { + HeaderView() + .transition(.move(edge: .top)) + } + + ScrollView { + content + .background( + GeometryReader { geometry in + Color.clear + .preference( + key: ScrollOffsetPreferenceKey.self, + value: geometry.frame(in: .named("scroll")).minY + ) + } + ) + } + .coordinateSpace(name: "scroll") + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in + if offset < -50 { // Scrolling down + withAnimation { showHeader = false } + } else if offset > 50 { // Scrolling up + withAnimation { showHeader = true } + } + } + } + } +} +``` + +## Scroll Transitions and Effects + +> **iOS 17+**: All APIs in this section require iOS 17 or later. + +### Scroll-Based Opacity + +```swift +struct ParallaxView: View { + var body: some View { + ScrollView { + LazyVStack(spacing: 20) { + ForEach(items) { item in + ItemCard(item: item) + .visualEffect { content, geometry in + let frame = geometry.frame(in: .scrollView) + let distance = min(0, frame.minY) + return content + .opacity(1 + distance / 200) + } + } + } + } + } +} +``` + +### Parallax Effect + +```swift +struct ParallaxHeader: View { + var body: some View { + ScrollView { + VStack(spacing: 0) { + Image("hero") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: 300) + .visualEffect { content, geometry in + let offset = geometry.frame(in: .scrollView).minY + return content + .offset(y: offset > 0 ? -offset * 0.5 : 0) + } + .clipped() + + ContentView() + } + } + } +} +``` + +## Scroll Target Behavior + +> **iOS 17+**: All APIs in this section require iOS 17 or later. + +### Paging ScrollView + +```swift +struct PagingView: View { + var body: some View { + ScrollView(.horizontal) { + LazyHStack(spacing: 0) { + ForEach(pages) { page in + PageView(page: page) + .containerRelativeFrame(.horizontal) + } + } + .scrollTargetLayout() + } + .scrollTargetBehavior(.paging) + } +} +``` + +### Snap to Items + +```swift +struct SnapScrollView: View { + var body: some View { + ScrollView(.horizontal) { + LazyHStack(spacing: 16) { + ForEach(items) { item in + ItemCard(item: item) + .frame(width: 280) + } + } + .scrollTargetLayout() + } + .scrollTargetBehavior(.viewAligned) + .contentMargins(.horizontal, 20) + } +} +``` + +## Summary Checklist + +- [ ] Use `.scrollIndicators(.hidden)` instead of initializer parameter +- [ ] Use `ScrollViewReader` with stable IDs for programmatic scrolling +- [ ] Always use explicit animations with `scrollTo()` +- [ ] Use `.visualEffect` for scroll-based visual changes +- [ ] Use `.scrollTargetBehavior(.paging)` for paging behavior +- [ ] Use `.scrollTargetBehavior(.viewAligned)` for snap-to-item behavior +- [ ] Gate frequent scroll position updates by thresholds +- [ ] Use preference keys for custom scroll position tracking diff --git a/.agents/skills/swiftui-expert-skill/references/sheet-navigation-patterns.md b/.agents/skills/swiftui-expert-skill/references/sheet-navigation-patterns.md new file mode 100644 index 0000000..467ffca --- /dev/null +++ b/.agents/skills/swiftui-expert-skill/references/sheet-navigation-patterns.md @@ -0,0 +1,292 @@ +# SwiftUI Sheet and Navigation Patterns Reference + +## Sheet Patterns + +### Item-Driven Sheets (Preferred) + +**Use `.sheet(item:)` instead of `.sheet(isPresented:)` when presenting model-based content.** + +```swift +// Good - item-driven +@State private var selectedItem: Item? + +var body: some View { + List(items) { item in + Button(item.name) { + selectedItem = item + } + } + .sheet(item: $selectedItem) { item in + ItemDetailSheet(item: item) + } +} + +// Avoid - boolean flag requires separate state +@State private var showSheet = false +@State private var selectedItem: Item? + +var body: some View { + List(items) { item in + Button(item.name) { + selectedItem = item + showSheet = true + } + } + .sheet(isPresented: $showSheet) { + if let selectedItem { + ItemDetailSheet(item: selectedItem) + } + } +} +``` + +**Why**: `.sheet(item:)` automatically handles presentation state and avoids optional unwrapping in the sheet body. + +### Sheets Own Their Actions + +**Sheets should handle their own dismiss and actions internally.** + +```swift +// Good - sheet owns its actions +struct EditItemSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(DataStore.self) private var store + + let item: Item + @State private var name: String + @State private var isSaving = false + + init(item: Item) { + self.item = item + _name = State(initialValue: item.name) + } + + var body: some View { + NavigationStack { + Form { + TextField("Name", text: $name) + } + .navigationTitle("Edit Item") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button(isSaving ? "Saving..." : "Save") { + Task { await save() } + } + .disabled(isSaving || name.isEmpty) + } + } + } + } + + private func save() async { + isSaving = true + await store.updateItem(item, name: name) + dismiss() + } +} + +// Avoid - parent manages sheet actions via closures +struct ParentView: View { + @State private var selectedItem: Item? + + var body: some View { + List(items) { item in + Button(item.name) { + selectedItem = item + } + } + .sheet(item: $selectedItem) { item in + EditItemSheet( + item: item, + onSave: { newName in + // Parent handles save + }, + onCancel: { + selectedItem = nil + } + ) + } + } +} +``` + +**Why**: Sheets that own their actions are more reusable and don't require callback prop-drilling. + +## Navigation Patterns + +### Type-Safe Navigation with NavigationStack + +```swift +struct ContentView: View { + var body: some View { + NavigationStack { + List { + NavigationLink("Profile", value: Route.profile) + NavigationLink("Settings", value: Route.settings) + } + .navigationDestination(for: Route.self) { route in + switch route { + case .profile: + ProfileView() + case .settings: + SettingsView() + } + } + } + } +} + +enum Route: Hashable { + case profile + case settings +} +``` + +### Programmatic Navigation + +```swift +struct ContentView: View { + @State private var navigationPath = NavigationPath() + + var body: some View { + NavigationStack(path: $navigationPath) { + List { + Button("Go to Detail") { + navigationPath.append(DetailRoute.item(id: 1)) + } + } + .navigationDestination(for: DetailRoute.self) { route in + switch route { + case .item(let id): + ItemDetailView(id: id) + } + } + } + } +} + +enum DetailRoute: Hashable { + case item(id: Int) +} +``` + +### Navigation with State Restoration + +```swift +struct ContentView: View { + @State private var navigationPath = NavigationPath() + + var body: some View { + NavigationStack(path: $navigationPath) { + RootView() + .navigationDestination(for: Route.self) { route in + destinationView(for: route) + } + } + } + + @ViewBuilder + private func destinationView(for route: Route) -> some View { + switch route { + case .profile: + ProfileView() + case .settings: + SettingsView() + } + } +} +``` + +## Presentation Modifiers + +### Full Screen Cover + +```swift +struct ContentView: View { + @State private var showFullScreen = false + + var body: some View { + Button("Show Full Screen") { + showFullScreen = true + } + .fullScreenCover(isPresented: $showFullScreen) { + FullScreenView() + } + } +} +``` + +### Popover + +```swift +struct ContentView: View { + @State private var showPopover = false + + var body: some View { + Button("Show Popover") { + showPopover = true + } + .popover(isPresented: $showPopover) { + PopoverContentView() + .presentationCompactAdaptation(.popover) // Don't adapt to sheet on iPhone + } + } +} +``` + +### Alert with Actions + +```swift +struct ContentView: View { + @State private var showAlert = false + + var body: some View { + Button("Show Alert") { + showAlert = true + } + .alert("Delete Item?", isPresented: $showAlert) { + Button("Delete", role: .destructive) { + deleteItem() + } + Button("Cancel", role: .cancel) { } + } message: { + Text("This action cannot be undone.") + } + } +} +``` + +### Confirmation Dialog + +```swift +struct ContentView: View { + @State private var showDialog = false + + var body: some View { + Button("Show Options") { + showDialog = true + } + .confirmationDialog("Choose an option", isPresented: $showDialog) { + Button("Option 1") { handleOption1() } + Button("Option 2") { handleOption2() } + Button("Cancel", role: .cancel) { } + } + } +} +``` + +## Summary Checklist + +- [ ] Use `.sheet(item:)` for model-based sheets +- [ ] Sheets own their actions and dismiss internally +- [ ] Use `NavigationStack` with `navigationDestination(for:)` for type-safe navigation +- [ ] Use `NavigationPath` for programmatic navigation +- [ ] Use appropriate presentation modifiers (sheet, fullScreenCover, popover) +- [ ] Alerts and confirmation dialogs use modern API with actions +- [ ] Avoid passing dismiss/save callbacks to sheets +- [ ] Navigation state can be saved/restored when needed diff --git a/.agents/skills/swiftui-expert-skill/references/state-management.md b/.agents/skills/swiftui-expert-skill/references/state-management.md new file mode 100644 index 0000000..94f69f0 --- /dev/null +++ b/.agents/skills/swiftui-expert-skill/references/state-management.md @@ -0,0 +1,447 @@ +# SwiftUI State Management Reference + +## Property Wrapper Selection Guide + +| Wrapper | Use When | Notes | +|---------|----------|-------| +| `@State` | Internal view state that triggers updates | Must be `private` | +| `@Binding` | Child view needs to modify parent's state | Don't use for read-only | +| `@Bindable` | iOS 17+: View receives `@Observable` object and needs bindings | For injected observables | +| `let` | Read-only value passed from parent | Simplest option | +| `var` | Read-only value that child observes via `.onChange()` | For reactive reads | + +**Legacy (Pre-iOS 17):** +| Wrapper | Use When | Notes | +|---------|----------|-------| +| `@StateObject` | View owns an `ObservableObject` instance | Use `@State` with `@Observable` instead | +| `@ObservedObject` | View receives an `ObservableObject` from outside | Never create inline | + +## @State + +Always mark `@State` properties as `private`. Use for internal view state that triggers UI updates. + +```swift +// Correct +@State private var isAnimating = false +@State private var selectedTab = 0 +``` + +**Why Private?** Marking state as `private` makes it clear what's created by the view versus what's passed in. It also prevents accidentally passing initial values that will be ignored (see "Don't Pass Values as @State" below). + +### iOS 17+ with @Observable (Preferred) + +**Always prefer `@Observable` over `ObservableObject`.** With iOS 17's `@Observable` macro, use `@State` instead of `@StateObject`: + +```swift +@Observable +@MainActor // Always mark @Observable classes with @MainActor +final class DataModel { + var name = "Some Name" + var count = 0 +} + +struct MyView: View { + @State private var model = DataModel() // Use @State, not @StateObject + + var body: some View { + VStack { + TextField("Name", text: $model.name) + Stepper("Count: \(model.count)", value: $model.count) + } + } +} +``` + +**Note**: You may want to mark `@Observable` classes with `@MainActor` to ensure thread safety with SwiftUI, unless your project or package uses Default Actor Isolation set to `MainActor`—in which case, the explicit attribute is redundant and can be omitted. + +## @Binding + +Use only when child view needs to **modify** parent's state. If child only reads the value, use `let` instead. + +```swift +// Parent +struct ParentView: View { + @State private var isSelected = false + + var body: some View { + ChildView(isSelected: $isSelected) + } +} + +// Child - will modify the value +struct ChildView: View { + @Binding var isSelected: Bool + + var body: some View { + Button("Toggle") { + isSelected.toggle() + } + } +} +``` + +### When NOT to use @Binding + +```swift +// Bad - child only displays, doesn't modify +struct DisplayView: View { + @Binding var title: String // Unnecessary + var body: some View { + Text(title) + } +} + +// Good - use let for read-only +struct DisplayView: View { + let title: String + var body: some View { + Text(title) + } +} +``` + +## @StateObject vs @ObservedObject (Legacy - Pre-iOS 17) + +**Note**: These are legacy patterns. Always prefer `@Observable` with `@State` for iOS 17+. + +The key distinction is **ownership**: + +- `@StateObject`: View **creates and owns** the object +- `@ObservedObject`: View **receives** the object from outside + +```swift +// Legacy pattern - use @Observable instead +class MyViewModel: ObservableObject { + @Published var items: [String] = [] +} + +// View creates it → @StateObject +struct OwnerView: View { + @StateObject private var viewModel = MyViewModel() + + var body: some View { + ChildView(viewModel: viewModel) + } +} + +// View receives it → @ObservedObject +struct ChildView: View { + @ObservedObject var viewModel: MyViewModel + + var body: some View { + List(viewModel.items, id: \.self) { Text($0) } + } +} +``` + +### Common Mistake + +Never create an `ObservableObject` inline with `@ObservedObject`: + +```swift +// WRONG - creates new instance on every view update +struct BadView: View { + @ObservedObject var viewModel = MyViewModel() // BUG! +} + +// CORRECT - owned objects use @StateObject +struct GoodView: View { + @StateObject private var viewModel = MyViewModel() +} +``` + +### @StateObject instantiation in View's initializer +If you need to create a @StateObject with initialization parameters in your view's custom initializer, be aware of redundant allocations and hidden side effects. + +```swift +// WRONG - creates a new ViewModel instance each time the view's initializer is called +// (which can happen multiple times during SwiftUI's structural identity evaluation) +struct MovieDetailsView: View { + + @StateObject private var viewModel: MovieDetailsViewModel + + init(movie: Movie) { + let viewModel = MovieDetailsViewModel(movie: movie) + _viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + // ... + } +} + +// CORRECT - creation in @autoclosure prevents multiple instantiations +struct MovieDetailsView: View { + + @StateObject private var viewModel: MovieDetailsViewModel + + init(movie: Movie) { + _viewModel = StateObject( + wrappedValue: MovieDetailsViewModel(movie: movie) + ) + } + + var body: some View { + // ... + } +} +``` + +**Modern Alternative**: Use `@Observable` with `@State` instead of `ObservableObject` patterns. + +## Don't Pass Values as @State + +**Critical**: Never declare passed values as `@State` or `@StateObject`. The value you provide is only an initial value and won't update. + +```swift +// Parent +struct ParentView: View { + @State private var item = Item(name: "Original") + + var body: some View { + ChildView(item: item) + Button("Change") { + item.name = "Updated" // Child won't see this! + } + } +} + +// Wrong - child ignores updates from parent +struct ChildView: View { + @State var item: Item // Accepts initial value only! + + var body: some View { + Text(item.name) // Shows "Original" forever + } +} + +// Correct - child receives updates +struct ChildView: View { + let item: Item // Or @Binding if child needs to modify + + var body: some View { + Text(item.name) // Updates when parent changes + } +} +``` + +**Why**: `@State` and `@StateObject` retain values between view updates. That's their purpose. When a parent passes a new value, the child reuses its existing state. + +**Prevention**: Always mark `@State` and `@StateObject` as `private`. This prevents them from appearing in the generated initializer. + +## @Bindable (iOS 17+) + +Use when receiving an `@Observable` object from outside and needing bindings: + +```swift +@Observable +final class UserModel { + var name = "" + var email = "" +} + +struct ParentView: View { + @State private var user = UserModel() + + var body: some View { + EditUserView(user: user) + } +} + +struct EditUserView: View { + @Bindable var user: UserModel // Received from parent, needs bindings + + var body: some View { + Form { + TextField("Name", text: $user.name) + TextField("Email", text: $user.email) + } + } +} +``` + +## let vs var for Passed Values + +### Use `let` for read-only display + +```swift +struct ProfileHeader: View { + let username: String + let avatarURL: URL + + var body: some View { + HStack { + AsyncImage(url: avatarURL) + Text(username) + } + } +} +``` + +### Use `var` when reacting to changes with `.onChange()` + +```swift +struct ReactiveView: View { + var externalValue: Int // Watch with .onChange() + @State private var displayText = "" + + var body: some View { + Text(displayText) + .onChange(of: externalValue) { oldValue, newValue in + displayText = "Changed from \(oldValue) to \(newValue)" + } + } +} +``` + +## Environment and Preferences + +### @Environment + +Access environment values provided by SwiftUI or parent views: + +```swift +struct MyView: View { + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + + var body: some View { + Button("Done") { dismiss() } + .foregroundStyle(colorScheme == .dark ? .white : .black) + } +} +``` + +### @Environment with @Observable (iOS 17+ - Preferred) + +**Always prefer this pattern** for sharing state through the environment: + +```swift +@Observable +@MainActor +final class AppState { + var isLoggedIn = false +} + +// Inject +ContentView() + .environment(AppState()) + +// Access +struct ChildView: View { + @Environment(AppState.self) private var appState +} +``` + +### @EnvironmentObject (Legacy - Pre-iOS 17) + +Legacy pattern for sharing observable objects through the environment: + +```swift +// Legacy pattern - use @Observable with @Environment instead +class AppState: ObservableObject { + @Published var isLoggedIn = false +} + +// Inject at root +ContentView() + .environmentObject(AppState()) + +// Access in child +struct ChildView: View { + @EnvironmentObject var appState: AppState +} +``` + +## Decision Flowchart + +``` +Is this value owned by this view? +├─ YES: Is it a simple value type? +│ ├─ YES → @State private var +│ └─ NO (class): +│ ├─ Use @Observable → @State private var (mark class @MainActor) +│ └─ Legacy ObservableObject → @StateObject private var +│ +└─ NO (passed from parent): + ├─ Does child need to MODIFY it? + │ ├─ YES → @Binding var + │ └─ NO: Does child need BINDINGS to its properties? + │ ├─ YES (@Observable) → @Bindable var + │ └─ NO: Does child react to changes? + │ ├─ YES → var + .onChange() + │ └─ NO → let + │ + └─ Is it a legacy ObservableObject from parent? + └─ YES → @ObservedObject var (consider migrating to @Observable) +``` + +## State Privacy Rules + +**All view-owned state should be `private`:** + +```swift +// Correct - clear what's created vs passed +struct MyView: View { + // Created by view - private + @State private var isExpanded = false + @State private var viewModel = ViewModel() + @AppStorage("theme") private var theme = "light" + @Environment(\.colorScheme) private var colorScheme + + // Passed from parent - not private + let title: String + @Binding var isSelected: Bool + @Bindable var user: User + + var body: some View { + // ... + } +} +``` + +**Why**: This makes dependencies explicit and improves code completion for the generated initializer. + +## Avoid Nested ObservableObject + +**Note**: This limitation only applies to `ObservableObject`. `@Observable` fully supports nested observed objects. + +```swift +// Avoid - breaks animations and change tracking +class Parent: ObservableObject { + @Published var child: Child // Nested ObservableObject +} + +class Child: ObservableObject { + @Published var value: Int +} + +// Workaround - pass child directly to views +struct ParentView: View { + @StateObject private var parent = Parent() + + var body: some View { + ChildView(child: parent.child) // Pass nested object directly + } +} + +struct ChildView: View { + @ObservedObject var child: Child + + var body: some View { + Text("\(child.value)") + } +} +``` + +**Why**: SwiftUI can't track changes through nested `ObservableObject` properties. Manual workarounds break animations. With `@Observable`, this isn't an issue. + +## Key Principles + +1. **Always prefer `@Observable` over `ObservableObject`** for new code +2. **Mark `@Observable` classes with `@MainActor` for thread safety (unless using default actor isolation)`** +3. Use `@State` with `@Observable` classes (not `@StateObject`) +4. Use `@Bindable` for injected `@Observable` objects that need bindings +5. **Always mark `@State` and `@StateObject` as `private`** +6. **Never declare passed values as `@State` or `@StateObject`** +7. With `@Observable`, nested objects work fine; with `ObservableObject`, pass nested objects directly to child views diff --git a/.agents/skills/swiftui-expert-skill/references/text-formatting.md b/.agents/skills/swiftui-expert-skill/references/text-formatting.md new file mode 100644 index 0000000..9bce545 --- /dev/null +++ b/.agents/skills/swiftui-expert-skill/references/text-formatting.md @@ -0,0 +1,285 @@ +# SwiftUI Text Formatting Reference + +## Modern Text Formatting + +**Never use C-style `String(format:)` with Text. Always use format parameters.** + +## Number Formatting + +### Basic Number Formatting + +```swift +let value = 42.12345 + +// Modern (Correct) +Text(value, format: .number.precision(.fractionLength(2))) +// Output: "42.12" + +Text(abs(value), format: .number.precision(.fractionLength(2))) +// Output: "42.12" (absolute value) + +// Legacy (Avoid) +Text(String(format: "%.2f", abs(value))) +``` + +### Integer Formatting + +```swift +let count = 1234567 + +// With grouping separator +Text(count, format: .number) +// Output: "1,234,567" (locale-dependent) + +// Without grouping +Text(count, format: .number.grouping(.never)) +// Output: "1234567" +``` + +### Decimal Precision + +```swift +let price = 19.99 + +// Fixed decimal places +Text(price, format: .number.precision(.fractionLength(2))) +// Output: "19.99" + +// Significant digits +Text(price, format: .number.precision(.significantDigits(3))) +// Output: "20.0" + +// Integer-only +Text(price, format: .number.precision(.integerLength(1...))) +// Output: "19" +``` + +## Currency Formatting + +```swift +let price = 19.99 + +// Correct - with currency code +Text(price, format: .currency(code: "USD")) +// Output: "$19.99" + +// With locale +Text(price, format: .currency(code: "EUR").locale(Locale(identifier: "de_DE"))) +// Output: "19,99 €" + +// Avoid - manual formatting +Text(String(format: "$%.2f", price)) +``` + +## Percentage Formatting + +```swift +let percentage = 0.856 + +// Correct - with precision +Text(percentage, format: .percent.precision(.fractionLength(1))) +// Output: "85.6%" + +// Without decimal places +Text(percentage, format: .percent.precision(.fractionLength(0))) +// Output: "86%" + +// Avoid - manual calculation +Text(String(format: "%.1f%%", percentage * 100)) +``` + +## Date and Time Formatting + +### Date Formatting + +```swift +let date = Date() + +// Date only +Text(date, format: .dateTime.day().month().year()) +// Output: "Jan 23, 2026" + +// Full date +Text(date, format: .dateTime.day().month(.wide).year()) +// Output: "January 23, 2026" + +// Short date +Text(date, style: .date) +// Output: "1/23/26" +``` + +### Time Formatting + +```swift +let date = Date() + +// Time only +Text(date, format: .dateTime.hour().minute()) +// Output: "2:30 PM" + +// With seconds +Text(date, format: .dateTime.hour().minute().second()) +// Output: "2:30:45 PM" + +// 24-hour format +Text(date, format: .dateTime.hour(.defaultDigits(amPM: .omitted)).minute()) +// Output: "14:30" +``` + +### Relative Date Formatting + +```swift +let futureDate = Date().addingTimeInterval(3600) + +// Relative formatting +Text(futureDate, style: .relative) +// Output: "in 1 hour" + +Text(futureDate, style: .timer) +// Output: "59:59" (counts down) +``` + +## String Searching and Comparison + +### Localized String Comparison + +**Use `localizedStandardContains()` for user-input filtering, not `contains()`.** + +```swift +let searchText = "café" +let items = ["Café Latte", "Coffee", "Tea"] + +// Correct - handles diacritics and case +let filtered = items.filter { $0.localizedStandardContains(searchText) } +// Matches "Café Latte" + +// Wrong - exact match only +let filtered = items.filter { $0.contains(searchText) } +// Might not match "Café Latte" depending on normalization +``` + +**Why**: `localizedStandardContains()` handles case-insensitive, diacritic-insensitive matching appropriate for user-facing search. + +### Case-Insensitive Comparison + +```swift +let text = "Hello World" +let search = "hello" + +// Correct - case-insensitive +if text.localizedCaseInsensitiveContains(search) { + // Match found +} + +// Also correct - for exact comparison +if text.lowercased() == search.lowercased() { + // Equal +} +``` + +### Localized Sorting + +```swift +let names = ["Zoë", "Zara", "Åsa"] + +// Correct - locale-aware sorting +let sorted = names.sorted { $0.localizedStandardCompare($1) == .orderedAscending } +// Output: ["Åsa", "Zara", "Zoë"] + +// Wrong - byte-wise sorting +let sorted = names.sorted() +// Output may not be correct for all locales +``` + +## Attributed Strings + +### Basic Attributed Text + +```swift +// Using Text concatenation +Text("Hello ") + .foregroundStyle(.primary) ++ Text("World") + .foregroundStyle(.blue) + .bold() + +// Using AttributedString +var attributedString = AttributedString("Hello World") +attributedString.foregroundColor = .primary +if let range = attributedString.range(of: "World") { + attributedString[range].foregroundColor = .blue + attributedString[range].font = .body.bold() +} +Text(attributedString) +``` + +### Markdown in Text + +```swift +// Simple markdown +Text("This is **bold** and this is *italic*") + +// With links +Text("Visit [Apple](https://apple.com) for more info") + +// Multiline markdown +Text(""" +# Title +This is a paragraph with **bold** text. +- Item 1 +- Item 2 +""") +``` + +## Text Measurement + +### Measuring Text Height + +```swift +// Wrong (Legacy) - GeometryReader trick +struct MeasuredText: View { + let text: String + @State private var textHeight: CGFloat = 0 + + var body: some View { + Text(text) + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + textWidth = geometry.size.height + } + } + ) + } +} + +// Modern (correct) +struct MeasuredText: View { + let text: String + @State private var textHeight: CGFloat = 0 + + var body: some View { + Text(text) + .onGeometryChange(for: CGFloat.self) { geometry in + geometry.size.height + } action: { newValue in + textHeight = newValue + } + } +} +``` + +## Summary Checklist + +- [ ] Use `.format` parameters with Text instead of `String(format:)` +- [ ] Use `.currency(code:)` for currency formatting +- [ ] Use `.percent` for percentage formatting +- [ ] Use `.dateTime` for date/time formatting +- [ ] Use `localizedStandardContains()` for user-input search +- [ ] Use `localizedStandardCompare()` for locale-aware sorting +- [ ] Use Text concatenation or AttributedString for styled text +- [ ] Use markdown syntax for simple text formatting +- [ ] All formatting respects user's locale and preferences + +**Why**: Modern format parameters are type-safe, localization-aware, and integrate better with SwiftUI's text rendering. diff --git a/.agents/skills/swiftui-expert-skill/references/view-structure.md b/.agents/skills/swiftui-expert-skill/references/view-structure.md new file mode 100644 index 0000000..43e8861 --- /dev/null +++ b/.agents/skills/swiftui-expert-skill/references/view-structure.md @@ -0,0 +1,276 @@ +# SwiftUI View Structure Reference + +## View Structure Principles + +SwiftUI's diffing algorithm compares view hierarchies to determine what needs updating. Proper view composition directly impacts performance. + +## Prefer Modifiers Over Conditional Views + +**Prefer "no-effect" modifiers over conditionally including views.** When you introduce a branch, consider whether you're representing multiple views or two states of the same view. + +### Use Opacity Instead of Conditional Inclusion + +```swift +// Good - same view, different states +SomeView() + .opacity(isVisible ? 1 : 0) + +// Avoid - creates/destroys view identity +if isVisible { + SomeView() +} +``` + +**Why**: Conditional view inclusion can cause loss of state, poor animation performance, and breaks view identity. Using modifiers maintains view identity across state changes. + +### When Conditionals Are Appropriate + +Use conditionals when you truly have **different views**, not different states: + +```swift +// Correct - fundamentally different views +if isLoggedIn { + DashboardView() +} else { + LoginView() +} + +// Correct - optional content +if let user { + UserProfileView(user: user) +} +``` + +## Extract Subviews, Not Computed Properties + +### The Problem with @ViewBuilder Functions + +When you use `@ViewBuilder` functions or computed properties for complex views, the entire function re-executes on every parent state change: + +```swift +// BAD - re-executes complexSection() on every tap +struct ParentView: View { + @State private var count = 0 + + var body: some View { + VStack { + Button("Tap: \(count)") { count += 1 } + complexSection() // Re-executes every tap! + } + } + + @ViewBuilder + func complexSection() -> some View { + // Complex views that re-execute unnecessarily + ForEach(0..<100) { i in + HStack { + Image(systemName: "star") + Text("Item \(i)") + Spacer() + Text("Detail") + } + } + } +} +``` + +### The Solution: Separate Structs + +Extract to separate `struct` views. SwiftUI can skip their `body` when inputs don't change: + +```swift +// GOOD - ComplexSection body SKIPPED when its inputs don't change +struct ParentView: View { + @State private var count = 0 + + var body: some View { + VStack { + Button("Tap: \(count)") { count += 1 } + ComplexSection() // Body skipped during re-evaluation + } + } +} + +struct ComplexSection: View { + var body: some View { + ForEach(0..<100) { i in + HStack { + Image(systemName: "star") + Text("Item \(i)") + Spacer() + Text("Detail") + } + } + } +} +``` + +### Why This Works + +1. SwiftUI compares the `ComplexSection` struct (which has no properties) +2. Since nothing changed, SwiftUI skips calling `ComplexSection.body` +3. The complex view code never executes unnecessarily + +## When @ViewBuilder Functions Are Acceptable + +Use for small, simple sections that don't affect performance: + +```swift +struct SimpleView: View { + @State private var showDetails = false + + var body: some View { + VStack { + headerSection() // OK - simple, few views + if showDetails { + detailsSection() + } + } + } + + @ViewBuilder + private func headerSection() -> some View { + HStack { + Text("Title") + Spacer() + Button("Toggle") { showDetails.toggle() } + } + } + + @ViewBuilder + private func detailsSection() -> some View { + Text("Some details here") + .font(.caption) + } +} +``` + +## When to Extract Subviews + +Extract complex views into separate subviews when: +- The view has multiple logical sections or responsibilities +- The view contains reusable components +- The view body becomes difficult to read or understand +- You need to isolate state changes for performance +- The view is becoming large (keep views small for better performance) + +## Container View Pattern + +### Avoid Closure-Based Content + +Closures can't be compared, causing unnecessary re-renders: + +```swift +// BAD - closure prevents SwiftUI from skipping updates +struct MyContainer: View { + let content: () -> Content + + var body: some View { + VStack { + Text("Header") + content() // Always called, can't compare closures + } + } +} + +// Usage forces re-render on every parent update +MyContainer { + ExpensiveView() +} +``` + +### Use @ViewBuilder Property Instead + +```swift +// GOOD - view can be compared +struct MyContainer: View { + @ViewBuilder let content: Content + + var body: some View { + VStack { + Text("Header") + content // SwiftUI can compare and skip if unchanged + } + } +} + +// Usage - SwiftUI can diff ExpensiveView +MyContainer { + ExpensiveView() +} +``` + +## ZStack vs overlay/background + +Use `ZStack` to **compose multiple peer views** that should be layered together and jointly define layout. + +Prefer `overlay` / `background` when you’re **decorating a primary view**. +Not primarily because they don’t affect layout size, but because they **express intent and improve readability**: the view being modified remains the clear layout anchor. + +A key difference is **size proposal behavior**: +- In `overlay` / `background`, the child view implicitly adopts the size proposed to the parent when it doesn’t define its own size, making decorative attachments feel natural and predictable. +- In `ZStack`, each child participates independently in layout, and no implicit size inheritance exists. This makes it better suited for peer composition, but less intuitive for simple decoration. + +Use `ZStack` (or another container) when the “decoration” **must explicitly participate in layout sizing**—for example, when reserving space, extending tappable/visible bounds, or preventing overlap with neighboring views. + +### Examples: Choosing Between overlay/background and ZStack + +```swift +// GOOD - correct usage +// Decoration that should not change layout sizing belongs in overlay/background +Button("Continue") { + // action +} +.overlay(alignment: .trailing) { + Image(systemName: "lock.fill") + .padding(.trailing, 8) +} + +// BAD - incorrect usage +// Using ZStack when overlay/background is enough and layout sizing should remain anchored to the button +ZStack(alignment: .trailing) { + Button("Continue") { + // action + } + Image(systemName: "lock.fill") + .padding(.trailing, 8) +} + +// GOOD - correct usage +// Capsule is taking a parent size for rendering +HStack(spacing: 12) { + HStack { + Image(systemName: "tray") + Text("Inbox") + } + Text("Next") +} +.background { + Capsule() + .strokeBorder(.blue, lineWidth: 2) +} + +// BAD - incorrect usage +// overlay does not contribute to measured size, so the Capsule is taking all available space if no explicit size is set +ZStack(alignment: .topTrailing) { + HStack(spacing: 12) { + HStack { + Image(systemName: "tray") + Text("Inbox") + } + Text("Next") + } + + Capsule() + .strokeBorder(.blue, lineWidth: 2) +} +``` + +## Summary Checklist + +- [ ] Prefer modifiers over conditional views for state changes +- [ ] Complex views extracted to separate subviews +- [ ] Views kept small for better performance +- [ ] `@ViewBuilder` functions only for simple sections +- [ ] Container views use `@ViewBuilder let content: Content` +- [ ] Extract views when they have multiple responsibilities or become hard to read diff --git a/.codex/skills/swiftui-expert-skill b/.codex/skills/swiftui-expert-skill new file mode 120000 index 0000000..94339c9 --- /dev/null +++ b/.codex/skills/swiftui-expert-skill @@ -0,0 +1 @@ +../../.agents/skills/swiftui-expert-skill \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b99fb86 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,199 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +env: + PROJECT_NAME: ScreenTranslate + +jobs: + build: + runs-on: macos-15 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '26.2' + + - name: Auto-increment build number + run: | + VERSION=${GITHUB_REF#refs/tags/v} + + # Get current build number from latest release's appcast + CURRENT_BUILD=0 + LATEST_APPCAST=$(curl -sL "https://github.com/${{ github.repository }}/releases/latest/download/appcast.xml" 2>/dev/null || echo "") + + if [ -n "$LATEST_APPCAST" ]; then + CURRENT_BUILD=$(echo "$LATEST_APPCAST" | grep -o '[^<]*' | sed 's/<[^>]*>//g' | head -1) + echo "Current build number from appcast: $CURRENT_BUILD" + fi + + # Increment build number + NEW_BUILD=$((CURRENT_BUILD + 1)) + echo "New build number: $NEW_BUILD" + + # Update project.pbxproj + sed -i '' "s/CURRENT_PROJECT_VERSION = [0-9]*;/CURRENT_PROJECT_VERSION = $NEW_BUILD;/g" ScreenTranslate.xcodeproj/project.pbxproj + sed -i '' "s/MARKETING_VERSION = [^;]*;/MARKETING_VERSION = $VERSION;/g" ScreenTranslate.xcodeproj/project.pbxproj + + echo "Updated to version $VERSION (build $NEW_BUILD)" + echo "new_build=$NEW_BUILD" >> $GITHUB_OUTPUT + + - name: Build Release + run: | + xcodebuild -project ${{ env.PROJECT_NAME }}.xcodeproj \ + -scheme ${{ env.PROJECT_NAME }} \ + -configuration Release \ + -derivedDataPath build \ + -destination 'platform=macOS' \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGN_STYLE=Automatic \ + MACOSX_DEPLOYMENT_TARGET=26.0 \ + ONLY_ACTIVE_ARCH=NO \ + clean build + + - name: Create DMG + id: create_dmg + run: | + # Ensure ad-hoc signing for the entire app bundle (including frameworks) + codesign --force --sign - --deep build/Build/Products/Release/${{ env.PROJECT_NAME }}.app + + mkdir -p dmg_temp + cp -R build/Build/Products/Release/${{ env.PROJECT_NAME }}.app dmg_temp/ + ln -sf /Applications dmg_temp/Applications + + VERSION=${GITHUB_REF#refs/tags/v} + DMG_FILE="${{ env.PROJECT_NAME }}-${VERSION}.dmg" + + hdiutil create -volname "${{ env.PROJECT_NAME }}" \ + -srcfolder dmg_temp \ + -ov -format UDZO \ + "$DMG_FILE" + + # Get file size + DMG_SIZE=$(stat -f%z "$DMG_FILE") + echo "dmg_size=$DMG_SIZE" >> $GITHUB_OUTPUT + echo "dmg_file=$DMG_FILE" >> $GITHUB_OUTPUT + + rm -rf dmg_temp + + - name: Sign DMG for Sparkle + id: sign_dmg + run: | + VERSION=${GITHUB_REF#refs/tags/v} + DMG_FILE="${{ env.PROJECT_NAME }}-${VERSION}.dmg" + + # Find sign_update tool in derived data + SIGN_TOOL=$(find build/SourcePackages/artifacts -name "sign_update" -type f | head -1) + + if [ -z "$SIGN_TOOL" ]; then + echo "sign_update tool not found, checking alternative locations..." + SIGN_TOOL=$(find build -name "sign_update" -type f | head -1) + fi + + if [ -n "$SIGN_TOOL" ]; then + echo "Found sign_update at: $SIGN_TOOL" + # Sign the DMG and get the signature + SIGNATURE=$(echo "${{ secrets.SPARKLE_PRIVATE_KEY }}" | "$SIGN_TOOL" --ed-key-file - -p "$DMG_FILE" 2>&1) + echo "Signature: $SIGNATURE" + echo "signature=$SIGNATURE" >> $GITHUB_OUTPUT + else + echo "sign_update tool not found, skipping signing" + echo "signature=" >> $GITHUB_OUTPUT + fi + + - name: Generate pubDate + id: pubdate + run: echo "date=$(date -Ru)" >> $GITHUB_OUTPUT + + - name: Get Build Number + id: build_number + run: | + # Extract build number from project + BUILD_NUM=$(grep -m1 'CURRENT_PROJECT_VERSION' ScreenTranslate.xcodeproj/project.pbxproj | sed 's/.*= *//;s/;//' | tr -d ' ') + echo "build_num=$BUILD_NUM" >> $GITHUB_OUTPUT + + - name: Get Tag Message + id: tag_message + env: + TAG_NAME: ${{ github.ref_name }} + run: | + # Get the tag annotation message (remove trailing PGP signature using POSIX-compatible sed) + TAG_MESSAGE=$(git tag -l --format='%(contents)' "$TAG_NAME" | sed '/^-----BEGIN PGP SIGNATURE-----/,$d' || echo "") + # If tag message is empty, use a default message + if [ -z "$TAG_MESSAGE" ]; then + TAG_MESSAGE="Latest release of ScreenTranslate" + fi + # Escape CDATA boundaries for safe XML wrapping (not entity escaping, since we use CDATA) + TAG_MESSAGE=$(echo "$TAG_MESSAGE" | sed 's/]]>/]]]]>/g') + # Generate a unique delimiter to avoid collision with message content + DELIMITER="EOF_$(uuidgen | tr -d '-')" + echo "message<<${DELIMITER}" >> $GITHUB_OUTPUT + echo "$TAG_MESSAGE" >> $GITHUB_OUTPUT + echo "${DELIMITER}" >> $GITHUB_OUTPUT + + - name: Generate Sparkle Appcast + env: + RELEASE_NOTES: ${{ steps.tag_message.outputs.message }} + run: | + VERSION=${GITHUB_REF#refs/tags/v} + BUILD_NUM="${{ steps.build_number.outputs.build_num }}" + DMG_URL="https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ env.PROJECT_NAME }}-${VERSION}.dmg" + PUBDATE="${{ steps.pubdate.outputs.date }}" + DMG_SIZE="${{ steps.create_dmg.outputs.dmg_size }}" + SIGNATURE="${{ steps.sign_dmg.outputs.signature }}" + + # Build enclosure tag with or without signature + if [ -n "$SIGNATURE" ]; then + ENCLOSURE="" + else + ENCLOSURE="" + fi + + # Generate unique delimiter to avoid collision with release notes content + APPCAST_DELIMITER="APPCAST_EOF_$(uuidgen | tr -d '-')" + cat > appcast.xml << ${APPCAST_DELIMITER} + + + + ${{ env.PROJECT_NAME }} Updates + https://github.com/${{ github.repository }}/releases + Most recent changes with links to updates. + en + + Version ${VERSION} + ${PUBDATE} + ${BUILD_NUM} + ${VERSION} + 26.0 + ${ENCLOSURE} + + + + + ${APPCAST_DELIMITER} + + echo "Generated appcast.xml:" + cat appcast.xml + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: | + ${{ env.PROJECT_NAME }}-*.dmg + appcast.xml + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Cleanup + run: rm -rf build diff --git a/.gitignore b/.gitignore index c9bb97c..2b75911 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,9 @@ edashot/ # Release builds release/ + +# Ralph TUI session data +.ralph-tui/ + +# OMD session data +.omd/ diff --git a/.omd/project-memory.json b/.omd/project-memory.json new file mode 100644 index 0000000..984d440 --- /dev/null +++ b/.omd/project-memory.json @@ -0,0 +1,244 @@ +{ + "version": "1.0.0", + "lastScanned": 1772269310504, + "projectRoot": "/Users/hubo/.superset/worktrees/screentranslate/featadd-translate-engine", + "techStack": { + "languages": [], + "frameworks": [], + "packageManager": null, + "runtime": null + }, + "build": { + "buildCommand": null, + "testCommand": null, + "lintCommand": null, + "devCommand": null, + "scripts": {} + }, + "conventions": { + "namingStyle": null, + "importStyle": null, + "testPattern": null, + "fileOrganization": null + }, + "structure": { + "isMonorepo": false, + "workspaces": [], + "mainDirectories": [ + "docs" + ], + "gitBranches": { + "defaultBranch": "main", + "branchingStrategy": null + } + }, + "customNotes": [], + "directoryMap": { + "Build": { + "path": "Build", + "purpose": "Build output", + "fileCount": 1, + "lastAccessed": 1772269310491, + "keyFiles": [] + }, + "ScreenTranslate": { + "path": "ScreenTranslate", + "purpose": null, + "fileCount": 0, + "lastAccessed": 1772269310492, + "keyFiles": [] + }, + "ScreenTranslate.xcodeproj": { + "path": "ScreenTranslate.xcodeproj", + "purpose": null, + "fileCount": 1, + "lastAccessed": 1772269310492, + "keyFiles": [ + "project.pbxproj" + ] + }, + "ScreenTranslateTests": { + "path": "ScreenTranslateTests", + "purpose": null, + "fileCount": 5, + "lastAccessed": 1772269310493, + "keyFiles": [ + "KeyboardShortcutTests.swift", + "README.md", + "ScreenTranslateErrorTests.swift", + "ShortcutRecordingTypeTests.swift", + "TextTranslationErrorTests.swift" + ] + }, + "docs": { + "path": "docs", + "purpose": "Documentation", + "fileCount": 6, + "lastAccessed": 1772269310493, + "keyFiles": [ + "README.md", + "api-reference.md", + "architecture.md", + "components.md", + "developer-guide.md" + ] + }, + "skills": { + "path": "skills", + "purpose": null, + "fileCount": 0, + "lastAccessed": 1772269310494, + "keyFiles": [] + }, + "tasks": { + "path": "tasks", + "purpose": null, + "fileCount": 6, + "lastAccessed": 1772269310494, + "keyFiles": [ + "prd-.md", + "prd-macos-screentranslate.md", + "prd-screencoder-kiss-translator.md", + "prd-screencoder.md", + "prd-text-translation.json" + ] + }, + "ScreenTranslate/App": { + "path": "ScreenTranslate/App", + "purpose": "Application code", + "fileCount": 2, + "lastAccessed": 1772269310495, + "keyFiles": [ + "AppDelegate.swift", + "ScreenTranslateApp.swift" + ] + }, + "ScreenTranslate/Models": { + "path": "ScreenTranslate/Models", + "purpose": "Data models", + "fileCount": 23, + "lastAccessed": 1772269310495, + "keyFiles": [ + "Annotation.swift", + "AppLanguage.swift", + "AppSettings.swift" + ] + }, + "ScreenTranslate/Services": { + "path": "ScreenTranslate/Services", + "purpose": "Business logic services", + "fileCount": 26, + "lastAccessed": 1772269310495, + "keyFiles": [ + "AccessibilityPermissionChecker.swift", + "AppleTranslationProvider.swift", + "ClaudeVLMProvider.swift" + ] + } + }, + "hotPaths": [ + { + "path": "ScreenTranslate/Services/PaddleOCREngine.swift", + "accessCount": 17, + "lastAccessed": 1772277198204, + "type": "file" + }, + { + "path": "ScreenTranslate/Services/Security/KeychainService.swift", + "accessCount": 14, + "lastAccessed": 1772277575721, + "type": "file" + }, + { + "path": "ScreenTranslate/Models/AppSettings.swift", + "accessCount": 13, + "lastAccessed": 1772277135092, + "type": "directory" + }, + { + "path": "ScreenTranslate/Resources/en.lproj/Localizable.strings", + "accessCount": 6, + "lastAccessed": 1772277251555, + "type": "file" + }, + { + "path": "ScreenTranslate/Services/PaddleOCRVLMProvider.swift", + "accessCount": 6, + "lastAccessed": 1772277354512, + "type": "directory" + }, + { + "path": "ScreenTranslate/Features/Settings/SettingsViewModel.swift", + "accessCount": 5, + "lastAccessed": 1772277092578, + "type": "directory" + }, + { + "path": "ScreenTranslate/Resources", + "accessCount": 4, + "lastAccessed": 1772271181502, + "type": "directory" + }, + { + "path": "ScreenTranslate/Features/Settings/EngineSettingsTab.swift", + "accessCount": 4, + "lastAccessed": 1772274724758, + "type": "directory" + }, + { + "path": "ScreenTranslate/Resources/zh-Hans.lproj/Localizable.strings", + "accessCount": 4, + "lastAccessed": 1772277283269, + "type": "file" + }, + { + "path": "ScreenTranslate/Features/TranslationFlow/TranslationFlowController.swift", + "accessCount": 3, + "lastAccessed": 1772270086752, + "type": "file" + }, + { + "path": "ScreenTranslate", + "accessCount": 3, + "lastAccessed": 1772271133277, + "type": "directory" + }, + { + "path": "ScreenTranslate/Features/Capture/ScreenDetector.swift", + "accessCount": 3, + "lastAccessed": 1772277067534, + "type": "directory" + }, + { + "path": "ScreenTranslate/Features/Capture/CaptureManager.swift", + "accessCount": 2, + "lastAccessed": 1772277030873, + "type": "directory" + }, + { + "path": "", + "accessCount": 1, + "lastAccessed": 1772269949125, + "type": "directory" + }, + { + "path": "ScreenTranslate/Errors/ScreenTranslateError.swift", + "accessCount": 1, + "lastAccessed": 1772269960584, + "type": "file" + }, + { + "path": "ScreenTranslate/Models/VLMProviderType.swift", + "accessCount": 1, + "lastAccessed": 1772269960585, + "type": "file" + }, + { + "path": "ScreenTranslate/Services/ScreenCoderEngine.swift", + "accessCount": 1, + "lastAccessed": 1772270035460, + "type": "file" + } + ], + "userDirectives": [] +} \ No newline at end of file diff --git a/.omd/sessions/0c203f54-c10d-4417-8115-005c18e9036b.json b/.omd/sessions/0c203f54-c10d-4417-8115-005c18e9036b.json new file mode 100644 index 0000000..666c2c8 --- /dev/null +++ b/.omd/sessions/0c203f54-c10d-4417-8115-005c18e9036b.json @@ -0,0 +1,8 @@ +{ + "session_id": "0c203f54-c10d-4417-8115-005c18e9036b", + "ended_at": "2026-02-28T09:01:44.054Z", + "reason": "other", + "agents_spawned": 0, + "agents_completed": 0, + "modes_used": [] +} \ No newline at end of file diff --git a/.opencode/skills/swiftui-expert-skill b/.opencode/skills/swiftui-expert-skill new file mode 120000 index 0000000..94339c9 --- /dev/null +++ b/.opencode/skills/swiftui-expert-skill @@ -0,0 +1 @@ +../../.agents/skills/swiftui-expert-skill \ No newline at end of file diff --git a/.superset/config.json b/.superset/config.json new file mode 100644 index 0000000..9d9aba5 --- /dev/null +++ b/.superset/config.json @@ -0,0 +1,4 @@ +{ + "setup": [], + "teardown": [] +} diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..30bce8b --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,153 @@ +# SwiftLint 配置 +# 基于 Airbnb Swift 风格指南和项目最佳实践 + +# 禁用规则 +disabled_rules: + - trailing_whitespace # 行尾空格会在编辑时自动处理 + - todo # 允许使用 TODO 标记 + +# 选择性启用规则 +opt_in_rules: + - empty_count # 使用 isEmpty 而非 count == 0 + - empty_string # 使用 isEmpty 而非 string == "" + - explicit_init # 禁止显式调用 .init() + - first_where # 使用 .first(where:) 而非 .filter { }.first + - fatal_error_message # fatalError 必须包含消息 + - force_unwrapping # 禁止强制解包(部分情况) + - implicitly_unwrapped_optional # 标记隐式解包可选类型 + - joined_default_parameter # 使用 joined() 而非 joined(separator: ",") + - multiline_arguments # 多行参数对齐 + - operator_usage_whitespace # 运算符空格规则 + - overridden_super_call # 重写方法应调用 super + - prohibited_interface_builder # 禁用 IB 设计able + - redundant_nil_coalescing # 冗余的 ?? 操作 + - sorted_first_last # 使用 min()/max() 而非 sorted().first + - vertical_parameter_alignment_on_call # 垂直参数对齐 + - closure_spacing # 闭包空格规则 + - collection_alignment # 集合对齐 + +# 排除路径 +excluded: + - Pods + - .build + - DerivedData + - ScreenCapture.xcodeproj + - ScreenCaptureTests + - Carthage + - .swiftpm + +# 行长度 +line_length: + warning: 120 + error: 200 + ignores_function_declarations: true + ignores_comments: true + ignores_urls: true + +# 文件长度 +file_length: + warning: 400 + error: 600 + ignore_comment_only_lines: true + +# 函数体长度 +function_body_length: + warning: 60 + error: 100 + +# 函数参数长度 +function_parameter_count: + warning: 6 + error: 8 + +# 类型体长度 +type_body_length: + warning: 350 + error: 500 + +# 类型名称 +type_name: + min_length: 3 + max_length: 40 + excluded: + - ID + - URL + - T + - U + - V + +# 标识符名称 +identifier_name: + min_length: + warning: 2 + error: 1 + max_length: + warning: 40 + error: 50 + excluded: + - id + - x + - y + - dx + - dy + - url + - db + - vc + - to + - in + - at + - on + - ok + - vs + - zg + - zh + - ns + +# 大型元组 +large_tuple: + warning: 3 + error: 4 + +# 嵌套类型层级 +nesting: + type_level: + warning: 3 + error: 5 + +# 强制类型转换警告(部分允许) +force_cast: warning # 允许在测试中使用 + +# 强制 try +force_try: + severity: warning + +# 强制解包(在测试中允许) +# force_unwrapping 已在 opt_in_rules 中启用 + +# 自定义规则 +custom_rules: + # 禁止使用 print(应使用日志系统) + no_print: + name: "No Print Statements" + regex: "\\bprint\\(" + match_kinds: + - identifier + message: "Use os.log instead of print()" + severity: warning + + # 禁止强制解包(除了 @IBOutlets) + no_force_unwrap: + name: "No Force Unwrap" + regex: "!\\s*[\\)\\}\\],\\.;]" + message: "Avoid force unwrapping, use guard let or if let instead" + severity: warning + +# 排除某些文件使用特定规则 +# 标记为 @MainActor 的属性不需要 unsafe_sendable 警告 +analyzer_rules: + - explicit_self + - unused_import + - unused_declaration + +# 报告格式 +reporter: "xcode" diff --git a/README.md b/README.md index 864ad78..c97af0f 100644 --- a/README.md +++ b/README.md @@ -1,130 +1,217 @@

- ScreenCapture + ScreenTranslate

-

ScreenCapture

+

ScreenTranslate

- A fast, lightweight macOS menu bar app for capturing and annotating screenshots. + macOS menu bar app for screenshot translation with OCR, multi-engine translation, text selection translation, and translate-and-insert features

+ Version License: MIT macOS - Swift + Swift

-## Features - -- **Instant Capture** - Full screen or region selection with global hotkeys -- **Annotation Tools** - Rectangles (filled/outline), arrows, freehand drawing, and text -- **Multi-Monitor Support** - Works seamlessly across all connected displays -- **Flexible Export** - PNG, JPEG, and HEIC formats with quality control -- **Crop & Edit** - Crop screenshots after capture with pixel-perfect precision -- **Quick Export** - Save to disk or copy to clipboard instantly -- **Lightweight** - Runs quietly in your menu bar with minimal resources - -## Installation - -### Requirements - -- macOS 13.0 (Ventura) or later -- Screen Recording permission - -### Download - -Download the latest release from the [Releases](../../releases) page. - -### Build from Source - -```bash -# Clone the repository -git clone https://github.com/sadopc/ScreenCapture.git -cd ScreenCapture - -# Open in Xcode -open ScreenCapture.xcodeproj - -# Build and run (Cmd+R) -``` - -## Usage +

+ 简体中文 +

-### Keyboard Shortcuts +## ✨ Features + +### Screenshot Capture +- **Region Capture** - Select any area of the screen to capture +- **Full Screen Capture** - Capture the entire screen with one click +- **Translation Mode** - Translate directly after capture, no extra steps needed +- **Multi-Monitor Support** - Automatic detection and support for multiple displays +- **Retina Display Optimized** - Perfect support for high-resolution displays + +### 🆕 Text Translation +- **Text Selection Translation** - Select any text and translate with a popup result window +- **Translate and Insert** - Replace selected text with translation (bypasses input method) +- **Independent Language Settings** - Separate target language configuration for translate-and-insert + +### OCR Text Recognition +- **Apple Vision** - Native OCR, no additional configuration required +- **PaddleOCR** - Optional external engine with better Chinese recognition + +### Multi-Engine Translation +- **Apple Translation** - Built-in system translation, works offline +- **MTranServer** - Self-hosted translation server for high-quality translation +- **VLM Vision Models** - OpenAI GPT-4 Vision / Claude / Ollama local models + +### Annotation Tools +- Rectangle selection +- Arrow annotation +- Freehand drawing +- Text annotation +- Screenshot cropping + +### Other Features +- **Translation History** - Save translation records with search and export +- **Bilingual Display** - Side-by-side original and translated text +- **Overlay Display** - Translation results displayed directly on the screenshot +- **Custom Shortcuts** - Global hotkeys for quick capture and translation +- **Menu Bar Quick Access** - All features accessible from menu bar +- **Multi-Language Support** - Support for 25+ languages + +## ⌨️ Keyboard Shortcuts | Shortcut | Action | |----------|--------| -| `Cmd+Shift+3` | Capture full screen | -| `Cmd+Shift+4` | Capture selection | +| `Cmd+Shift+3` | Capture Full Screen | +| `Cmd+Shift+4` | Capture Selection (default) | +| `Cmd+Shift+T` | Translation Mode (translate after capture) | +| `Cmd+Shift+Y` | Text Selection Translation | +| `Cmd+Shift+I` | Translate and Insert | -### In Preview Window +> All shortcuts can be customized in Settings + +## Preview Window Shortcuts | Shortcut | Action | |----------|--------| -| `Enter` / `Cmd+S` | Save screenshot (or apply crop in crop mode) | -| `Cmd+C` | Copy to clipboard | -| `Escape` | Dismiss / Cancel crop / Deselect tool | -| `R` / `1` | Rectangle tool | -| `D` / `2` | Freehand tool | -| `A` / `3` | Arrow tool | -| `T` / `4` | Text tool | -| `C` | Crop mode | +| `Enter` / `Cmd+S` | Save Screenshot | +| `Cmd+C` | Copy to Clipboard | +| `Escape` | Close Window / Cancel Crop | +| `R` / `1` | Rectangle Tool | +| `D` / `2` | Freehand Tool | +| `A` / `3` | Arrow Tool | +| `T` / `4` | Text Tool | +| `C` | Crop Mode | | `Cmd+Z` | Undo | | `Cmd+Shift+Z` | Redo | -## Documentation - -Detailed documentation is available in the [docs](./docs) folder: - -- [Architecture](./docs/architecture.md) - System design and patterns -- [Components](./docs/components.md) - Feature documentation -- [API Reference](./docs/api-reference.md) - Public APIs -- [Developer Guide](./docs/developer-guide.md) - Contributing guide -- [User Guide](./docs/user-guide.md) - End-user documentation - -## Tech Stack - -- **Swift 6.2** with strict concurrency -- **SwiftUI** + **AppKit** for native macOS UI -- **ScreenCaptureKit** for system-level capture -- **CoreGraphics** for image processing +## 📦 Requirements -## Contributing - -Contributions are welcome! Please read our contributing guidelines: - -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request +- macOS 13.0 (Ventura) or later +- Screen Recording permission (prompted on first use) +- Accessibility permission (required for text translation features) + +## Download & Installation + +Download the latest version from the [Releases](../../releases) page. + +> ⚠️ **Note: The app is not signed by Apple Developer** +> +> Since there's no Apple Developer account, the app is not code-signed. On first launch, macOS may show "cannot be opened" or "developer cannot be verified". +> +> **Solutions** (choose one): +> +> **Method 1 - Terminal Command (Recommended)** +> ```bash +> xattr -rd com.apple.quarantine /Applications/ScreenTranslate.app +> ``` +> +> **Method 2 - System Settings** +> 1. Open "System Settings" → "Privacy & Security" +> 2. Find the notification about ScreenTranslate under "Security" +> 3. Click "Open Anyway" +> +> Either method only needs to be done once, after which the app can be used normally. + +## 🔧 Tech Stack + +- **Swift 6.0** - Modern Swift language features with strict concurrency checking +- **SwiftUI + AppKit** - Declarative UI combined with native macOS components +- **ScreenCaptureKit** - System-level screen recording and capture +- **Vision** - Apple native OCR text recognition +- **Translation** - Apple system translation framework +- **CoreGraphics** - Image processing and rendering + +## 📁 Project Structure + +```text +ScreenTranslate/ +├── App/ # App entry point and coordinators +│ ├── AppDelegate.swift +│ └── Coordinators/ # Feature coordinators +│ ├── CaptureCoordinator.swift +│ ├── TextTranslationCoordinator.swift +│ └── HotkeyCoordinator.swift +├── Features/ # Feature modules +│ ├── Capture/ # Screenshot capture +│ ├── Preview/ # Preview and annotation +│ ├── TextTranslation/ # Text translation +│ ├── Overlay/ # Translation overlay +│ ├── BilingualResult/ # Bilingual result display +│ ├── History/ # Translation history +│ ├── Settings/ # Settings UI +│ └── MenuBar/ # Menu bar control +├── Services/ # Business services +│ ├── Protocols/ # Service protocols (dependency injection) +│ ├── OCREngine/ # OCR engines +│ ├── Translation/ # Translation services +│ └── VLMProvider/ # Vision-language models +├── Models/ # Data models +└── Resources/ # Resource files +``` -### Development Setup +## 🛠️ Build from Source ```bash -# Clone your fork -git clone https://github.com/YOUR_FORK/ScreenCapture.git +# Clone the repository +git clone https://github.com/hubo1989/ScreenTranslate.git +cd ScreenTranslate # Open in Xcode -open ScreenCapture.xcodeproj - -# Grant Screen Recording permission when prompted -``` - -See the [Developer Guide](./docs/developer-guide.md) for detailed setup instructions. - -## License - -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +open ScreenTranslate.xcodeproj +# Or build from command line +xcodebuild -project ScreenTranslate.xcodeproj -scheme ScreenTranslate ``` -MIT License - Copyright (c) 2026 Serdar Albayrak -``` - -## Acknowledgments -- Built with [ScreenCaptureKit](https://developer.apple.com/documentation/screencapturekit) -- Icons from [SF Symbols](https://developer.apple.com/sf-symbols/) +## 📝 Changelog + +### v1.4.1 +- 🐛 Fixed duplicate text segments in VLM responses +- 🐛 Simplified toolbar UI +- 🐛 Fixed CGColorSpace compatibility for older macOS versions +- ✨ Added new annotation tools (shapes, highlighter) +- ✨ Added pinned window feature for annotation mode + +### v1.3.0 +- ✨ Added About menu with version, license, and acknowledgements +- ✨ Integrated Sparkle auto-update framework +- ✨ Added GitHub Actions CI/CD for automated releases +- 📚 Translated README to English + +### v1.2.0 +- ✨ Added unified EngineIdentifier for standard and compatible engines +- ✨ Added multi-instance support for OpenAI-compatible engines +- ✨ Optimized engine selection UI and added Gemini support +- ✨ Improved prompt editor UX with copyable variables +- ✨ Improved engine config UX with API key links +- ✨ Moved prompt configuration to dedicated sidebar tab +- ✨ Implemented multi-translation engine support +- 🐛 Fixed quick switch order editing +- 🐛 Improved multi-engine settings interface +- 🌐 Added Chinese localization for multi-engine settings + +### v1.1.0 +- ✨ Added text selection translation feature +- ✨ Added translate and insert feature +- ✨ Menu bar shortcuts synced with settings +- 🏗️ Architecture refactoring: AppDelegate split into 3 Coordinators +- 🧪 Added unit test coverage +- 🐛 Fixed Retina display issues +- 🐛 Fixed translate-and-insert language settings not applying + +### v1.0.2 +- 🐛 Deep fix for Retina display scaling issues + +### v1.0.1 +- 🎉 Initial release + +## 🤝 Contributing + +Issues and Pull Requests are welcome! + +## 📄 License + +MIT License - See [LICENSE](LICENSE) file for details --- diff --git a/README_CN.md b/README_CN.md new file mode 100644 index 0000000..86c9a3e --- /dev/null +++ b/README_CN.md @@ -0,0 +1,195 @@ +

+ ScreenTranslate +

+ +

ScreenTranslate

+ +

+ macOS 菜单栏截图翻译工具,支持 OCR 识别、多引擎翻译、文本选择翻译和翻译插入 +

+ +

+ Version + License: MIT + macOS + Swift +

+ +## ✨ 功能特性 + +### 截图功能 +- **区域截图** - 选择屏幕任意区域进行截图 +- **全屏截图** - 一键截取整个屏幕 +- **翻译模式** - 截图后直接翻译,无需额外操作 +- **多显示器支持** - 自动识别并支持多显示器环境 +- **Retina 屏幕优化** - 完美支持高分辨率显示器 + +### 🆕 文本翻译功能 +- **文本选择翻译** - 选中任意文本,一键翻译并弹出结果窗口 +- **翻译并插入** - 选中文本翻译后,自动替换为译文(绕过输入法) +- **独立语言设置** - 翻译并插入支持独立的目标语言配置 + +### OCR 文字识别 +- **Apple Vision** - 原生 OCR,无需额外配置 +- **PaddleOCR** - 可选外部引擎,中文识别更准确 + +### 多引擎翻译 +- **Apple Translation** - 系统内置翻译,离线可用 +- **MTranServer** - 自建翻译服务器,高质量翻译 +- **VLM 视觉模型** - OpenAI GPT-4 Vision / Claude / Ollama 本地模型 + +### 标注工具 +- 矩形框选 +- 箭头标注 +- 手绘涂鸦 +- 文字注释 +- 截图裁剪 + +### 其他功能 +- **翻译历史** - 保存翻译记录,支持搜索和导出 +- **双语对照** - 原文译文并排显示 +- **覆盖层显示** - 翻译结果直接显示在截图上方 +- **自定义快捷键** - 支持全局快捷键快速截图和翻译 +- **菜单栏快捷操作** - 所有功能均可通过菜单栏访问 +- **多语言支持** - 支持 25+ 种语言翻译 + +## ⌨️ 快捷键 + +| 快捷键 | 功能 | +|--------|------| +| `Cmd+Shift+3` | 全屏截图 | +| `Cmd+Shift+4` | 区域截图翻译(默认) | +| `Cmd+Shift+T` | 翻译模式(截图后直接翻译) | +| `Cmd+Shift+Y` | 文本选择翻译 | +| `Cmd+Shift+I` | 翻译并插入 | + +> 所有快捷键均可在设置中自定义 + +## 预览窗口操作 + +| 快捷键 | 功能 | +|--------|------| +| `Enter` / `Cmd+S` | 保存截图 | +| `Cmd+C` | 复制到剪贴板 | +| `Escape` | 关闭窗口 / 取消裁剪 | +| `R` / `1` | 矩形工具 | +| `D` / `2` | 手绘工具 | +| `A` / `3` | 箭头工具 | +| `T` / `4` | 文字工具 | +| `C` | 裁剪模式 | +| `Cmd+Z` | 撤销 | +| `Cmd+Shift+Z` | 重做 | + +## 📦 安装要求 + +- macOS 13.0 (Ventura) 或更高版本 +- 屏幕录制权限(首次使用时会提示) +- 辅助功能权限(文本翻译功能需要) + +## 下载安装 + +从 [Releases](../../releases) 页面下载最新版本。 + +> ⚠️ **注意:应用未经过 Apple 开发者签名** +> +> 由于目前没有 Apple Developer 账号,应用未进行代码签名。首次运行时 macOS 会提示「无法打开」或「开发者无法验证」。 +> +> **解决方法**(二选一): +> +> **方法 1 - 终端命令(推荐)** +> ```bash +> xattr -rd com.apple.quarantine /Applications/ScreenTranslate.app +> ``` +> +> **方法 2 - 系统设置** +> 1. 打开「系统设置」→「隐私与安全性」 +> 2. 在「安全性」部分找到关于 ScreenTranslate 的提示 +> 3. 点击「仍要打开」 +> +> 两种方法都只需要执行一次,之后可以正常使用。 + +## 🔧 技术栈 + +- **Swift 6.0** - 现代 Swift 语言特性,严格并发检查 +- **SwiftUI + AppKit** - 声明式 UI 与原生 macOS 组件结合 +- **ScreenCaptureKit** - 系统级屏幕录制与截图 +- **Vision** - Apple 原生 OCR 文字识别 +- **Translation** - Apple 系统翻译框架 +- **CoreGraphics** - 图像处理与渲染 + +## 📁 项目结构 + +```text +ScreenTranslate/ +├── App/ # 应用入口与协调器 +│ ├── AppDelegate.swift +│ └── Coordinators/ # 功能协调器 +│ ├── CaptureCoordinator.swift +│ ├── TextTranslationCoordinator.swift +│ └── HotkeyCoordinator.swift +├── Features/ # 功能模块 +│ ├── Capture/ # 截图功能 +│ ├── Preview/ # 预览与标注 +│ ├── TextTranslation/ # 文本翻译 +│ ├── Overlay/ # 翻译覆盖层 +│ ├── BilingualResult/ # 双语结果展示 +│ ├── History/ # 历史记录 +│ ├── Settings/ # 设置界面 +│ └── MenuBar/ # 菜单栏控制 +├── Services/ # 业务服务 +│ ├── Protocols/ # 服务协议(依赖注入) +│ ├── OCREngine/ # OCR 引擎 +│ ├── Translation/ # 翻译服务 +│ └── VLMProvider/ # 视觉语言模型 +├── Models/ # 数据模型 +└── Resources/ # 资源文件 +``` + +## 🛠️ 构建源码 + +```bash +# 克隆仓库 +git clone https://github.com/hubo1989/ScreenTranslate.git +cd ScreenTranslate + +# 用 Xcode 打开 +open ScreenTranslate.xcodeproj + +# 或命令行构建 +xcodebuild -project ScreenTranslate.xcodeproj -scheme ScreenTranslate +``` + +## 📝 更新日志 + +### v1.3.0 +- ✨ 新增关于菜单(版本、许可证、致谢) +- ✨ 集成 Sparkle 自动更新框架 +- ✨ 添加 GitHub Actions CI/CD 自动发布 +- 📚 README 翻译为英文 + +### v1.1.0 +- ✨ 新增文本选择翻译功能(选中任意文本一键翻译) +- ✨ 新增翻译并插入功能(自动替换选中文本为译文) +- ✨ 菜单栏快捷键与设置同步 +- 🏗️ 架构重构:AppDelegate 拆分为 3 个 Coordinator +- 🧪 添加单元测试覆盖 +- 🐛 修复 Retina 屏幕显示问题 +- 🐛 修复翻译并插入语言设置不生效问题 + +### v1.0.2 +- 🐛 深度修复 Retina 屏幕缩放问题 + +### v1.0.1 +- 🎉 首次发布 + +## 🤝 贡献指南 + +欢迎提交 Issue 和 Pull Request。 + +## 📄 许可证 + +MIT License - 详见 [LICENSE](LICENSE) 文件 + +--- + +Made with Swift for macOS diff --git a/ScreenCapture/App/AppDelegate.swift b/ScreenCapture/App/AppDelegate.swift deleted file mode 100644 index 3871d4a..0000000 --- a/ScreenCapture/App/AppDelegate.swift +++ /dev/null @@ -1,393 +0,0 @@ -import AppKit - -/// Application delegate responsible for menu bar setup, hotkey registration, and app lifecycle. -/// Runs on the main actor to ensure thread-safe UI operations. -@MainActor -final class AppDelegate: NSObject, NSApplicationDelegate { - // MARK: - Properties - - /// Menu bar controller for status item management - private var menuBarController: MenuBarController? - - /// Store for recent captures - private var recentCapturesStore: RecentCapturesStore? - - /// Registered hotkey for full screen capture - private var fullScreenHotkeyRegistration: HotkeyManager.Registration? - - /// Registered hotkey for selection capture - private var selectionHotkeyRegistration: HotkeyManager.Registration? - - /// Shared app settings - private let settings = AppSettings.shared - - /// Display selector for multi-monitor support - private let displaySelector = DisplaySelector() - - /// Whether a capture is currently in progress (prevents overlapping captures) - private var isCaptureInProgress = false - - // MARK: - NSApplicationDelegate - - func applicationDidFinishLaunching(_ notification: Notification) { - // Ensure we're a menu bar only app (no dock icon) - NSApp.setActivationPolicy(.accessory) - - // Initialize recent captures store - recentCapturesStore = RecentCapturesStore(settings: settings) - - // Set up menu bar - menuBarController = MenuBarController( - appDelegate: self, - recentCapturesStore: recentCapturesStore! - ) - menuBarController?.setup() - - // Register global hotkeys - Task { - await registerHotkeys() - } - - // Check for screen recording permission on first launch - Task { - await checkAndRequestScreenRecordingPermission() - } - - #if DEBUG - print("ScreenCapture launched - settings loaded from: \(settings.saveLocation.path)") - #endif - } - - /// Checks for screen recording permission and shows an explanatory prompt if needed. - private func checkAndRequestScreenRecordingPermission() async { - // Check if we already have permission - let hasPermission = await CaptureManager.shared.hasPermission - - if !hasPermission { - // Show an explanatory alert before triggering the system prompt - await MainActor.run { - showPermissionExplanationAlert() - } - } - } - - /// Shows an alert explaining why screen recording permission is needed. - private func showPermissionExplanationAlert() { - let alert = NSAlert() - alert.alertStyle = .informational - alert.messageText = NSLocalizedString("permission.prompt.title", comment: "Screen Recording Permission Required") - alert.informativeText = NSLocalizedString("permission.prompt.message", comment: "") - alert.addButton(withTitle: NSLocalizedString("permission.prompt.continue", comment: "Continue")) - alert.addButton(withTitle: NSLocalizedString("permission.prompt.later", comment: "Later")) - - let response = alert.runModal() - if response == .alertFirstButtonReturn { - // Trigger the system permission prompt by attempting a capture - Task { - _ = await CaptureManager.shared.requestPermission() - } - } - } - - func applicationWillTerminate(_ notification: Notification) { - // Unregister hotkeys - Task { - await unregisterHotkeys() - } - - // Remove menu bar item - menuBarController?.teardown() - - #if DEBUG - print("ScreenCapture terminating") - #endif - } - - func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - // For menu bar apps, we don't need to do anything special on reopen - // The menu bar icon is always visible - return false - } - - func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { - // Enable secure state restoration - return true - } - - // MARK: - Hotkey Management - - /// Registers global hotkeys for capture actions - private func registerHotkeys() async { - let hotkeyManager = HotkeyManager.shared - - // Register full screen capture hotkey - do { - fullScreenHotkeyRegistration = try await hotkeyManager.register( - shortcut: settings.fullScreenShortcut - ) { [weak self] in - Task { @MainActor in - self?.captureFullScreen() - } - } - #if DEBUG - print("Registered full screen hotkey: \(settings.fullScreenShortcut.displayString)") - #endif - } catch { - #if DEBUG - print("Failed to register full screen hotkey: \(error)") - #endif - } - - // Register selection capture hotkey - do { - selectionHotkeyRegistration = try await hotkeyManager.register( - shortcut: settings.selectionShortcut - ) { [weak self] in - Task { @MainActor in - self?.captureSelection() - } - } - #if DEBUG - print("Registered selection hotkey: \(settings.selectionShortcut.displayString)") - #endif - } catch { - #if DEBUG - print("Failed to register selection hotkey: \(error)") - #endif - } - } - - /// Unregisters all global hotkeys - private func unregisterHotkeys() async { - let hotkeyManager = HotkeyManager.shared - - if let registration = fullScreenHotkeyRegistration { - await hotkeyManager.unregister(registration) - fullScreenHotkeyRegistration = nil - } - - if let registration = selectionHotkeyRegistration { - await hotkeyManager.unregister(registration) - selectionHotkeyRegistration = nil - } - } - - /// Re-registers hotkeys after settings change - func updateHotkeys() { - Task { - await unregisterHotkeys() - await registerHotkeys() - } - } - - // MARK: - Capture Actions - - /// Triggers a full screen capture - @objc func captureFullScreen() { - // Prevent overlapping captures - guard !isCaptureInProgress else { - #if DEBUG - print("Capture already in progress, ignoring request") - #endif - return - } - - #if DEBUG - print("Full screen capture triggered via hotkey or menu") - #endif - - isCaptureInProgress = true - - Task { - defer { isCaptureInProgress = false } - - do { - // Get available displays - let displays = try await CaptureManager.shared.availableDisplays() - - // Select display (shows menu if multiple) - guard let selectedDisplay = await displaySelector.selectDisplay(from: displays) else { - #if DEBUG - print("Display selection cancelled") - #endif - return - } - - #if DEBUG - print("Capturing display: \(selectedDisplay.name)") - #endif - - // Perform capture - let screenshot = try await CaptureManager.shared.captureFullScreen(display: selectedDisplay) - - #if DEBUG - print("Capture successful: \(screenshot.formattedDimensions)") - #endif - - // Show preview window - PreviewWindowController.shared.showPreview(for: screenshot) { [weak self] savedURL in - // Add to recent captures when saved - self?.addRecentCapture(filePath: savedURL, image: screenshot.image) - } - - } catch let error as ScreenCaptureError { - showCaptureError(error) - } catch { - showCaptureError(.captureFailure(underlying: error)) - } - } - } - - /// Triggers a selection capture - @objc func captureSelection() { - // Prevent overlapping captures - guard !isCaptureInProgress else { - #if DEBUG - print("Capture already in progress, ignoring request") - #endif - return - } - - #if DEBUG - print("Selection capture triggered via hotkey or menu") - #endif - - isCaptureInProgress = true - - Task { - do { - // Present the selection overlay on all displays - let overlayController = SelectionOverlayController.shared - - // Set up callbacks before presenting - overlayController.onSelectionComplete = { [weak self] rect, display in - Task { @MainActor in - await self?.handleSelectionComplete(rect: rect, display: display) - } - } - - overlayController.onSelectionCancel = { [weak self] in - Task { @MainActor in - self?.handleSelectionCancel() - } - } - - try await overlayController.presentOverlay() - - } catch { - isCaptureInProgress = false - #if DEBUG - print("Failed to present selection overlay: \(error)") - #endif - showCaptureError(.captureFailure(underlying: error)) - } - } - } - - /// Handles successful selection completion - private func handleSelectionComplete(rect: CGRect, display: DisplayInfo) async { - defer { isCaptureInProgress = false } - - do { - #if DEBUG - print("Selection complete: \(Int(rect.width))×\(Int(rect.height)) on \(display.name)") - #endif - - // Capture the selected region - let screenshot = try await CaptureManager.shared.captureRegion(rect, from: display) - - #if DEBUG - print("Region capture successful: \(screenshot.formattedDimensions)") - #endif - - // Show preview window - PreviewWindowController.shared.showPreview(for: screenshot) { [weak self] savedURL in - // Add to recent captures when saved - self?.addRecentCapture(filePath: savedURL, image: screenshot.image) - } - - } catch let error as ScreenCaptureError { - showCaptureError(error) - } catch { - showCaptureError(.captureFailure(underlying: error)) - } - } - - /// Handles selection cancellation - private func handleSelectionCancel() { - isCaptureInProgress = false - #if DEBUG - print("Selection cancelled by user") - #endif - } - - /// Opens the settings window - @objc func openSettings() { - #if DEBUG - print("Opening settings window") - #endif - - SettingsWindowController.shared.showSettings(appDelegate: self) - } - - // MARK: - Error Handling - - /// Shows an error alert for capture failures - private func showCaptureError(_ error: ScreenCaptureError) { - #if DEBUG - print("Capture error: \(error)") - #endif - - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = error.errorDescription ?? NSLocalizedString("error.capture.failed", comment: "") - alert.informativeText = error.recoverySuggestion ?? "" - - switch error { - case .permissionDenied: - alert.addButton(withTitle: NSLocalizedString("error.permission.open.settings", comment: "Open System Settings")) - alert.addButton(withTitle: NSLocalizedString("error.dismiss", comment: "Dismiss")) - - let response = alert.runModal() - if response == .alertFirstButtonReturn { - // Open System Settings > Privacy > Screen Recording - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { - NSWorkspace.shared.open(url) - } - } - - case .displayDisconnected: - // Offer to retry capture on a different display - alert.addButton(withTitle: NSLocalizedString("error.retry.capture", comment: "Retry")) - alert.addButton(withTitle: NSLocalizedString("error.dismiss", comment: "Dismiss")) - - let response = alert.runModal() - if response == .alertFirstButtonReturn { - // Retry the capture on the remaining displays - captureFullScreen() - } - - case .diskFull, .invalidSaveLocation: - // Offer to open settings to change save location - alert.addButton(withTitle: NSLocalizedString("menu.settings", comment: "Settings...")) - alert.addButton(withTitle: NSLocalizedString("error.dismiss", comment: "Dismiss")) - - let response = alert.runModal() - if response == .alertFirstButtonReturn { - openSettings() - } - - default: - alert.addButton(withTitle: NSLocalizedString("error.ok", comment: "OK")) - alert.runModal() - } - } - - // MARK: - Recent Captures - - /// Adds a capture to recent captures store - func addRecentCapture(filePath: URL, image: CGImage) { - recentCapturesStore?.add(filePath: filePath, image: image) - menuBarController?.updateRecentCapturesMenu() - } -} diff --git a/ScreenCapture/Features/Capture/SelectionOverlayWindow.swift b/ScreenCapture/Features/Capture/SelectionOverlayWindow.swift deleted file mode 100644 index 0273f51..0000000 --- a/ScreenCapture/Features/Capture/SelectionOverlayWindow.swift +++ /dev/null @@ -1,608 +0,0 @@ -import AppKit -import CoreGraphics - -// MARK: - SelectionOverlayDelegate - -/// Delegate protocol for selection overlay events. -@MainActor -protocol SelectionOverlayDelegate: AnyObject { - /// Called when user completes a selection. - /// - Parameters: - /// - rect: The selected rectangle in screen coordinates - /// - display: The display containing the selection - func selectionOverlay(didSelectRect rect: CGRect, on display: DisplayInfo) - - /// Called when user cancels the selection. - func selectionOverlayDidCancel() -} - -// MARK: - SelectionOverlayWindow - -/// NSPanel subclass for displaying the selection overlay. -/// Provides a full-screen transparent overlay with crosshair cursor, -/// dim effect, and selection rectangle drawing. -final class SelectionOverlayWindow: NSPanel { - // MARK: - Properties - - /// The screen this overlay covers - let targetScreen: NSScreen - - /// The display info for this screen - let displayInfo: DisplayInfo - - /// The content view handling drawing and interaction - private var overlayView: SelectionOverlayView? - - // MARK: - Initialization - - /// Creates a new selection overlay window for the specified screen. - /// - Parameters: - /// - screen: The NSScreen to overlay - /// - displayInfo: The DisplayInfo for the screen - @MainActor - init(screen: NSScreen, displayInfo: DisplayInfo) { - self.targetScreen = screen - self.displayInfo = displayInfo - - super.init( - contentRect: screen.frame, - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, - defer: false - ) - - configureWindow() - setupOverlayView() - } - - // MARK: - Configuration - - @MainActor - private func configureWindow() { - // Window properties for full-screen overlay - level = .screenSaver // Above most windows but below alerts - isOpaque = false - backgroundColor = .clear - ignoresMouseEvents = false - hasShadow = false - - // Don't hide on deactivation - hidesOnDeactivate = false - - // Behavior - collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] - isMovable = false - isMovableByWindowBackground = false - - // Accept mouse events - acceptsMouseMovedEvents = true - } - - @MainActor - private func setupOverlayView() { - let view = SelectionOverlayView(frame: targetScreen.frame) - view.autoresizingMask = [.width, .height] - self.contentView = view - self.overlayView = view - } - - // MARK: - Public API - - /// Sets the delegate for selection events - @MainActor - func setDelegate(_ delegate: SelectionOverlayDelegate) { - overlayView?.delegate = delegate - overlayView?.displayInfo = displayInfo - } - - /// Updates the current mouse position for crosshair drawing - @MainActor - func updateMousePosition(_ point: NSPoint) { - overlayView?.mousePosition = point - overlayView?.needsDisplay = true - } - - /// Updates the selection state (start point and current point) - @MainActor - func updateSelection(start: NSPoint?, current: NSPoint?) { - overlayView?.selectionStart = start - overlayView?.selectionCurrent = current - overlayView?.needsDisplay = true - } - - /// Shows the overlay window - @MainActor - func showOverlay() { - makeKeyAndOrderFront(nil) - } - - /// Hides and closes the overlay window - @MainActor - func hideOverlay() { - orderOut(nil) - close() - } - - // MARK: - NSWindow Overrides - - override var canBecomeKey: Bool { true } - override var canBecomeMain: Bool { true } - - // Make the window accept first responder - override var acceptsFirstResponder: Bool { true } -} - -// MARK: - SelectionOverlayView - -/// Custom NSView for drawing the selection overlay. -/// Handles crosshair cursor, dim overlay, and selection rectangle. -final class SelectionOverlayView: NSView { - // MARK: - Properties - - /// Delegate for selection events - weak var delegate: SelectionOverlayDelegate? - - /// Display info for coordinate conversion - var displayInfo: DisplayInfo? - - /// Current mouse position (in window coordinates) - var mousePosition: NSPoint? - - /// Selection start point (in window coordinates) - var selectionStart: NSPoint? - - /// Current selection end point (in window coordinates) - var selectionCurrent: NSPoint? - - /// Whether the user is currently dragging - private var isDragging = false - - /// Dim overlay color - private let dimColor = NSColor.black.withAlphaComponent(0.3) - - /// Selection rectangle stroke color - private let selectionStrokeColor = NSColor.white - - /// Selection rectangle fill color - private let selectionFillColor = NSColor.white.withAlphaComponent(0.1) - - /// Dimensions label background color - private let labelBackgroundColor = NSColor.black.withAlphaComponent(0.75) - - /// Dimensions label text color - private let labelTextColor = NSColor.white - - /// Crosshair line color - private let crosshairColor = NSColor.white.withAlphaComponent(0.8) - - /// Tracking area for mouse moved events - private var trackingArea: NSTrackingArea? - - // MARK: - Initialization - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - setupTrackingArea() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - private func setupTrackingArea() { - let options: NSTrackingArea.Options = [ - .activeAlways, - .mouseMoved, - .mouseEnteredAndExited, - .inVisibleRect - ] - - trackingArea = NSTrackingArea( - rect: bounds, - options: options, - owner: self, - userInfo: nil - ) - addTrackingArea(trackingArea!) - } - - override func updateTrackingAreas() { - super.updateTrackingAreas() - - if let existing = trackingArea { - removeTrackingArea(existing) - } - - setupTrackingArea() - } - - // MARK: - Drawing - - override func draw(_ dirtyRect: NSRect) { - guard let context = NSGraphicsContext.current?.cgContext else { return } - - // Draw dim overlay - drawDimOverlay(context: context) - - // If we have a selection, cut it out and draw the rectangle - if let start = selectionStart, let current = selectionCurrent { - let selectionRect = normalizedRect(from: start, to: current) - drawSelectionRect(selectionRect, context: context) - drawDimensionsLabel(for: selectionRect, context: context) - } else if let mousePos = mousePosition { - // Draw crosshair when not selecting - drawCrosshair(at: mousePos, context: context) - } - } - - /// Draws the semi-transparent dim overlay - private func drawDimOverlay(context: CGContext) { - if let start = selectionStart, let current = selectionCurrent { - // Draw dim with cutout for selection - let selectionRect = normalizedRect(from: start, to: current) - - context.saveGState() - - // Create path for the entire view minus the selection - context.addRect(bounds) - context.addRect(selectionRect) - - // Use even-odd rule to create the cutout - context.setFillColor(dimColor.cgColor) - context.fillPath(using: .evenOdd) - - context.restoreGState() - } else { - // Full dim when not selecting - dimColor.setFill() - bounds.fill() - } - } - - /// Draws the selection rectangle with border - private func drawSelectionRect(_ rect: CGRect, context: CGContext) { - // Fill - selectionFillColor.setFill() - rect.fill() - - // Stroke - let strokePath = NSBezierPath(rect: rect) - strokePath.lineWidth = 1.5 - selectionStrokeColor.setStroke() - strokePath.stroke() - - // Draw dashed inner border - context.saveGState() - context.setLineDash(phase: 0, lengths: [4, 4]) - context.setStrokeColor(NSColor.black.withAlphaComponent(0.5).cgColor) - context.setLineWidth(1.0) - context.addRect(rect.insetBy(dx: 1, dy: 1)) - context.strokePath() - context.restoreGState() - } - - /// Draws the crosshair cursor at the specified position - private func drawCrosshair(at point: NSPoint, context: CGContext) { - context.saveGState() - context.setStrokeColor(crosshairColor.cgColor) - context.setLineWidth(1.0) - - // Horizontal line - context.move(to: CGPoint(x: 0, y: point.y)) - context.addLine(to: CGPoint(x: bounds.width, y: point.y)) - - // Vertical line - context.move(to: CGPoint(x: point.x, y: 0)) - context.addLine(to: CGPoint(x: point.x, y: bounds.height)) - - context.strokePath() - context.restoreGState() - } - - /// Draws the dimensions label near the selection rectangle - private func drawDimensionsLabel(for rect: CGRect, context: CGContext) { - // Get dimensions in pixels (accounting for scale factor) - let scaleFactor = displayInfo?.scaleFactor ?? 1.0 - let pixelWidth = Int(rect.width * scaleFactor) - let pixelHeight = Int(rect.height * scaleFactor) - - let dimensionsText = "\(pixelWidth) × \(pixelHeight)" - - // Text attributes - let font = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium) - let attributes: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: labelTextColor - ] - - let textSize = (dimensionsText as NSString).size(withAttributes: attributes) - let labelPadding: CGFloat = 6 - let labelSize = CGSize( - width: textSize.width + labelPadding * 2, - height: textSize.height + labelPadding * 2 - ) - - // Position the label below and to the right of the selection - var labelOrigin = CGPoint( - x: rect.maxX - labelSize.width, - y: rect.minY - labelSize.height - 8 - ) - - // Ensure label stays within screen bounds - if labelOrigin.x < 0 { - labelOrigin.x = rect.minX - } - if labelOrigin.y < 0 { - labelOrigin.y = rect.maxY + 8 - } - if labelOrigin.x + labelSize.width > bounds.width { - labelOrigin.x = bounds.width - labelSize.width - } - - let labelRect = CGRect(origin: labelOrigin, size: labelSize) - - // Draw background - let backgroundPath = NSBezierPath(roundedRect: labelRect, xRadius: 4, yRadius: 4) - labelBackgroundColor.setFill() - backgroundPath.fill() - - // Draw text - let textPoint = CGPoint( - x: labelRect.origin.x + labelPadding, - y: labelRect.origin.y + labelPadding - ) - (dimensionsText as NSString).draw(at: textPoint, withAttributes: attributes) - } - - /// Creates a normalized rectangle from two points (handles any drag direction) - private func normalizedRect(from start: NSPoint, to end: NSPoint) -> CGRect { - let minX = min(start.x, end.x) - let minY = min(start.y, end.y) - let width = abs(end.x - start.x) - let height = abs(end.y - start.y) - return CGRect(x: minX, y: minY, width: width, height: height) - } - - // MARK: - Mouse Events - - override func mouseDown(with event: NSEvent) { - let point = convert(event.locationInWindow, from: nil) - selectionStart = point - selectionCurrent = point - isDragging = true - needsDisplay = true - } - - override func mouseDragged(with event: NSEvent) { - guard isDragging else { return } - - let point = convert(event.locationInWindow, from: nil) - selectionCurrent = point - needsDisplay = true - } - - override func mouseUp(with event: NSEvent) { - guard isDragging, - let start = selectionStart, - let current = selectionCurrent else { return } - - isDragging = false - - // Calculate final selection rectangle - let selectionRect = normalizedRect(from: start, to: current) - - // Only accept selection if it has meaningful size - if selectionRect.width >= 10 && selectionRect.height >= 10 { - // Convert to screen coordinates - guard let window = self.window, - let displayInfo = displayInfo else { return } - - #if DEBUG - print("=== SELECTION COORDINATE DEBUG ===") - print("[1] selectionRect (view coords): \(selectionRect)") - print("[2] window.frame: \(window.frame)") - print("[3] window.screen?.frame: \(String(describing: window.screen?.frame))") - #endif - - // The selectionRect is in view coordinates, convert to screen coordinates - // screenRect is in Cocoa coordinates (Y=0 at bottom of primary screen) - let screenRect = window.convertToScreen(selectionRect) - - #if DEBUG - print("[4] screenRect (after convertToScreen): \(screenRect)") - print("[5] NSScreen.screens.first?.frame: \(String(describing: NSScreen.screens.first?.frame))") - #endif - - // Get the screen height for coordinate conversion - // Use the window's screen, not necessarily the primary screen - // Cocoa uses Y=0 at bottom, ScreenCaptureKit/Quartz uses Y=0 at top - let screenHeight = window.screen?.frame.height ?? NSScreen.screens.first?.frame.height ?? 0 - - #if DEBUG - print("[6] screenHeight for conversion: \(screenHeight)") - #endif - - // Convert from Cocoa coordinates (Y=0 at bottom) to Quartz coordinates (Y=0 at top) - let quartzY = screenHeight - screenRect.origin.y - screenRect.height - - #if DEBUG - print("[7] quartzY (converted): \(quartzY)") - #endif - - // displayFrame is in Quartz coordinates (from SCDisplay) - let displayFrame = displayInfo.frame - - #if DEBUG - print("[8] displayInfo.frame (SCDisplay): \(displayFrame)") - print("[9] displayInfo.isPrimary: \(displayInfo.isPrimary)") - #endif - - // Now compute display-relative coordinates (both in Quartz coordinate system) - // Round to whole points to minimize fractional pixel issues when scaled - let relativeRect = CGRect( - x: round(screenRect.origin.x - displayFrame.origin.x), - y: round(quartzY - displayFrame.origin.y), - width: round(screenRect.width), - height: round(screenRect.height) - ) - - #if DEBUG - print("[10] FINAL relativeRect (rounded): \(relativeRect)") - print("[11] Normalized would be: x=\(relativeRect.origin.x / displayFrame.width), y=\(relativeRect.origin.y / displayFrame.height)") - print("=== END COORDINATE DEBUG ===") - #endif - - delegate?.selectionOverlay(didSelectRect: relativeRect, on: displayInfo) - } else { - // Too small - cancel - delegate?.selectionOverlayDidCancel() - } - - // Reset state - selectionStart = nil - selectionCurrent = nil - needsDisplay = true - } - - override func mouseMoved(with event: NSEvent) { - let point = convert(event.locationInWindow, from: nil) - mousePosition = point - needsDisplay = true - } - - override func mouseEntered(with event: NSEvent) { - // Change cursor to crosshair - NSCursor.crosshair.set() - } - - override func mouseExited(with event: NSEvent) { - // Reset cursor - NSCursor.arrow.set() - mousePosition = nil - needsDisplay = true - } - - // MARK: - Keyboard Events - - override var acceptsFirstResponder: Bool { true } - - override func keyDown(with event: NSEvent) { - // Escape key cancels selection - if event.keyCode == 53 { // Escape - isDragging = false - selectionStart = nil - selectionCurrent = nil - delegate?.selectionOverlayDidCancel() - return - } - - super.keyDown(with: event) - } - - // MARK: - Cursor - - override func resetCursorRects() { - addCursorRect(bounds, cursor: .crosshair) - } -} - -// MARK: - SelectionOverlayController - -/// Controller for managing selection overlay windows across all displays. -/// Creates and coordinates overlay windows for multi-display spanning selection. -@MainActor -final class SelectionOverlayController { - // MARK: - Properties - - /// Shared instance - static let shared = SelectionOverlayController() - - /// All active overlay windows (one per display) - private var overlayWindows: [SelectionOverlayWindow] = [] - - /// Delegate for selection events - weak var delegate: SelectionOverlayDelegate? - - /// Callback for when selection completes - var onSelectionComplete: ((CGRect, DisplayInfo) -> Void)? - - /// Callback for when selection is cancelled - var onSelectionCancel: (() -> Void)? - - // MARK: - Initialization - - private init() {} - - // MARK: - Public API - - /// Presents selection overlay on all connected displays. - func presentOverlay() async throws { - // Get all available displays - let displays = try await ScreenDetector.shared.availableDisplays() - - // Get matching screens - let screens = NSScreen.screens - - // Create overlay window for each display - for display in displays { - guard let screen = screens.first(where: { screen in - guard let screenNumber = screen.deviceDescription[ - NSDeviceDescriptionKey("NSScreenNumber") - ] as? CGDirectDisplayID else { - return false - } - return screenNumber == display.id - }) else { - continue - } - - let overlayWindow = SelectionOverlayWindow(screen: screen, displayInfo: display) - overlayWindow.setDelegate(self) - overlayWindows.append(overlayWindow) - } - - // Show all overlay windows - for window in overlayWindows { - window.showOverlay() - } - - // Make the first window (primary display) key - if let primaryWindow = overlayWindows.first { - primaryWindow.makeKey() - NSApp.activate(ignoringOtherApps: true) - } - } - - /// Dismisses all overlay windows. - func dismissOverlay() { - for window in overlayWindows { - window.hideOverlay() - } - overlayWindows.removeAll() - - // Reset cursor - NSCursor.arrow.set() - } -} - -// MARK: - SelectionOverlayController + SelectionOverlayDelegate - -extension SelectionOverlayController: SelectionOverlayDelegate { - func selectionOverlay(didSelectRect rect: CGRect, on display: DisplayInfo) { - // Dismiss all overlays first - dismissOverlay() - - // Notify via callback - onSelectionComplete?(rect, display) - } - - func selectionOverlayDidCancel() { - // Dismiss all overlays - dismissOverlay() - - // Notify via callback - onSelectionCancel?() - } -} diff --git a/ScreenCapture/Features/MenuBar/MenuBarController.swift b/ScreenCapture/Features/MenuBar/MenuBarController.swift deleted file mode 100644 index 1365c4f..0000000 --- a/ScreenCapture/Features/MenuBar/MenuBarController.swift +++ /dev/null @@ -1,206 +0,0 @@ -import AppKit - -/// Manages the menu bar status item and its menu. -/// Responsible for setting up the menu bar icon and building the app menu. -@MainActor -final class MenuBarController { - // MARK: - Properties - - /// The status item displayed in the menu bar - private var statusItem: NSStatusItem? - - /// Reference to the app delegate for action routing - private weak var appDelegate: AppDelegate? - - /// Store for recent captures - private let recentCapturesStore: RecentCapturesStore - - /// The submenu for recent captures - private var recentCapturesMenu: NSMenu? - - // MARK: - Initialization - - init(appDelegate: AppDelegate, recentCapturesStore: RecentCapturesStore) { - self.appDelegate = appDelegate - self.recentCapturesStore = recentCapturesStore - } - - // MARK: - Setup - - /// Sets up the menu bar status item with icon and menu - func setup() { - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) - - if let button = statusItem?.button { - button.image = NSImage(systemSymbolName: "camera.viewfinder", accessibilityDescription: "ScreenCapture") - button.image?.isTemplate = true - } - - statusItem?.menu = buildMenu() - } - - /// Removes the status item from the menu bar - func teardown() { - if let item = statusItem { - NSStatusBar.system.removeStatusItem(item) - statusItem = nil - } - } - - // MARK: - Menu Construction - - /// Builds the complete menu for the status item - private func buildMenu() -> NSMenu { - let menu = NSMenu() - - // Capture Full Screen - let fullScreenItem = NSMenuItem( - title: NSLocalizedString("menu.capture.full.screen", comment: "Capture Full Screen"), - action: #selector(AppDelegate.captureFullScreen), - keyEquivalent: "3" - ) - fullScreenItem.keyEquivalentModifierMask = [.command, .shift] - fullScreenItem.target = appDelegate - menu.addItem(fullScreenItem) - - // Capture Selection - let selectionItem = NSMenuItem( - title: NSLocalizedString("menu.capture.selection", comment: "Capture Selection"), - action: #selector(AppDelegate.captureSelection), - keyEquivalent: "4" - ) - selectionItem.keyEquivalentModifierMask = [.command, .shift] - selectionItem.target = appDelegate - menu.addItem(selectionItem) - - menu.addItem(NSMenuItem.separator()) - - // Recent Captures submenu - let recentItem = NSMenuItem( - title: NSLocalizedString("menu.recent.captures", comment: "Recent Captures"), - action: nil, - keyEquivalent: "" - ) - recentCapturesMenu = buildRecentCapturesMenu() - recentItem.submenu = recentCapturesMenu - menu.addItem(recentItem) - - menu.addItem(NSMenuItem.separator()) - - // Settings - let settingsItem = NSMenuItem( - title: NSLocalizedString("menu.settings", comment: "Settings..."), - action: #selector(AppDelegate.openSettings), - keyEquivalent: "," - ) - settingsItem.keyEquivalentModifierMask = [.command] - settingsItem.target = appDelegate - menu.addItem(settingsItem) - - menu.addItem(NSMenuItem.separator()) - - // Quit - let quitItem = NSMenuItem( - title: NSLocalizedString("menu.quit", comment: "Quit ScreenCapture"), - action: #selector(NSApplication.terminate(_:)), - keyEquivalent: "q" - ) - quitItem.keyEquivalentModifierMask = [.command] - menu.addItem(quitItem) - - return menu - } - - /// Builds the recent captures submenu - private func buildRecentCapturesMenu() -> NSMenu { - let menu = NSMenu() - updateRecentCapturesMenu(menu) - return menu - } - - /// Updates the recent captures submenu with current captures - func updateRecentCapturesMenu() { - guard let menu = recentCapturesMenu else { return } - updateRecentCapturesMenu(menu) - } - - /// Updates a given menu with recent captures - private func updateRecentCapturesMenu(_ menu: NSMenu) { - menu.removeAllItems() - - let captures = recentCapturesStore.captures - - if captures.isEmpty { - let emptyItem = NSMenuItem( - title: NSLocalizedString("menu.recent.captures.empty", comment: "No Recent Captures"), - action: nil, - keyEquivalent: "" - ) - emptyItem.isEnabled = false - menu.addItem(emptyItem) - } else { - for capture in captures { - let item = RecentCaptureMenuItem(capture: capture) - item.action = #selector(openRecentCapture(_:)) - item.target = self - menu.addItem(item) - } - - menu.addItem(NSMenuItem.separator()) - - let clearItem = NSMenuItem( - title: NSLocalizedString("menu.recent.captures.clear", comment: "Clear Recent"), - action: #selector(clearRecentCaptures), - keyEquivalent: "" - ) - clearItem.target = self - menu.addItem(clearItem) - } - } - - // MARK: - Actions - - /// Opens a recent capture file in Finder - @objc private func openRecentCapture(_ sender: NSMenuItem) { - guard let item = sender as? RecentCaptureMenuItem else { return } - let url = item.capture.filePath - - if item.capture.fileExists { - NSWorkspace.shared.activateFileViewerSelecting([url]) - } else { - // File no longer exists, remove from recent captures - recentCapturesStore.remove(capture: item.capture) - updateRecentCapturesMenu() - } - } - - /// Clears all recent captures - @objc private func clearRecentCaptures() { - recentCapturesStore.clear() - updateRecentCapturesMenu() - } -} - -// MARK: - Recent Capture Menu Item - -/// Custom menu item that holds a reference to a RecentCapture -private final class RecentCaptureMenuItem: NSMenuItem { - let capture: RecentCapture - - init(capture: RecentCapture) { - self.capture = capture - super.init(title: capture.filename, action: nil, keyEquivalent: "") - - // Set thumbnail image if available - if let thumbnailData = capture.thumbnailData, - let image = NSImage(data: thumbnailData) { - image.size = NSSize(width: 32, height: 32) - self.image = image - } - } - - @available(*, unavailable) - required init(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/ScreenCapture/Features/Preview/PreviewContentView.swift b/ScreenCapture/Features/Preview/PreviewContentView.swift deleted file mode 100644 index bfa09fb..0000000 --- a/ScreenCapture/Features/Preview/PreviewContentView.swift +++ /dev/null @@ -1,985 +0,0 @@ -import SwiftUI -import AppKit - -/// SwiftUI view for the screenshot preview content. -/// Displays the captured image with an info bar showing dimensions and file size. -struct PreviewContentView: View { - // MARK: - Properties - - /// The view model driving this view - @Bindable var viewModel: PreviewViewModel - - /// State for tracking the image display size and scale - @State private var imageDisplaySize: CGSize = .zero - @State private var imageScale: CGFloat = 1.0 - @State private var imageOffset: CGPoint = .zero - - /// Focus state for the text input field - @FocusState private var isTextFieldFocused: Bool - - /// Environment variable for Reduce Motion preference - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - // MARK: - Body - - var body: some View { - VStack(spacing: 0) { - // Main image view with annotation canvas - annotatedImageView - .frame(maxWidth: .infinity, maxHeight: .infinity) - - Divider() - - // Info bar - infoBar - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(.bar) - } - .alert( - "Error", - isPresented: .constant(viewModel.errorMessage != nil), - presenting: viewModel.errorMessage - ) { _ in - Button("OK") { - viewModel.errorMessage = nil - } - } message: { message in - Text(message) - } - } - - // MARK: - Subviews - - /// The main image display area with annotation overlay - @ViewBuilder - private var annotatedImageView: some View { - GeometryReader { geometry in - let imageSize = CGSize( - width: CGFloat(viewModel.image.width), - height: CGFloat(viewModel.image.height) - ) - let displayInfo = calculateDisplayInfo( - imageSize: imageSize, - containerSize: geometry.size - ) - - ZStack { - // Background - Color(nsColor: .windowBackgroundColor) - - // Image and annotations centered - VStack { - Spacer() - HStack { - Spacer() - - ZStack(alignment: .topLeading) { - // Base image - Image(viewModel.image, scale: 1.0, label: Text("Screenshot")) - .resizable() - .aspectRatio(contentMode: .fit) - .frame( - width: displayInfo.displaySize.width, - height: displayInfo.displaySize.height - ) - .accessibilityLabel(Text("Screenshot preview, \(viewModel.dimensionsText), from \(viewModel.displayName)")) - - // Annotation canvas overlay - AnnotationCanvas( - annotations: viewModel.annotations, - currentAnnotation: viewModel.currentAnnotation, - canvasSize: imageSize, - scale: displayInfo.scale, - selectedIndex: viewModel.selectedAnnotationIndex - ) - .frame( - width: displayInfo.displaySize.width, - height: displayInfo.displaySize.height - ) - - // Text input field overlay (when text tool is active) - if viewModel.isWaitingForTextInput, - let inputPosition = viewModel.textInputPosition { - textInputField( - at: inputPosition, - scale: displayInfo.scale - ) - } - - // Drawing gesture overlay - if viewModel.selectedTool != nil { - drawingGestureOverlay( - displaySize: displayInfo.displaySize, - scale: displayInfo.scale - ) - } - - // Selection/editing gesture overlay (when no tool and no crop mode) - if viewModel.selectedTool == nil && !viewModel.isCropMode { - selectionGestureOverlay( - displaySize: displayInfo.displaySize, - scale: displayInfo.scale - ) - } - - // Crop overlay - if viewModel.isCropMode { - cropOverlay( - displaySize: displayInfo.displaySize, - scale: displayInfo.scale - ) - } - } - .overlay(alignment: .topLeading) { - // Active tool indicator - if let tool = viewModel.selectedTool { - activeToolIndicator(tool: tool) - .padding(8) - } else if viewModel.isCropMode { - cropModeIndicator - .padding(8) - } - } - .overlay(alignment: .bottom) { - // Crop action buttons - if viewModel.cropRect != nil && !viewModel.isCropSelecting { - cropActionButtons - .padding(12) - } - } - - Spacer() - } - Spacer() - } - } - .onAppear { - imageDisplaySize = displayInfo.displaySize - imageScale = displayInfo.scale - } - .onChange(of: geometry.size) { _, newSize in - let newInfo = calculateDisplayInfo( - imageSize: imageSize, - containerSize: newSize - ) - imageDisplaySize = newInfo.displaySize - imageScale = newInfo.scale - } - } - .contentShape(Rectangle()) - .cursor(cursorForCurrentTool) - } - - /// Calculates the display size and scale for fitting the image in the container - private func calculateDisplayInfo( - imageSize: CGSize, - containerSize: CGSize - ) -> (displaySize: CGSize, scale: CGFloat) { - let widthScale = containerSize.width / imageSize.width - let heightScale = containerSize.height / imageSize.height - - // For large images, scale down to fit. For small images, scale up to fill - // at least 50% of the container (but cap at 4x to avoid excessive pixelation) - let fitScale = min(widthScale, heightScale) - let scale: CGFloat - if fitScale > 1.0 { - // Image is smaller than container - scale up but cap at 4x - scale = min(fitScale, 4.0) - } else { - // Image is larger than container - scale down to fit - scale = fitScale - } - - let displaySize = CGSize( - width: imageSize.width * scale, - height: imageSize.height * scale - ) - - return (displaySize, scale) - } - - /// The cursor to use based on the current tool - private var cursorForCurrentTool: NSCursor { - if viewModel.isCropMode { - return .crosshair - } - - guard let tool = viewModel.selectedTool else { - // No tool selected - show move cursor if dragging annotation - if viewModel.isDraggingAnnotation { - return .closedHand - } else if viewModel.selectedAnnotationIndex != nil { - return .openHand - } - return .arrow - } - - switch tool { - case .rectangle, .freehand, .arrow: - return .crosshair - case .text: - return .iBeam - } - } - - /// Overlay for capturing drawing gestures - private func drawingGestureOverlay( - displaySize: CGSize, - scale: CGFloat - ) -> some View { - Color.clear - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - let point = convertToImageCoordinates( - value.location, - scale: scale - ) - - if value.translation == .zero { - // First point - begin drawing - viewModel.beginDrawing(at: point) - } else { - // Subsequent points - continue drawing - viewModel.continueDrawing(to: point) - } - } - .onEnded { value in - let point = convertToImageCoordinates( - value.location, - scale: scale - ) - viewModel.endDrawing(at: point) - } - ) - } - - /// Converts view coordinates to image coordinates - private func convertToImageCoordinates( - _ point: CGPoint, - scale: CGFloat - ) -> CGPoint { - CGPoint( - x: point.x / scale, - y: point.y / scale - ) - } - - /// Overlay for selecting and dragging annotations - private func selectionGestureOverlay( - displaySize: CGSize, - scale: CGFloat - ) -> some View { - Color.clear - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - let point = convertToImageCoordinates( - value.location, - scale: scale - ) - - if value.translation == .zero { - // First tap - check for hit - if let hitIndex = viewModel.hitTest(at: point) { - // Hit an annotation - select it and prepare for dragging - viewModel.selectAnnotation(at: hitIndex) - viewModel.beginDraggingAnnotation(at: point) - } else { - // Clicked on empty space - deselect - viewModel.deselectAnnotation() - } - } else if viewModel.isDraggingAnnotation { - // Dragging a selected annotation - viewModel.continueDraggingAnnotation(to: point) - } - } - .onEnded { _ in - viewModel.endDraggingAnnotation() - } - ) - } - - /// Text input field for text annotations - private func textInputField( - at position: CGPoint, - scale: CGFloat - ) -> some View { - let scaledPosition = CGPoint( - x: position.x * scale, - y: position.y * scale - ) - - return TextField("Enter text", text: $viewModel.textInputContent) - .textFieldStyle(.plain) - .font(.system(size: 14 * scale)) - .foregroundColor(AppSettings.shared.strokeColor.color) - .padding(4) - .background(Color.white.opacity(0.9)) - .cornerRadius(4) - .frame(minWidth: 100, maxWidth: 300) - .position(x: scaledPosition.x + 50, y: scaledPosition.y + 10) - .focused($isTextFieldFocused) - .onAppear { - isTextFieldFocused = true - } - .onSubmit { - viewModel.commitTextInput() - isTextFieldFocused = false - } - .onExitCommand { - viewModel.cancelCurrentDrawing() - isTextFieldFocused = false - } - } - - /// Active tool indicator badge - private func activeToolIndicator(tool: AnnotationToolType) -> some View { - HStack(spacing: 4) { - Image(systemName: tool.systemImage) - Text(tool.displayName) - .font(.caption) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.ultraThinMaterial) - .cornerRadius(6) - .foregroundStyle(.secondary) - .accessibilityElement(children: .combine) - .accessibilityLabel(Text("Active tool: \(tool.displayName)")) - } - - /// Crop mode indicator badge - private var cropModeIndicator: some View { - HStack(spacing: 4) { - Image(systemName: "crop") - Text("Crop") - .font(.caption) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.ultraThinMaterial) - .cornerRadius(6) - .foregroundStyle(.secondary) - .accessibilityElement(children: .combine) - .accessibilityLabel(Text("Crop mode active")) - } - - /// Overlay for capturing crop selection gestures - private func cropOverlay( - displaySize: CGSize, - scale: CGFloat - ) -> some View { - ZStack { - // Dim overlay outside crop area - if let cropRect = viewModel.cropRect, cropRect.width > 0, cropRect.height > 0 { - let scaledRect = CGRect( - x: cropRect.origin.x * scale, - y: cropRect.origin.y * scale, - width: cropRect.width * scale, - height: cropRect.height * scale - ) - - // Create a shape that covers everything except the crop area - CropDimOverlay(cropRect: scaledRect) - .fill(Color.black.opacity(0.5)) - .allowsHitTesting(false) - .transaction { $0.animation = nil } - - // Crop selection border - Rectangle() - .stroke(Color.white, lineWidth: 2) - .frame(width: scaledRect.width, height: scaledRect.height) - .position(x: scaledRect.midX, y: scaledRect.midY) - .allowsHitTesting(false) - - // Corner handles - ForEach(0..<4, id: \.self) { corner in - let position = cornerPosition(for: corner, in: scaledRect) - RoundedRectangle(cornerRadius: 2) - .fill(Color.white) - .frame(width: 10, height: 10) - .position(position) - .allowsHitTesting(false) - } - - // Crop dimensions label - cropDimensionsLabel(for: cropRect, scaledRect: scaledRect) - } - - // Gesture capture layer - Color.clear - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - let point = convertToImageCoordinates(value.location, scale: scale) - if value.translation == .zero { - viewModel.beginCropSelection(at: point) - } else { - viewModel.continueCropSelection(to: point) - } - } - .onEnded { value in - let point = convertToImageCoordinates(value.location, scale: scale) - viewModel.endCropSelection(at: point) - } - ) - } - } - - /// Gets the position for a corner handle - private func cornerPosition(for corner: Int, in rect: CGRect) -> CGPoint { - switch corner { - case 0: return CGPoint(x: rect.minX, y: rect.minY) // Top-left - case 1: return CGPoint(x: rect.maxX, y: rect.minY) // Top-right - case 2: return CGPoint(x: rect.minX, y: rect.maxY) // Bottom-left - case 3: return CGPoint(x: rect.maxX, y: rect.maxY) // Bottom-right - default: return .zero - } - } - - /// Crop dimensions label - private func cropDimensionsLabel(for cropRect: CGRect, scaledRect: CGRect) -> some View { - let width = Int(cropRect.width) - let height = Int(cropRect.height) - - return Text("\(width) × \(height)") - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundColor(.white) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.black.opacity(0.75)) - .cornerRadius(4) - .position( - x: scaledRect.midX, - y: max(scaledRect.minY - 20, 15) - ) - .allowsHitTesting(false) - } - - /// Crop action buttons (Apply/Cancel) - private var cropActionButtons: some View { - HStack(spacing: 12) { - Button { - viewModel.cancelCrop() - } label: { - Label("Cancel", systemImage: "xmark") - } - .buttonStyle(.bordered) - .keyboardShortcut(.escape, modifiers: []) - - Button { - viewModel.applyCrop() - } label: { - Label("Apply Crop", systemImage: "checkmark") - } - .buttonStyle(.borderedProminent) - .keyboardShortcut(.return, modifiers: []) - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(.ultraThinMaterial) - .cornerRadius(10) - } - - /// The info bar at the bottom showing dimensions and file size - private var infoBar: some View { - HStack(spacing: 12) { - // Left side: Image info (compact) - HStack(spacing: 8) { - Text(viewModel.dimensionsText) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .help("Image dimensions") - - Text("•") - .foregroundStyle(.tertiary) - - Text(viewModel.fileSizeText) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .help("Estimated file size") - } - .fixedSize() - - Divider() - .frame(height: 16) - - // Center: Scrollable toolbar - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - toolBar - } - .padding(.horizontal, 4) - } - .frame(minWidth: 100) - - Divider() - .frame(height: 16) - - // Right side: Action buttons (fixed) - actionButtons - .fixedSize() - } - } - - /// Tool selection buttons - private var toolBar: some View { - HStack(spacing: 4) { - ForEach(AnnotationToolType.allCases) { tool in - let isSelected = viewModel.selectedTool == tool - Button { - if isSelected { - viewModel.selectTool(nil) - } else { - viewModel.selectTool(tool) - } - } label: { - Image(systemName: tool.systemImage) - .frame(width: 24, height: 24) - } - .buttonStyle(.accessoryBar) - .background( - isSelected - ? Color.accentColor.opacity(0.2) - : Color.clear - ) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .help("\(tool.displayName) (\(String(tool.keyboardShortcut).uppercased()))") - .accessibilityLabel(Text(tool.displayName)) - .accessibilityHint(Text("Press \(String(tool.keyboardShortcut).uppercased()) to toggle")) - .accessibilityAddTraits(isSelected ? [.isSelected] : []) - } - - // Show customization options when a tool is selected OR an annotation is selected - if viewModel.selectedTool != nil || viewModel.selectedAnnotationIndex != nil { - Divider() - .frame(height: 16) - - styleCustomizationBar - } - } - .accessibilityElement(children: .contain) - .accessibilityLabel(Text("Annotation tools")) - } - - /// Style customization bar for color and stroke width - @ViewBuilder - private var styleCustomizationBar: some View { - let isEditingAnnotation = viewModel.selectedAnnotationIndex != nil - let effectiveToolType = isEditingAnnotation ? viewModel.selectedAnnotationType : viewModel.selectedTool - - HStack(spacing: 8) { - // Show "Editing" label when modifying existing annotation - if isEditingAnnotation { - Text("Edit:") - .font(.caption) - .foregroundStyle(.secondary) - } - - // Color picker with preset colors - HStack(spacing: 2) { - ForEach(presetColors, id: \.self) { color in - Button { - if isEditingAnnotation { - viewModel.updateSelectedAnnotationColor(CodableColor(color)) - } else { - AppSettings.shared.strokeColor = CodableColor(color) - } - } label: { - Circle() - .fill(color) - .frame(width: 16, height: 16) - .overlay { - let currentColor = isEditingAnnotation - ? (viewModel.selectedAnnotationColor?.color ?? .clear) - : AppSettings.shared.strokeColor.color - if colorsAreEqual(currentColor, color) { - Circle() - .stroke(Color.primary, lineWidth: 2) - } - } - .overlay { - if color == .white || color == .yellow { - Circle() - .stroke(Color.gray.opacity(0.3), lineWidth: 1) - } - } - } - .buttonStyle(.plain) - .help(colorName(for: color)) - } - - ColorPicker("", selection: Binding( - get: { - if isEditingAnnotation { - return viewModel.selectedAnnotationColor?.color ?? .red - } - return AppSettings.shared.strokeColor.color - }, - set: { newColor in - if isEditingAnnotation { - viewModel.updateSelectedAnnotationColor(CodableColor(newColor)) - } else { - AppSettings.shared.strokeColor = CodableColor(newColor) - } - } - ), supportsOpacity: false) - .labelsHidden() - .frame(width: 24) - } - - Divider() - .frame(height: 16) - - // Rectangle fill toggle (for rectangle only) - if effectiveToolType == .rectangle { - let isFilled = isEditingAnnotation - ? (viewModel.selectedAnnotationIsFilled ?? false) - : AppSettings.shared.rectangleFilled - - Button { - if isEditingAnnotation { - viewModel.updateSelectedAnnotationFilled(!isFilled) - } else { - AppSettings.shared.rectangleFilled.toggle() - } - } label: { - Image(systemName: isFilled ? "rectangle.fill" : "rectangle") - .frame(width: 24, height: 24) - } - .buttonStyle(.accessoryBar) - .background( - isFilled - ? Color.accentColor.opacity(0.2) - : Color.clear - ) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .help(isFilled ? "Filled (click for hollow)" : "Hollow (click for filled)") - - Divider() - .frame(height: 16) - } - - // Stroke width control (for rectangle/freehand/arrow - only show for hollow rectangles) - if effectiveToolType == .freehand || effectiveToolType == .arrow || - (effectiveToolType == .rectangle && !(isEditingAnnotation ? (viewModel.selectedAnnotationIsFilled ?? false) : AppSettings.shared.rectangleFilled)) { - HStack(spacing: 4) { - Image(systemName: "lineweight") - .font(.caption) - .foregroundStyle(.secondary) - - Slider( - value: Binding( - get: { - if isEditingAnnotation { - return viewModel.selectedAnnotationStrokeWidth ?? 3.0 - } - return AppSettings.shared.strokeWidth - }, - set: { newWidth in - if isEditingAnnotation { - viewModel.updateSelectedAnnotationStrokeWidth(newWidth) - } else { - AppSettings.shared.strokeWidth = newWidth - } - } - ), - in: 1.0...20.0, - step: 0.5 - ) - .frame(width: 80) - .help("Stroke Width") - - let width = isEditingAnnotation - ? Int(viewModel.selectedAnnotationStrokeWidth ?? 3) - : Int(AppSettings.shared.strokeWidth) - Text("\(width)") - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 20) - } - } - - // Text size control - if effectiveToolType == .text { - HStack(spacing: 4) { - Image(systemName: "textformat.size") - .font(.caption) - .foregroundStyle(.secondary) - - Slider( - value: Binding( - get: { - if isEditingAnnotation { - return viewModel.selectedAnnotationFontSize ?? 16.0 - } - return AppSettings.shared.textSize - }, - set: { newSize in - if isEditingAnnotation { - viewModel.updateSelectedAnnotationFontSize(newSize) - } else { - AppSettings.shared.textSize = newSize - } - } - ), - in: 8.0...72.0, - step: 1 - ) - .frame(width: 80) - .help("Text Size") - - let size = isEditingAnnotation - ? Int(viewModel.selectedAnnotationFontSize ?? 16) - : Int(AppSettings.shared.textSize) - Text("\(size)") - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 20) - } - } - - // Delete button for selected annotation - if isEditingAnnotation { - Divider() - .frame(height: 16) - - Button { - viewModel.deleteSelectedAnnotation() - } label: { - Image(systemName: "trash") - .foregroundStyle(.red) - } - .buttonStyle(.plain) - .help("Delete selected annotation (Delete)") - } - } - } - - /// Preset colors for quick selection (reduced set to save space) - private var presetColors: [Color] { - [.red, .yellow, .green, .blue, .black] - } - - /// Compare colors approximately - private func colorsAreEqual(_ a: Color, _ b: Color) -> Bool { - let nsA = NSColor(a).usingColorSpace(.deviceRGB) - let nsB = NSColor(b).usingColorSpace(.deviceRGB) - guard let colorA = nsA, let colorB = nsB else { return false } - - let tolerance: CGFloat = 0.01 - return abs(colorA.redComponent - colorB.redComponent) < tolerance && - abs(colorA.greenComponent - colorB.greenComponent) < tolerance && - abs(colorA.blueComponent - colorB.blueComponent) < tolerance - } - - /// Get accessible color name - private func colorName(for color: Color) -> String { - switch color { - case .red: return "Red" - case .orange: return "Orange" - case .yellow: return "Yellow" - case .green: return "Green" - case .blue: return "Blue" - case .purple: return "Purple" - case .white: return "White" - case .black: return "Black" - default: return "Custom" - } - } - - /// Action buttons for save, copy, etc. - private var actionButtons: some View { - HStack(spacing: 8) { - // Crop button - Button { - viewModel.toggleCropMode() - } label: { - Image(systemName: "crop") - } - .buttonStyle(.accessoryBar) - .background( - viewModel.isCropMode - ? Color.accentColor.opacity(0.2) - : Color.clear - ) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .help("Crop (C)") - .accessibilityLabel(Text("Crop")) - .accessibilityHint(Text("Press C to toggle")) - - Divider() - .frame(height: 16) - .accessibilityHidden(true) - - // Undo/Redo - Button { - viewModel.undo() - } label: { - Image(systemName: "arrow.uturn.backward") - } - .disabled(!viewModel.canUndo) - .help("Undo (⌘Z)") - .accessibilityLabel(Text("Undo")) - .accessibilityHint(Text("Command Z")) - - Button { - viewModel.redo() - } label: { - Image(systemName: "arrow.uturn.forward") - } - .disabled(!viewModel.canRedo) - .help("Redo (⌘⇧Z)") - .accessibilityLabel(Text("Redo")) - .accessibilityHint(Text("Command Shift Z")) - - Divider() - .frame(height: 16) - .accessibilityHidden(true) - - // Copy to clipboard and dismiss - Button { - viewModel.copyToClipboard() - viewModel.dismiss() - } label: { - if viewModel.isCopying { - if reduceMotion { - Image(systemName: "ellipsis") - .frame(width: 16, height: 16) - } else { - ProgressView() - .controlSize(.small) - .frame(width: 16, height: 16) - } - } else { - Image(systemName: "doc.on.doc") - } - } - .disabled(viewModel.isCopying) - .help("Copy to Clipboard (⌘C)") - .accessibilityLabel(Text(viewModel.isCopying ? "Copying to clipboard" : "Copy to clipboard")) - .accessibilityHint(Text("Command C")) - - // Save - Button { - viewModel.saveScreenshot() - } label: { - if viewModel.isSaving { - if reduceMotion { - Image(systemName: "ellipsis") - .frame(width: 16, height: 16) - } else { - ProgressView() - .controlSize(.small) - .frame(width: 16, height: 16) - } - } else { - Image(systemName: "square.and.arrow.down") - } - } - .disabled(viewModel.isSaving) - .help("Save (⌘S or Enter)") - .accessibilityLabel(Text(viewModel.isSaving ? "Saving screenshot" : "Save screenshot")) - .accessibilityHint(Text("Command S or Enter")) - - Divider() - .frame(height: 16) - .accessibilityHidden(true) - - // Dismiss - Button { - viewModel.dismiss() - } label: { - Image(systemName: "xmark") - } - .help("Dismiss (Escape)") - .accessibilityLabel(Text("Dismiss preview")) - .accessibilityHint(Text("Escape key")) - } - .buttonStyle(.accessoryBar) - .accessibilityElement(children: .contain) - .accessibilityLabel(Text("Screenshot actions")) - } -} - -// MARK: - Crop Dim Overlay Shape - -/// A shape that covers everything except a rectangular cutout -struct CropDimOverlay: Shape { - var cropRect: CGRect - - var animatableData: AnimatablePair, AnimatablePair> { - get { - AnimatablePair( - AnimatablePair(cropRect.origin.x, cropRect.origin.y), - AnimatablePair(cropRect.width, cropRect.height) - ) - } - set { - cropRect = CGRect( - x: newValue.first.first, - y: newValue.first.second, - width: newValue.second.first, - height: newValue.second.second - ) - } - } - - func path(in rect: CGRect) -> Path { - var path = Path() - path.addRect(rect) - path.addRect(cropRect) - return path - } -} - -// MARK: - Preview - -#if DEBUG -#Preview { - // Create a simple test image for preview - let testImage: CGImage = { - let width = 800 - let height = 600 - let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)! - let context = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: 0, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - )! - - // Fill with a gradient - context.setFillColor(NSColor.systemBlue.cgColor) - context.fill(CGRect(x: 0, y: 0, width: width, height: height)) - - return context.makeImage()! - }() - - let display = DisplayInfo( - id: 1, - name: "Built-in Display", - frame: CGRect(x: 0, y: 0, width: 1920, height: 1080), - scaleFactor: 2.0, - isPrimary: true - ) - - let screenshot = Screenshot( - image: testImage, - sourceDisplay: display - ) - - let viewModel = PreviewViewModel(screenshot: screenshot) - - return PreviewContentView(viewModel: viewModel) - .frame(width: 600, height: 400) -} -#endif diff --git a/ScreenCapture/Features/Preview/PreviewViewModel.swift b/ScreenCapture/Features/Preview/PreviewViewModel.swift deleted file mode 100644 index 1b79dd6..0000000 --- a/ScreenCapture/Features/Preview/PreviewViewModel.swift +++ /dev/null @@ -1,983 +0,0 @@ -import Foundation -import SwiftUI -import AppKit -import Observation - -/// ViewModel for the screenshot preview window. -/// Manages screenshot state, annotations, and user actions. -/// Must run on MainActor for UI binding. -@MainActor -@Observable -final class PreviewViewModel { - // MARK: - Properties - - /// The current screenshot being previewed - private(set) var screenshot: Screenshot - - /// Whether the preview is currently visible (internal state, not observed) - @ObservationIgnored - private(set) var isVisible: Bool = false - - /// Current annotation tool selection (nil = no tool active) - var selectedTool: AnnotationToolType? { - didSet { - // Cancel any in-progress drawing when switching tools - if oldValue != selectedTool { - cancelCurrentDrawing() - } - // Exit crop mode when selecting an annotation tool - if selectedTool != nil && isCropMode { - isCropMode = false - cropRect = nil - } - } - } - - /// Whether crop mode is active - var isCropMode: Bool = false { - didSet { - // Deselect annotation tool when entering crop mode - if isCropMode && selectedTool != nil { - selectedTool = nil - } - if !isCropMode { - cropRect = nil - } - } - } - - /// The current crop selection rectangle (in image coordinates) - var cropRect: CGRect? - - /// Whether a crop selection is in progress - var isCropSelecting: Bool = false - - /// Start point of crop selection - private var cropStartPoint: CGPoint? - - /// Error message to display (if any) - var errorMessage: String? - - /// Whether save is in progress - private(set) var isSaving: Bool = false - - /// Whether copy is in progress - private(set) var isCopying: Bool = false - - /// Callback when the preview should be dismissed - @ObservationIgnored - var onDismiss: (() -> Void)? - - /// Callback when screenshot is saved successfully - @ObservationIgnored - var onSave: ((URL) -> Void)? - - /// App settings for default export options - @ObservationIgnored - private let settings = AppSettings.shared - - /// Image exporter for saving screenshots - @ObservationIgnored - private let imageExporter = ImageExporter.shared - - /// Clipboard service for copying screenshots - @ObservationIgnored - private let clipboardService = ClipboardService.shared - - /// Recent captures store - @ObservationIgnored - private let recentCapturesStore: RecentCapturesStore - - // MARK: - Annotation Tools - - /// Rectangle drawing tool - @ObservationIgnored - private(set) var rectangleTool = RectangleTool() - - /// Freehand drawing tool - @ObservationIgnored - private(set) var freehandTool = FreehandTool() - - /// Arrow drawing tool - @ObservationIgnored - private(set) var arrowTool = ArrowTool() - - /// Text placement tool - @ObservationIgnored - private(set) var textTool = TextTool() - - /// Counter to trigger view updates during drawing - /// Incremented each time drawing state changes to force re-render - private(set) var drawingUpdateCounter: Int = 0 - - /// Cached current annotation for observation - private(set) var _currentAnnotation: Annotation? - - /// Observable state for text input visibility (since textTool is @ObservationIgnored) - private(set) var _isWaitingForTextInput: Bool = false - - /// Observable position for text input field - private(set) var _textInputPosition: CGPoint? - - // MARK: - Annotation Selection & Editing - - /// Index of the currently selected annotation (nil = none selected) - var selectedAnnotationIndex: Int? - - /// Whether we're currently dragging a selected annotation - private(set) var isDraggingAnnotation: Bool = false - - /// The starting point of a drag operation (in image coordinates) - @ObservationIgnored - private var dragStartPoint: CGPoint? - - /// The original position of the annotation being dragged - @ObservationIgnored - private var dragOriginalPosition: CGPoint? - - /// Whether any tool is currently drawing - var isDrawing: Bool { - currentTool?.isActive ?? false - } - - /// The current in-progress annotation for preview - var currentAnnotation: Annotation? { - _currentAnnotation - } - - /// The currently active tool instance - private var currentTool: (any AnnotationTool)? { - guard let selectedTool else { return nil } - switch selectedTool { - case .rectangle: return rectangleTool - case .freehand: return freehandTool - case .arrow: return arrowTool - case .text: return textTool - } - } - - /// Whether we're waiting for text input - var isWaitingForTextInput: Bool { - _isWaitingForTextInput - } - - /// The current text input content - var textInputContent: String { - get { textTool.currentText } - set { textTool.updateText(newValue) } - } - - /// The position for text input field - var textInputPosition: CGPoint? { - _textInputPosition - } - - // MARK: - Computed Properties - - /// The CGImage being previewed - var image: CGImage { - screenshot.image - } - - /// Current annotations on the screenshot - var annotations: [Annotation] { - screenshot.annotations - } - - /// Formatted dimensions string (e.g., "1920 × 1080") - var dimensionsText: String { - screenshot.formattedDimensions - } - - /// Formatted estimated file size (e.g., "1.2 MB") - var fileSizeText: String { - // Use the actual format from settings for accurate estimation - let format = settings.defaultFormat - let pixelCount = Double(screenshot.image.width * screenshot.image.height) - let bytes = Int(pixelCount * format.estimatedBytesPerPixel) - - if bytes < 1024 { - return "\(bytes) B" - } else if bytes < 1024 * 1024 { - return String(format: "%.1f KB", Double(bytes) / 1024.0) - } else { - return String(format: "%.1f MB", Double(bytes) / (1024.0 * 1024.0)) - } - } - - /// Source display name - var displayName: String { - screenshot.sourceDisplay.name - } - - /// Current export format - var format: ExportFormat { - get { screenshot.format } - set { screenshot = screenshot.with(format: newValue) } - } - - /// Whether undo is available - var canUndo: Bool { - !undoStack.isEmpty - } - - /// Whether redo is available - var canRedo: Bool { - !redoStack.isEmpty - } - - // MARK: - Undo/Redo - - /// Stack of previous screenshot states for undo (includes image + annotations) - private var undoStack: [Screenshot] = [] - - /// Stack of undone screenshot states for redo - private var redoStack: [Screenshot] = [] - - /// Maximum undo history - @ObservationIgnored - private let maxUndoLevels = 50 - - /// Counter that increments when image size changes (for window resize notification) - private(set) var imageSizeChangeCounter: Int = 0 - - // MARK: - Initialization - - init(screenshot: Screenshot, recentCapturesStore: RecentCapturesStore? = nil) { - self.screenshot = screenshot - self.recentCapturesStore = recentCapturesStore ?? RecentCapturesStore() - } - - // MARK: - Public API - - /// Shows the preview window - func show() { - isVisible = true - } - - /// Hides the preview window - func hide() { - // Guard against recursive calls - guard isVisible else { return } - isVisible = false - onDismiss?() - } - - /// Adds an annotation to the screenshot - func addAnnotation(_ annotation: Annotation) { - pushUndoState() - screenshot = screenshot.adding(annotation) - redoStack.removeAll() - } - - /// Removes the annotation at the given index - func removeAnnotation(at index: Int) { - guard index >= 0 && index < annotations.count else { return } - pushUndoState() - screenshot = screenshot.removingAnnotation(at: index) - redoStack.removeAll() - } - - /// Undoes the last change (annotation or crop) - func undo() { - guard let previousState = undoStack.popLast() else { return } - - // Check if image size will change - let currentSize = CGSize(width: screenshot.image.width, height: screenshot.image.height) - let previousSize = CGSize(width: previousState.image.width, height: previousState.image.height) - let imageSizeChanged = currentSize != previousSize - - redoStack.append(screenshot) - screenshot = previousState - - // Notify if image size changed (for window resize) - if imageSizeChanged { - imageSizeChangeCounter += 1 - } - } - - /// Redoes the last undone change - func redo() { - guard let nextState = redoStack.popLast() else { return } - - // Check if image size will change - let currentSize = CGSize(width: screenshot.image.width, height: screenshot.image.height) - let nextSize = CGSize(width: nextState.image.width, height: nextState.image.height) - let imageSizeChanged = currentSize != nextSize - - undoStack.append(screenshot) - screenshot = nextState - - // Notify if image size changed (for window resize) - if imageSizeChanged { - imageSizeChangeCounter += 1 - } - } - - /// Selects an annotation tool - func selectTool(_ tool: AnnotationToolType?) { - selectedTool = tool - } - - // MARK: - Drawing Methods - - /// Begins a drawing gesture at the given point - /// - Parameter point: The point in image coordinates - func beginDrawing(at point: CGPoint) { - guard let selectedTool else { return } - - // Apply current stroke/text styles from settings - let strokeStyle = StrokeStyle( - color: settings.strokeColor, - lineWidth: settings.strokeWidth - ) - let textStyle = TextStyle( - color: settings.strokeColor, - fontSize: settings.textSize, - fontName: ".AppleSystemUIFont" - ) - - switch selectedTool { - case .rectangle: - rectangleTool.strokeStyle = strokeStyle - rectangleTool.isFilled = settings.rectangleFilled - rectangleTool.beginDrawing(at: point) - case .freehand: - freehandTool.strokeStyle = strokeStyle - freehandTool.beginDrawing(at: point) - case .arrow: - arrowTool.strokeStyle = strokeStyle - arrowTool.beginDrawing(at: point) - case .text: - textTool.textStyle = textStyle - textTool.beginDrawing(at: point) - // Update observable properties for text input UI - _isWaitingForTextInput = true - _textInputPosition = point - } - - updateCurrentAnnotation() - } - - /// Continues a drawing gesture to the given point - /// - Parameter point: The point in image coordinates - func continueDrawing(to point: CGPoint) { - guard let selectedTool else { return } - - switch selectedTool { - case .rectangle: - rectangleTool.continueDrawing(to: point) - case .freehand: - freehandTool.continueDrawing(to: point) - case .arrow: - arrowTool.continueDrawing(to: point) - case .text: - textTool.continueDrawing(to: point) - } - - updateCurrentAnnotation() - } - - /// Ends a drawing gesture at the given point - /// - Parameter point: The point in image coordinates - func endDrawing(at point: CGPoint) { - guard let selectedTool else { return } - - var annotation: Annotation? - - switch selectedTool { - case .rectangle: - annotation = rectangleTool.endDrawing(at: point) - case .freehand: - annotation = freehandTool.endDrawing(at: point) - case .arrow: - annotation = arrowTool.endDrawing(at: point) - case .text: - // Text tool doesn't finish on mouse up - _ = textTool.endDrawing(at: point) - updateCurrentAnnotation() - return - } - - _currentAnnotation = nil - drawingUpdateCounter += 1 - - if let annotation { - addAnnotation(annotation) - } - } - - /// Cancels the current drawing operation - func cancelCurrentDrawing() { - rectangleTool.cancelDrawing() - freehandTool.cancelDrawing() - arrowTool.cancelDrawing() - textTool.cancelDrawing() - _currentAnnotation = nil - _isWaitingForTextInput = false - _textInputPosition = nil - drawingUpdateCounter += 1 - } - - /// Updates the cached current annotation to trigger view refresh - private func updateCurrentAnnotation() { - _currentAnnotation = currentTool?.currentAnnotation - drawingUpdateCounter += 1 - } - - // MARK: - Crop Methods - - /// Toggles crop mode - func toggleCropMode() { - isCropMode.toggle() - } - - /// Begins a crop selection at the given point - func beginCropSelection(at point: CGPoint) { - guard isCropMode else { return } - cropStartPoint = point - cropRect = CGRect(origin: point, size: .zero) - isCropSelecting = true - } - - /// Continues a crop selection to the given point - func continueCropSelection(to point: CGPoint) { - guard isCropMode, let start = cropStartPoint else { return } - - let minX = min(start.x, point.x) - let minY = min(start.y, point.y) - let width = abs(point.x - start.x) - let height = abs(point.y - start.y) - - cropRect = CGRect(x: minX, y: minY, width: width, height: height) - } - - /// Ends a crop selection - func endCropSelection(at point: CGPoint) { - guard isCropMode else { return } - continueCropSelection(to: point) - isCropSelecting = false - - // Validate minimum crop size - if let rect = cropRect, rect.width < 10 || rect.height < 10 { - cropRect = nil - } - } - - /// Applies the current crop selection - func applyCrop() { - guard let rect = cropRect else { return } - - // Ensure crop rect is within image bounds - let imageWidth = CGFloat(screenshot.image.width) - let imageHeight = CGFloat(screenshot.image.height) - - let clampedRect = CGRect( - x: max(0, rect.origin.x), - y: max(0, rect.origin.y), - width: min(rect.width, imageWidth - rect.origin.x), - height: min(rect.height, imageHeight - rect.origin.y) - ) - - guard clampedRect.width >= 10, clampedRect.height >= 10 else { - errorMessage = "Crop area is too small" - cropRect = nil - isCropMode = false - return - } - - // Create cropped image - guard let croppedImage = screenshot.image.cropping(to: clampedRect) else { - errorMessage = "Failed to crop image" - return - } - - // Push undo state before cropping - pushUndoState() - - // Update screenshot with cropped image and clear annotations - // (annotations would need to be recalculated for the new crop, so we clear them) - screenshot = Screenshot( - image: croppedImage, - captureDate: screenshot.captureDate, - sourceDisplay: screenshot.sourceDisplay - ) - - // Clear redo stack since we made a change - redoStack.removeAll() - - // Exit crop mode - isCropMode = false - cropRect = nil - - // Notify that image size changed (for window resize) - imageSizeChangeCounter += 1 - } - - /// Cancels the current crop selection - func cancelCrop() { - cropRect = nil - isCropMode = false - isCropSelecting = false - cropStartPoint = nil - } - - // MARK: - Annotation Selection & Editing - - /// Tests if a point hits an annotation and returns its index - /// - Parameter point: The point to test in image coordinates - /// - Returns: The index of the hit annotation, or nil if none hit - func hitTest(at point: CGPoint) -> Int? { - // Check in reverse order (top-most first) - for (index, annotation) in annotations.enumerated().reversed() { - let bounds = annotation.bounds - // Add some padding for easier selection - let expandedBounds = bounds.insetBy(dx: -10, dy: -10) - if expandedBounds.contains(point) { - return index - } - } - return nil - } - - /// Selects the annotation at the given index - func selectAnnotation(at index: Int?) { - // Deselect any tool when selecting an annotation - if index != nil && selectedTool != nil { - selectedTool = nil - } - selectedAnnotationIndex = index - } - - /// Deselects any selected annotation - func deselectAnnotation() { - selectedAnnotationIndex = nil - isDraggingAnnotation = false - dragStartPoint = nil - dragOriginalPosition = nil - } - - /// Deletes the currently selected annotation - func deleteSelectedAnnotation() { - guard let index = selectedAnnotationIndex else { return } - pushUndoState() - screenshot = screenshot.removingAnnotation(at: index) - redoStack.removeAll() - selectedAnnotationIndex = nil - } - - /// Begins dragging the selected annotation - func beginDraggingAnnotation(at point: CGPoint) { - guard let index = selectedAnnotationIndex, - index < annotations.count else { return } - - isDraggingAnnotation = true - dragStartPoint = point - - // Store the original position based on annotation type - let annotation = annotations[index] - switch annotation { - case .rectangle(let rect): - dragOriginalPosition = rect.rect.origin - case .freehand(let freehand): - dragOriginalPosition = freehand.bounds.origin - case .arrow(let arrow): - dragOriginalPosition = arrow.bounds.origin - case .text(let text): - dragOriginalPosition = text.position - } - } - - /// Continues dragging the selected annotation - func continueDraggingAnnotation(to point: CGPoint) { - guard isDraggingAnnotation, - let index = selectedAnnotationIndex, - let startPoint = dragStartPoint, - let originalPosition = dragOriginalPosition, - index < annotations.count else { return } - - let delta = CGPoint( - x: point.x - startPoint.x, - y: point.y - startPoint.y - ) - - let annotation = annotations[index] - var updatedAnnotation: Annotation? - - switch annotation { - case .rectangle(var rect): - rect.rect.origin = CGPoint( - x: originalPosition.x + delta.x, - y: originalPosition.y + delta.y - ) - updatedAnnotation = .rectangle(rect) - - case .freehand(var freehand): - // Move all points by the delta - let bounds = freehand.bounds - let offsetX = originalPosition.x + delta.x - bounds.origin.x - let offsetY = originalPosition.y + delta.y - bounds.origin.y - freehand.points = freehand.points.map { point in - CGPoint(x: point.x + offsetX, y: point.y + offsetY) - } - updatedAnnotation = .freehand(freehand) - - case .arrow(var arrow): - // Move both start and end points by the delta - let bounds = arrow.bounds - let offsetX = originalPosition.x + delta.x - bounds.origin.x - let offsetY = originalPosition.y + delta.y - bounds.origin.y - arrow.startPoint = CGPoint( - x: arrow.startPoint.x + offsetX, - y: arrow.startPoint.y + offsetY - ) - arrow.endPoint = CGPoint( - x: arrow.endPoint.x + offsetX, - y: arrow.endPoint.y + offsetY - ) - updatedAnnotation = .arrow(arrow) - - case .text(var text): - text.position = CGPoint( - x: originalPosition.x + delta.x, - y: originalPosition.y + delta.y - ) - updatedAnnotation = .text(text) - } - - if let updated = updatedAnnotation { - // Update without pushing undo (will push on end) - screenshot.annotations[index] = updated - drawingUpdateCounter += 1 - } - } - - /// Ends dragging the selected annotation - func endDraggingAnnotation() { - isDraggingAnnotation = false - dragStartPoint = nil - dragOriginalPosition = nil - } - - /// Updates the color of the selected annotation - func updateSelectedAnnotationColor(_ color: CodableColor) { - guard let index = selectedAnnotationIndex, - index < annotations.count else { return } - - pushUndoState() - let annotation = annotations[index] - var updatedAnnotation: Annotation? - - switch annotation { - case .rectangle(var rect): - rect.style.color = color - updatedAnnotation = .rectangle(rect) - - case .freehand(var freehand): - freehand.style.color = color - updatedAnnotation = .freehand(freehand) - - case .arrow(var arrow): - arrow.style.color = color - updatedAnnotation = .arrow(arrow) - - case .text(var text): - text.style.color = color - updatedAnnotation = .text(text) - } - - if let updated = updatedAnnotation { - screenshot = screenshot.replacingAnnotation(at: index, with: updated) - redoStack.removeAll() - } - } - - /// Updates the stroke width of the selected annotation (rectangle/freehand/arrow) - func updateSelectedAnnotationStrokeWidth(_ width: CGFloat) { - guard let index = selectedAnnotationIndex, - index < annotations.count else { return } - - pushUndoState() - let annotation = annotations[index] - var updatedAnnotation: Annotation? - - switch annotation { - case .rectangle(var rect): - rect.style.lineWidth = width - updatedAnnotation = .rectangle(rect) - - case .freehand(var freehand): - freehand.style.lineWidth = width - updatedAnnotation = .freehand(freehand) - - case .arrow(var arrow): - arrow.style.lineWidth = width - updatedAnnotation = .arrow(arrow) - - case .text: - // Text doesn't have stroke width - return - } - - if let updated = updatedAnnotation { - screenshot = screenshot.replacingAnnotation(at: index, with: updated) - redoStack.removeAll() - } - } - - /// Updates the font size of the selected text annotation - func updateSelectedAnnotationFontSize(_ size: CGFloat) { - guard let index = selectedAnnotationIndex, - index < annotations.count else { return } - - let annotation = annotations[index] - guard case .text(var text) = annotation else { return } - - pushUndoState() - text.style.fontSize = size - screenshot = screenshot.replacingAnnotation(at: index, with: .text(text)) - redoStack.removeAll() - } - - /// Returns the type of the selected annotation - var selectedAnnotationType: AnnotationToolType? { - guard let index = selectedAnnotationIndex, - index < annotations.count else { return nil } - - switch annotations[index] { - case .rectangle: return .rectangle - case .freehand: return .freehand - case .arrow: return .arrow - case .text: return .text - } - } - - /// Returns the color of the selected annotation - var selectedAnnotationColor: CodableColor? { - guard let index = selectedAnnotationIndex, - index < annotations.count else { return nil } - - switch annotations[index] { - case .rectangle(let rect): return rect.style.color - case .freehand(let freehand): return freehand.style.color - case .arrow(let arrow): return arrow.style.color - case .text(let text): return text.style.color - } - } - - /// Returns the stroke width of the selected annotation (rectangle/freehand/arrow) - var selectedAnnotationStrokeWidth: CGFloat? { - guard let index = selectedAnnotationIndex, - index < annotations.count else { return nil } - - switch annotations[index] { - case .rectangle(let rect): return rect.style.lineWidth - case .freehand(let freehand): return freehand.style.lineWidth - case .arrow(let arrow): return arrow.style.lineWidth - case .text: return nil - } - } - - /// Returns the font size of the selected text annotation - var selectedAnnotationFontSize: CGFloat? { - guard let index = selectedAnnotationIndex, - index < annotations.count else { return nil } - - if case .text(let text) = annotations[index] { - return text.style.fontSize - } - return nil - } - - /// Returns the isFilled state of the selected rectangle annotation - var selectedAnnotationIsFilled: Bool? { - guard let index = selectedAnnotationIndex, - index < annotations.count else { return nil } - - if case .rectangle(let rect) = annotations[index] { - return rect.isFilled - } - return nil - } - - /// Updates the isFilled state of the selected rectangle annotation - func updateSelectedAnnotationFilled(_ isFilled: Bool) { - guard let index = selectedAnnotationIndex, - index < annotations.count else { return } - - let annotation = annotations[index] - guard case .rectangle(var rect) = annotation else { return } - - pushUndoState() - rect.isFilled = isFilled - screenshot = screenshot.replacingAnnotation(at: index, with: .rectangle(rect)) - redoStack.removeAll() - } - - /// Commits the current text input and adds the annotation - func commitTextInput() { - if let annotation = textTool.commitText() { - addAnnotation(annotation) - } - // Reset observable text input state - _isWaitingForTextInput = false - _textInputPosition = nil - } - - /// Dismisses the preview (Escape key action) - func dismiss() { - hide() - } - - /// Copies the screenshot to clipboard (Cmd+C action) - func copyToClipboard() { - guard !isCopying else { return } - isCopying = true - - do { - try clipboardService.copy(image, annotations: annotations) - } catch { - errorMessage = NSLocalizedString("error.clipboard.write.failed", comment: "Failed to copy to clipboard") - clearError() - } - - isCopying = false - } - - /// Saves the screenshot to the default location (Enter/Cmd+S action) - func saveScreenshot() { - guard !isSaving else { return } - isSaving = true - - Task { - await performSave() - } - } - - /// Performs the actual save operation - private func performSave() async { - defer { isSaving = false } - - let directory = settings.saveLocation - let format = settings.defaultFormat - let quality: Double - switch format { - case .jpeg: - quality = settings.jpegQuality - case .heic: - quality = settings.heicQuality - case .png: - quality = 1.0 // PNG doesn't use quality, but we need a value - } - - // Generate file URL - let fileURL = imageExporter.generateFileURL(in: directory, format: format) - - do { - try imageExporter.save( - image, - annotations: annotations, - to: fileURL, - format: format, - quality: quality - ) - - // Update screenshot with file path - screenshot = screenshot.saved(to: fileURL) - - // Add to recent captures - recentCapturesStore.add(filePath: fileURL, image: image) - - // Notify callback - onSave?(fileURL) - - // Dismiss the preview after successful save - hide() - } catch let error as ScreenCaptureError { - handleSaveError(error) - } catch { - errorMessage = NSLocalizedString("error.save.unknown", comment: "An unexpected error occurred while saving") - clearError() - } - } - - /// Handles save errors with user-friendly messages - private func handleSaveError(_ error: ScreenCaptureError) { - switch error { - case .invalidSaveLocation(let url): - errorMessage = String( - format: NSLocalizedString("error.save.location.invalid.detail", comment: ""), - url.path - ) - case .diskFull: - errorMessage = NSLocalizedString("error.disk.full", comment: "Not enough disk space") - case .exportEncodingFailed(let format): - errorMessage = String( - format: NSLocalizedString("error.export.encoding.failed.detail", comment: ""), - format.displayName - ) - default: - errorMessage = error.localizedDescription - } - clearError() - } - - // MARK: - Private Methods - - /// Pushes the current screenshot state to the undo stack - private func pushUndoState() { - undoStack.append(screenshot) - - // Limit undo history - if undoStack.count > maxUndoLevels { - undoStack.removeFirst() - } - } - - /// Clears error message after delay - private func clearError() { - Task { - try? await Task.sleep(for: .seconds(3)) - errorMessage = nil - } - } -} - -// MARK: - Annotation Tool Type - -/// Available annotation tool types for the preview -enum AnnotationToolType: String, CaseIterable, Identifiable, Sendable { - case rectangle - case freehand - case arrow - case text - - var id: String { rawValue } - - var displayName: String { - switch self { - case .rectangle: return "Rectangle" - case .freehand: return "Draw" - case .arrow: return "Arrow" - case .text: return "Text" - } - } - - var keyboardShortcut: Character { - switch self { - case .rectangle: return "r" - case .freehand: return "d" - case .arrow: return "a" - case .text: return "t" - } - } - - var systemImage: String { - switch self { - case .rectangle: return "rectangle" - case .freehand: return "pencil.line" - case .arrow: return "arrow.up.right" - case .text: return "textformat" - } - } -} diff --git a/ScreenCapture/Features/Settings/SettingsView.swift b/ScreenCapture/Features/Settings/SettingsView.swift deleted file mode 100644 index b997b94..0000000 --- a/ScreenCapture/Features/Settings/SettingsView.swift +++ /dev/null @@ -1,534 +0,0 @@ -import SwiftUI -import AppKit - -/// Main settings view with all preference controls. -/// Organized into sections: General, Export, Keyboard Shortcuts, and Annotations. -struct SettingsView: View { - @Bindable var viewModel: SettingsViewModel - - var body: some View { - Form { - // Permissions Section - Section { - PermissionRow(viewModel: viewModel) - } header: { - Label("Permissions", systemImage: "lock.shield") - } - - // General Settings Section - Section { - SaveLocationPicker(viewModel: viewModel) - } header: { - Label("General", systemImage: "gearshape") - } - - // Export Settings Section - Section { - ExportFormatPicker(viewModel: viewModel) - if viewModel.defaultFormat == .jpeg { - JPEGQualitySlider(viewModel: viewModel) - } else if viewModel.defaultFormat == .heic { - HEICQualitySlider(viewModel: viewModel) - } - } header: { - Label("Export", systemImage: "square.and.arrow.up") - } - - // Keyboard Shortcuts Section - Section { - ShortcutRecorder( - label: "Full Screen Capture", - shortcut: viewModel.fullScreenShortcut, - isRecording: viewModel.isRecordingFullScreenShortcut, - onRecord: { viewModel.startRecordingFullScreenShortcut() }, - onReset: { viewModel.resetFullScreenShortcut() } - ) - - ShortcutRecorder( - label: "Selection Capture", - shortcut: viewModel.selectionShortcut, - isRecording: viewModel.isRecordingSelectionShortcut, - onRecord: { viewModel.startRecordingSelectionShortcut() }, - onReset: { viewModel.resetSelectionShortcut() } - ) - } header: { - Label("Keyboard Shortcuts", systemImage: "keyboard") - } - - // Annotation Settings Section - Section { - StrokeColorPicker(viewModel: viewModel) - StrokeWidthSlider(viewModel: viewModel) - TextSizeSlider(viewModel: viewModel) - } header: { - Label("Annotations", systemImage: "pencil.tip.crop.circle") - } - - // Reset Section - Section { - Button(role: .destructive) { - viewModel.resetAllToDefaults() - } label: { - Label("Reset All to Defaults", systemImage: "arrow.counterclockwise") - } - .buttonStyle(.plain) - .foregroundStyle(.red) - } - } - .formStyle(.grouped) - .frame(minWidth: 450, minHeight: 500) - .alert("Error", isPresented: $viewModel.showErrorAlert) { - Button("OK") { - viewModel.errorMessage = nil - } - } message: { - if let message = viewModel.errorMessage { - Text(message) - } - } - } -} - -// MARK: - Permission Row - -/// Row showing permission status with action button. -private struct PermissionRow: View { - @Bindable var viewModel: SettingsViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - // Screen Recording permission - PermissionItem( - icon: "record.circle", - title: "Screen Recording", - hint: "Required to capture screenshots", - isGranted: viewModel.hasScreenRecordingPermission, - isChecking: viewModel.isCheckingPermissions, - onGrant: { viewModel.requestScreenRecordingPermission() } - ) - - Divider() - - // Folder Access permission - PermissionItem( - icon: "folder", - title: "Save Location Access", - hint: "Required to save screenshots to the selected folder", - isGranted: viewModel.hasFolderAccessPermission, - isChecking: viewModel.isCheckingPermissions, - onGrant: { viewModel.requestFolderAccess() } - ) - - HStack { - Spacer() - Button { - viewModel.checkPermissions() - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .buttonStyle(.borderless) - } - } - .onAppear { - viewModel.checkPermissions() - } - } -} - -/// Individual permission item row -private struct PermissionItem: View { - let icon: String - let title: String - let hint: String - let isGranted: Bool - let isChecking: Bool - let onGrant: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - HStack(spacing: 8) { - Image(systemName: icon) - .foregroundStyle(.secondary) - .frame(width: 20) - Text(title) - } - - Spacer() - - if isChecking { - ProgressView() - .controlSize(.small) - } else { - HStack(spacing: 8) { - if isGranted { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text("Granted") - .foregroundStyle(.secondary) - } else { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.red) - - Button { - onGrant() - } label: { - Text("Grant Access") - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - } - } - } - - if !isGranted && !isChecking { - Text(hint) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .accessibilityElement(children: .combine) - .accessibilityLabel(Text("\(title): \(isGranted ? "Granted" : "Not Granted")")) - } -} - -// MARK: - Save Location Picker - -/// Picker for selecting the default save location. -private struct SaveLocationPicker: View { - @Bindable var viewModel: SettingsViewModel - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Save Location") - .font(.headline) - Text(viewModel.saveLocationPath) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - - Spacer() - - Button { - viewModel.selectSaveLocation() - } label: { - Text("Choose...") - } - - Button { - viewModel.revealSaveLocation() - } label: { - Image(systemName: "folder") - } - .help("Show in Finder") - } - .accessibilityElement(children: .combine) - .accessibilityLabel(Text("Save Location: \(viewModel.saveLocationPath)")) - } -} - -// MARK: - Export Format Picker - -/// Picker for selecting the default export format (PNG/JPEG). -private struct ExportFormatPicker: View { - @Bindable var viewModel: SettingsViewModel - - var body: some View { - Picker("Default Format", selection: $viewModel.defaultFormat) { - Text("PNG").tag(ExportFormat.png) - Text("JPEG").tag(ExportFormat.jpeg) - Text("HEIC").tag(ExportFormat.heic) - } - .pickerStyle(.segmented) - .accessibilityLabel(Text("Export Format")) - } -} - -// MARK: - JPEG Quality Slider - -/// Slider for adjusting JPEG compression quality. -private struct JPEGQualitySlider: View { - @Bindable var viewModel: SettingsViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("JPEG Quality") - Spacer() - Text("\(Int(viewModel.jpegQualityPercentage))%") - .foregroundStyle(.secondary) - .monospacedDigit() - } - - Slider( - value: $viewModel.jpegQuality, - in: SettingsViewModel.jpegQualityRange, - step: 0.05 - ) { - Text("JPEG Quality") - } minimumValueLabel: { - Text("10%") - .font(.caption) - } maximumValueLabel: { - Text("100%") - .font(.caption) - } - .accessibilityValue(Text("\(Int(viewModel.jpegQualityPercentage)) percent")) - - Text("Higher quality results in larger file sizes") - .font(.caption) - .foregroundStyle(.secondary) - } - } -} - -// MARK: - HEIC Quality Slider - -/// Slider for adjusting HEIC compression quality. -private struct HEICQualitySlider: View { - @Bindable var viewModel: SettingsViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("HEIC Quality") - Spacer() - Text("\(Int(viewModel.heicQualityPercentage))%") - .foregroundStyle(.secondary) - .monospacedDigit() - } - - Slider( - value: $viewModel.heicQuality, - in: SettingsViewModel.heicQualityRange, - step: 0.05 - ) { - Text("HEIC Quality") - } minimumValueLabel: { - Text("10%") - .font(.caption) - } maximumValueLabel: { - Text("100%") - .font(.caption) - } - .accessibilityValue(Text("\(Int(viewModel.heicQualityPercentage)) percent")) - - Text("HEIC offers better compression than JPEG at similar quality") - .font(.caption) - .foregroundStyle(.secondary) - } - } -} - -// MARK: - Shortcut Recorder - -/// A control for recording keyboard shortcuts. -private struct ShortcutRecorder: View { - let label: String - let shortcut: KeyboardShortcut - let isRecording: Bool - let onRecord: () -> Void - let onReset: () -> Void - - var body: some View { - HStack { - Text(label) - - Spacer() - - if isRecording { - Text("Press keys...") - .foregroundStyle(.secondary) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.accentColor.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - } else { - Button { - onRecord() - } label: { - Text(shortcut.displayString) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.secondary.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - } - .buttonStyle(.plain) - } - - Button { - onReset() - } label: { - Image(systemName: "arrow.counterclockwise") - } - .buttonStyle(.borderless) - .help("Reset to default") - .disabled(isRecording) - } - .accessibilityElement(children: .combine) - .accessibilityLabel(Text("\(label): \(shortcut.displayString)")) - } -} - -// MARK: - Stroke Color Picker - -/// Color picker for annotation stroke color. -private struct StrokeColorPicker: View { - @Bindable var viewModel: SettingsViewModel - - var body: some View { - HStack { - Text("Stroke Color") - - Spacer() - - // Preset color buttons - HStack(spacing: 4) { - ForEach(SettingsViewModel.presetColors, id: \.self) { color in - Button { - viewModel.strokeColor = color - } label: { - Circle() - .fill(color) - .frame(width: 20, height: 20) - .overlay { - if colorsAreEqual(viewModel.strokeColor, color) { - Circle() - .stroke(Color.primary, lineWidth: 2) - } - } - .overlay { - // Add border for light colors - if color == .white || color == .yellow { - Circle() - .stroke(Color.gray.opacity(0.3), lineWidth: 1) - } - } - } - .buttonStyle(.plain) - .accessibilityLabel(Text(colorName(for: color))) - } - } - - // Custom color picker - ColorPicker("", selection: $viewModel.strokeColor, supportsOpacity: false) - .labelsHidden() - .frame(width: 30) - } - .accessibilityElement(children: .combine) - .accessibilityLabel(Text("Stroke Color")) - } - - /// Compare colors approximately - private func colorsAreEqual(_ a: Color, _ b: Color) -> Bool { - // Convert to NSColor for comparison - let nsA = NSColor(a).usingColorSpace(.deviceRGB) - let nsB = NSColor(b).usingColorSpace(.deviceRGB) - guard let colorA = nsA, let colorB = nsB else { return false } - - let tolerance: CGFloat = 0.01 - return abs(colorA.redComponent - colorB.redComponent) < tolerance && - abs(colorA.greenComponent - colorB.greenComponent) < tolerance && - abs(colorA.blueComponent - colorB.blueComponent) < tolerance - } - - /// Get accessible color name - private func colorName(for color: Color) -> String { - switch color { - case .red: return "Red" - case .orange: return "Orange" - case .yellow: return "Yellow" - case .green: return "Green" - case .blue: return "Blue" - case .purple: return "Purple" - case .pink: return "Pink" - case .white: return "White" - case .black: return "Black" - default: return "Custom" - } - } -} - -// MARK: - Stroke Width Slider - -/// Slider for adjusting annotation stroke width. -private struct StrokeWidthSlider: View { - @Bindable var viewModel: SettingsViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Stroke Width") - Spacer() - Text("\(viewModel.strokeWidth, specifier: "%.1f") pt") - .foregroundStyle(.secondary) - .monospacedDigit() - } - - HStack(spacing: 12) { - Slider( - value: $viewModel.strokeWidth, - in: SettingsViewModel.strokeWidthRange, - step: 0.5 - ) { - Text("Stroke Width") - } - .accessibilityValue(Text("\(viewModel.strokeWidth, specifier: "%.1f") points")) - - // Preview of stroke width - RoundedRectangle(cornerRadius: viewModel.strokeWidth / 2) - .fill(viewModel.strokeColor) - .frame(width: 40, height: viewModel.strokeWidth) - } - } - } -} - -// MARK: - Text Size Slider - -/// Slider for adjusting text annotation font size. -private struct TextSizeSlider: View { - @Bindable var viewModel: SettingsViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Text Size") - Spacer() - Text("\(Int(viewModel.textSize)) pt") - .foregroundStyle(.secondary) - .monospacedDigit() - } - - HStack(spacing: 12) { - Slider( - value: $viewModel.textSize, - in: SettingsViewModel.textSizeRange, - step: 1 - ) { - Text("Text Size") - } - .accessibilityValue(Text("\(Int(viewModel.textSize)) points")) - - // Preview of text size - Text("Aa") - .font(.system(size: min(viewModel.textSize, 24))) - .foregroundStyle(viewModel.strokeColor) - .frame(width: 40) - } - } - } -} - -// MARK: - Preview - -#if DEBUG -#Preview { - SettingsView(viewModel: SettingsViewModel()) - .frame(width: 500, height: 600) -} -#endif diff --git a/ScreenCapture/Features/Settings/SettingsViewModel.swift b/ScreenCapture/Features/Settings/SettingsViewModel.swift deleted file mode 100644 index cd033ad..0000000 --- a/ScreenCapture/Features/Settings/SettingsViewModel.swift +++ /dev/null @@ -1,370 +0,0 @@ -import Foundation -import SwiftUI -import AppKit -import Carbon.HIToolbox -import ScreenCaptureKit - -/// ViewModel for the Settings view. -/// Manages user preferences and provides bindings for the settings UI. -@MainActor -@Observable -final class SettingsViewModel { - // MARK: - Properties - - /// Reference to shared app settings - private let settings: AppSettings - - /// Reference to app delegate for hotkey re-registration - private weak var appDelegate: AppDelegate? - - /// Whether a shortcut is currently being recorded - var isRecordingFullScreenShortcut = false - var isRecordingSelectionShortcut = false - - /// Temporary storage for shortcut recording - var recordedShortcut: KeyboardShortcut? - - /// Error message to display - var errorMessage: String? - - /// Whether to show error alert - var showErrorAlert = false - - /// Screen recording permission status - var hasScreenRecordingPermission: Bool = false - - /// Folder access permission status - var hasFolderAccessPermission: Bool = false - - /// Whether permission check is in progress - var isCheckingPermissions: Bool = false - - // MARK: - Computed Properties (Bindings to AppSettings) - - /// Save location URL - var saveLocation: URL { - get { settings.saveLocation } - set { settings.saveLocation = newValue } - } - - /// Save location display path - var saveLocationPath: String { - saveLocation.path.replacingOccurrences(of: NSHomeDirectory(), with: "~") - } - - /// Default export format - var defaultFormat: ExportFormat { - get { settings.defaultFormat } - set { settings.defaultFormat = newValue } - } - - /// JPEG quality (0.0-1.0) - var jpegQuality: Double { - get { settings.jpegQuality } - set { settings.jpegQuality = newValue } - } - - /// JPEG quality as percentage (0-100) - var jpegQualityPercentage: Double { - get { jpegQuality * 100 } - set { jpegQuality = newValue / 100 } - } - - /// HEIC quality (0.0-1.0) - var heicQuality: Double { - get { settings.heicQuality } - set { settings.heicQuality = newValue } - } - - /// HEIC quality as percentage (0-100) - var heicQualityPercentage: Double { - get { heicQuality * 100 } - set { heicQuality = newValue / 100 } - } - - /// Full screen capture shortcut - var fullScreenShortcut: KeyboardShortcut { - get { settings.fullScreenShortcut } - set { - settings.fullScreenShortcut = newValue - appDelegate?.updateHotkeys() - } - } - - /// Selection capture shortcut - var selectionShortcut: KeyboardShortcut { - get { settings.selectionShortcut } - set { - settings.selectionShortcut = newValue - appDelegate?.updateHotkeys() - } - } - - /// Annotation stroke color - var strokeColor: Color { - get { settings.strokeColor.color } - set { settings.strokeColor = CodableColor(newValue) } - } - - /// Annotation stroke width - var strokeWidth: CGFloat { - get { settings.strokeWidth } - set { settings.strokeWidth = newValue } - } - - /// Text annotation font size - var textSize: CGFloat { - get { settings.textSize } - set { settings.textSize = newValue } - } - - // MARK: - Validation Ranges - - /// Valid range for stroke width - static let strokeWidthRange: ClosedRange = 1.0...20.0 - - /// Valid range for text size - static let textSizeRange: ClosedRange = 8.0...72.0 - - /// Valid range for JPEG quality - static let jpegQualityRange: ClosedRange = 0.1...1.0 - - /// Valid range for HEIC quality - static let heicQualityRange: ClosedRange = 0.1...1.0 - - // MARK: - Initialization - - init(settings: AppSettings = .shared, appDelegate: AppDelegate? = nil) { - self.settings = settings - self.appDelegate = appDelegate - } - - // MARK: - Permission Checking - - /// Checks all required permissions and updates status - func checkPermissions() { - isCheckingPermissions = true - - // Check screen recording permission using CGPreflightScreenCaptureAccess - hasScreenRecordingPermission = CGPreflightScreenCaptureAccess() - - // Check folder access permission by testing if we can write to the save location - hasFolderAccessPermission = checkFolderAccess(to: saveLocation) - - isCheckingPermissions = false - } - - /// Checks if we have write access to the specified folder - private func checkFolderAccess(to url: URL) -> Bool { - let fileManager = FileManager.default - - // Check if directory exists and is writable - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory), - isDirectory.boolValue else { - return false - } - - return fileManager.isWritableFile(atPath: url.path) - } - - /// Requests screen recording permission or opens System Settings - func requestScreenRecordingPermission() { - // First try to request permission (this triggers the system prompt if not asked before) - let hasAccess = CGRequestScreenCaptureAccess() - - if !hasAccess { - // If no access, open System Settings to the Screen Recording pane - openScreenRecordingSettings() - } - - // Recheck permissions after a short delay - Task { - try? await Task.sleep(for: .milliseconds(500)) - checkPermissions() - } - } - - /// Requests folder access by showing a folder picker - func requestFolderAccess() { - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.canCreateDirectories = true - panel.prompt = "Grant Access" - panel.message = "Select the folder where you want to save screenshots" - panel.directoryURL = saveLocation - - if panel.runModal() == .OK, let url = panel.url { - // Save the security-scoped bookmark for persistent access - do { - let bookmarkData = try url.bookmarkData( - options: .withSecurityScope, - includingResourceValuesForKeys: nil, - relativeTo: nil - ) - UserDefaults.standard.set(bookmarkData, forKey: "SaveLocationBookmark") - saveLocation = url - } catch { - // If bookmark fails, just save the URL - saveLocation = url - } - } - - // Recheck permissions - checkPermissions() - } - - /// Opens System Settings to the Screen Recording privacy pane - func openScreenRecordingSettings() { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { - NSWorkspace.shared.open(url) - } - } - - // MARK: - Actions - - /// Shows folder selection panel to choose save location - func selectSaveLocation() { - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.canCreateDirectories = true - panel.prompt = "Select" - panel.message = "Choose the default location for saving screenshots" - panel.directoryURL = saveLocation - - if panel.runModal() == .OK, let url = panel.url { - // Save the security-scoped bookmark for persistent access - do { - let bookmarkData = try url.bookmarkData( - options: .withSecurityScope, - includingResourceValuesForKeys: nil, - relativeTo: nil - ) - UserDefaults.standard.set(bookmarkData, forKey: "SaveLocationBookmark") - } catch { - // Ignore bookmark errors - } - saveLocation = url - checkPermissions() - } - } - - /// Reveals the save location in Finder - func revealSaveLocation() { - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: saveLocation.path) - } - - /// Starts recording a keyboard shortcut for full screen capture - func startRecordingFullScreenShortcut() { - isRecordingFullScreenShortcut = true - isRecordingSelectionShortcut = false - recordedShortcut = nil - } - - /// Starts recording a keyboard shortcut for selection capture - func startRecordingSelectionShortcut() { - isRecordingFullScreenShortcut = false - isRecordingSelectionShortcut = true - recordedShortcut = nil - } - - /// Cancels shortcut recording - func cancelRecording() { - isRecordingFullScreenShortcut = false - isRecordingSelectionShortcut = false - recordedShortcut = nil - } - - /// Handles a key event during shortcut recording - /// - Parameter event: The key event - /// - Returns: Whether the event was handled - func handleKeyEvent(_ event: NSEvent) -> Bool { - guard isRecordingFullScreenShortcut || isRecordingSelectionShortcut else { - return false - } - - // Escape cancels recording - if event.keyCode == UInt16(kVK_Escape) { - cancelRecording() - return true - } - - // Create shortcut from event - let shortcut = KeyboardShortcut( - keyCode: UInt32(event.keyCode), - modifierFlags: event.modifierFlags.intersection([.command, .shift, .option, .control]) - ) - - // Validate shortcut - guard shortcut.isValid else { - showError("Shortcuts must include Command, Control, or Option") - return true - } - - // Check for conflicts with other shortcuts - if isRecordingFullScreenShortcut && shortcut == selectionShortcut { - showError("This shortcut is already used for Selection Capture") - return true - } - if isRecordingSelectionShortcut && shortcut == fullScreenShortcut { - showError("This shortcut is already used for Full Screen Capture") - return true - } - - // Apply the shortcut - if isRecordingFullScreenShortcut { - fullScreenShortcut = shortcut - } else { - selectionShortcut = shortcut - } - - // End recording - cancelRecording() - return true - } - - /// Resets a shortcut to its default - func resetFullScreenShortcut() { - fullScreenShortcut = .fullScreenDefault - } - - /// Resets selection shortcut to default - func resetSelectionShortcut() { - selectionShortcut = .selectionDefault - } - - /// Resets all settings to defaults - func resetAllToDefaults() { - settings.resetToDefaults() - appDelegate?.updateHotkeys() - } - - // MARK: - Private Helpers - - /// Shows an error message - private func showError(_ message: String) { - errorMessage = message - showErrorAlert = true - } -} - -// MARK: - Preset Colors - -extension SettingsViewModel { - /// Preset colors for the color picker - static let presetColors: [Color] = [ - .red, - .orange, - .yellow, - .green, - .blue, - .purple, - .pink, - .white, - .black - ] -} diff --git a/ScreenCapture/Models/AppSettings.swift b/ScreenCapture/Models/AppSettings.swift deleted file mode 100644 index 4a4b013..0000000 --- a/ScreenCapture/Models/AppSettings.swift +++ /dev/null @@ -1,287 +0,0 @@ -import Foundation -import SwiftUI - -/// User preferences persisted across sessions via UserDefaults. -/// All properties automatically sync to UserDefaults with the `ScreenCapture.` prefix. -@MainActor -@Observable -final class AppSettings { - // MARK: - Singleton - - /// Shared settings instance - static let shared = AppSettings() - - // MARK: - UserDefaults Keys - - private enum Keys { - static let prefix = "ScreenCapture." - static let saveLocation = prefix + "saveLocation" - static let defaultFormat = prefix + "defaultFormat" - static let jpegQuality = prefix + "jpegQuality" - static let heicQuality = prefix + "heicQuality" - static let fullScreenShortcut = prefix + "fullScreenShortcut" - static let selectionShortcut = prefix + "selectionShortcut" - static let strokeColor = prefix + "strokeColor" - static let strokeWidth = prefix + "strokeWidth" - static let textSize = prefix + "textSize" - static let rectangleFilled = prefix + "rectangleFilled" - static let recentCaptures = prefix + "recentCaptures" - } - - // MARK: - Properties - - /// Default save directory - var saveLocation: URL { - didSet { save(saveLocation.path, forKey: Keys.saveLocation) } - } - - /// Default export format (PNG or JPEG) - var defaultFormat: ExportFormat { - didSet { save(defaultFormat.rawValue, forKey: Keys.defaultFormat) } - } - - /// JPEG compression quality (0.0-1.0) - var jpegQuality: Double { - didSet { save(jpegQuality, forKey: Keys.jpegQuality) } - } - - /// HEIC compression quality (0.0-1.0) - var heicQuality: Double { - didSet { save(heicQuality, forKey: Keys.heicQuality) } - } - - /// Global hotkey for full screen capture - var fullScreenShortcut: KeyboardShortcut { - didSet { saveShortcut(fullScreenShortcut, forKey: Keys.fullScreenShortcut) } - } - - /// Global hotkey for selection capture - var selectionShortcut: KeyboardShortcut { - didSet { saveShortcut(selectionShortcut, forKey: Keys.selectionShortcut) } - } - - /// Default annotation stroke color - var strokeColor: CodableColor { - didSet { saveColor(strokeColor, forKey: Keys.strokeColor) } - } - - /// Default annotation stroke width - var strokeWidth: CGFloat { - didSet { save(Double(strokeWidth), forKey: Keys.strokeWidth) } - } - - /// Default text annotation font size - var textSize: CGFloat { - didSet { save(Double(textSize), forKey: Keys.textSize) } - } - - /// Whether rectangles are filled (solid) by default - var rectangleFilled: Bool { - didSet { save(rectangleFilled, forKey: Keys.rectangleFilled) } - } - - /// Last 5 saved captures - var recentCaptures: [RecentCapture] { - didSet { saveRecentCaptures() } - } - - // MARK: - Initialization - - private init() { - let defaults = UserDefaults.standard - - // Load save location from bookmark first, then path, or use Desktop - let loadedLocation: URL - if let bookmarkData = defaults.data(forKey: "SaveLocationBookmark"), - let url = Self.resolveBookmark(bookmarkData) { - loadedLocation = url - } else if let path = defaults.string(forKey: Keys.saveLocation) { - loadedLocation = URL(fileURLWithPath: path) - } else { - loadedLocation = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first - ?? URL(fileURLWithPath: NSHomeDirectory()) - } - saveLocation = loadedLocation - - // Load format - if let formatRaw = defaults.string(forKey: Keys.defaultFormat), - let format = ExportFormat(rawValue: formatRaw) { - defaultFormat = format - } else { - defaultFormat = .png - } - - // Load JPEG quality - jpegQuality = defaults.object(forKey: Keys.jpegQuality) as? Double ?? 0.9 - - // Load HEIC quality - heicQuality = defaults.object(forKey: Keys.heicQuality) as? Double ?? 0.9 - - // Load shortcuts - fullScreenShortcut = Self.loadShortcut(forKey: Keys.fullScreenShortcut) - ?? KeyboardShortcut.fullScreenDefault - selectionShortcut = Self.loadShortcut(forKey: Keys.selectionShortcut) - ?? KeyboardShortcut.selectionDefault - - // Load annotation defaults - strokeColor = Self.loadColor(forKey: Keys.strokeColor) ?? .red - strokeWidth = CGFloat(defaults.object(forKey: Keys.strokeWidth) as? Double ?? 2.0) - textSize = CGFloat(defaults.object(forKey: Keys.textSize) as? Double ?? 14.0) - rectangleFilled = defaults.object(forKey: Keys.rectangleFilled) as? Bool ?? false - - // Load recent captures - recentCaptures = Self.loadRecentCaptures() - - print("ScreenCapture launched - settings loaded from: \(loadedLocation.path)") - } - - // MARK: - Computed Properties - - /// Default stroke style based on current settings - var defaultStrokeStyle: StrokeStyle { - StrokeStyle(color: strokeColor, lineWidth: strokeWidth) - } - - /// Default text style based on current settings - var defaultTextStyle: TextStyle { - TextStyle(color: strokeColor, fontSize: textSize, fontName: ".AppleSystemUIFont") - } - - // MARK: - Recent Captures Management - - /// Adds a capture to the recent list (maintains max 5, FIFO) - func addRecentCapture(_ capture: RecentCapture) { - recentCaptures.insert(capture, at: 0) - if recentCaptures.count > 5 { - recentCaptures = Array(recentCaptures.prefix(5)) - } - } - - /// Clears all recent captures - func clearRecentCaptures() { - recentCaptures = [] - } - - // MARK: - Reset - - /// Resets all settings to defaults - func resetToDefaults() { - saveLocation = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first - ?? URL(fileURLWithPath: NSHomeDirectory()) - defaultFormat = .png - jpegQuality = 0.9 - heicQuality = 0.9 - fullScreenShortcut = .fullScreenDefault - selectionShortcut = .selectionDefault - strokeColor = .red - strokeWidth = 2.0 - textSize = 14.0 - rectangleFilled = false - recentCaptures = [] - } - - // MARK: - Private Persistence Helpers - - private func save(_ value: Any, forKey key: String) { - UserDefaults.standard.set(value, forKey: key) - } - - private func saveShortcut(_ shortcut: KeyboardShortcut, forKey key: String) { - let data: [String: UInt32] = [ - "keyCode": shortcut.keyCode, - "modifiers": shortcut.modifiers - ] - UserDefaults.standard.set(data, forKey: key) - } - - private static func loadShortcut(forKey key: String) -> KeyboardShortcut? { - guard let data = UserDefaults.standard.dictionary(forKey: key) as? [String: UInt32], - let keyCode = data["keyCode"], - let modifiers = data["modifiers"] else { - return nil - } - return KeyboardShortcut(keyCode: keyCode, modifiers: modifiers) - } - - private func saveColor(_ color: CodableColor, forKey key: String) { - if let data = try? JSONEncoder().encode(color) { - UserDefaults.standard.set(data, forKey: key) - } - } - - private static func loadColor(forKey key: String) -> CodableColor? { - guard let data = UserDefaults.standard.data(forKey: key) else { return nil } - return try? JSONDecoder().decode(CodableColor.self, from: data) - } - - private func saveRecentCaptures() { - if let data = try? JSONEncoder().encode(recentCaptures) { - UserDefaults.standard.set(data, forKey: Keys.recentCaptures) - } - } - - private static func loadRecentCaptures() -> [RecentCapture] { - guard let data = UserDefaults.standard.data(forKey: Keys.recentCaptures) else { - return [] - } - return (try? JSONDecoder().decode([RecentCapture].self, from: data)) ?? [] - } - - /// Resolves a security-scoped bookmark to a URL - private static func resolveBookmark(_ bookmarkData: Data) -> URL? { - var isStale = false - do { - let url = try URL( - resolvingBookmarkData: bookmarkData, - options: .withSecurityScope, - relativeTo: nil, - bookmarkDataIsStale: &isStale - ) - - // Start accessing the security-scoped resource - if url.startAccessingSecurityScopedResource() { - // Note: We don't call stopAccessingSecurityScopedResource() - // because we need ongoing access throughout the app's lifetime - return url - } - return url - } catch { - print("Failed to resolve bookmark: \(error)") - return nil - } - } -} - -// MARK: - Recent Capture - -/// Entry in the recent captures list. -struct RecentCapture: Identifiable, Codable, Sendable { - /// Unique identifier - let id: UUID - - /// Location of saved file - let filePath: URL - - /// When the screenshot was captured - let captureDate: Date - - /// JPEG thumbnail data (max 10KB, 128px on longest edge) - let thumbnailData: Data? - - init(id: UUID = UUID(), filePath: URL, captureDate: Date = Date(), thumbnailData: Data? = nil) { - self.id = id - self.filePath = filePath - self.captureDate = captureDate - self.thumbnailData = thumbnailData - } - - /// The filename without path - var filename: String { - filePath.lastPathComponent - } - - /// Whether the file still exists on disk - var fileExists: Bool { - FileManager.default.fileExists(atPath: filePath.path) - } -} diff --git a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png deleted file mode 100644 index 60b494a..0000000 Binary files a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png and /dev/null differ diff --git a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png deleted file mode 100644 index 996e653..0000000 Binary files a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png and /dev/null differ diff --git a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png deleted file mode 100644 index 1119a94..0000000 Binary files a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png and /dev/null differ diff --git a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png deleted file mode 100644 index 423b429..0000000 Binary files a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png and /dev/null differ diff --git a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png deleted file mode 100644 index 996e653..0000000 Binary files a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png and /dev/null differ diff --git a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png deleted file mode 100644 index 21d1480..0000000 Binary files a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png and /dev/null differ diff --git a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png deleted file mode 100644 index 423b429..0000000 Binary files a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png and /dev/null differ diff --git a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png deleted file mode 100644 index 4139931..0000000 Binary files a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png and /dev/null differ diff --git a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png deleted file mode 100644 index 21d1480..0000000 Binary files a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png and /dev/null differ diff --git a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png deleted file mode 100644 index 49d2b3b..0000000 Binary files a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png and /dev/null differ diff --git a/ScreenCapture/Resources/Localizable.strings b/ScreenCapture/Resources/Localizable.strings deleted file mode 100644 index 0623ef1..0000000 --- a/ScreenCapture/Resources/Localizable.strings +++ /dev/null @@ -1,125 +0,0 @@ -/* Error Messages */ -"error.permission.denied" = "Screen recording permission is required to capture screenshots."; -"error.permission.denied.recovery" = "Open System Settings to grant permission."; - -"error.display.not.found" = "The selected display is no longer available."; -"error.display.not.found.recovery" = "Please select a different display."; - -"error.display.disconnected" = "The display '%@' was disconnected during capture."; -"error.display.disconnected.recovery" = "Please reconnect the display and try again."; - -"error.capture.failed" = "Failed to capture the screen."; -"error.capture.failed.recovery" = "Please try again."; - -"error.save.location.invalid" = "The save location is not accessible."; -"error.save.location.invalid.recovery" = "Choose a different save location in Settings."; - -"error.disk.full" = "There is not enough disk space to save the screenshot."; -"error.disk.full.recovery" = "Free up disk space and try again."; - -"error.export.encoding.failed" = "Failed to encode the image."; -"error.export.encoding.failed.recovery" = "Try a different format in Settings."; -"error.export.encoding.failed.detail" = "Failed to encode the image as %@."; - -"error.save.location.invalid.detail" = "Cannot save to %@. The location is not accessible."; -"error.save.unknown" = "An unexpected error occurred while saving."; - -"error.clipboard.write.failed" = "Failed to copy the screenshot to clipboard."; -"error.clipboard.write.failed.recovery" = "Please try again."; - -"error.hotkey.registration.failed" = "Failed to register the keyboard shortcut."; -"error.hotkey.registration.failed.recovery" = "The shortcut may conflict with another app. Try a different shortcut."; - -"error.hotkey.conflict" = "This keyboard shortcut conflicts with another application."; -"error.hotkey.conflict.recovery" = "Choose a different keyboard shortcut."; - -/* Menu Items */ -"menu.capture.full.screen" = "Capture Full Screen"; -"menu.capture.fullscreen" = "Capture Full Screen"; -"menu.capture.selection" = "Capture Selection"; -"menu.recent.captures" = "Recent Captures"; -"menu.recent.captures.empty" = "No Recent Captures"; -"menu.recent.captures.clear" = "Clear Recent"; -"menu.settings" = "Settings..."; -"menu.quit" = "Quit ScreenCapture"; - -/* Preview Window */ -"preview.title" = "Screenshot Preview"; -"preview.dimensions" = "%d x %d pixels"; -"preview.file.size" = "~%@ %@"; - -/* Annotation Tools */ -"tool.rectangle" = "Rectangle"; -"tool.freehand" = "Freehand"; -"tool.text" = "Text"; - -/* Settings Window */ -"settings.window.title" = "ScreenCapture Settings"; -"settings.title" = "ScreenCapture Settings"; - -/* Settings Sections */ -"settings.section.general" = "General"; -"settings.section.export" = "Export"; -"settings.section.shortcuts" = "Keyboard Shortcuts"; -"settings.section.annotations" = "Annotations"; - -/* Save Location */ -"settings.save.location" = "Save Location"; -"settings.save.location.choose" = "Choose..."; -"settings.save.location.select" = "Select"; -"settings.save.location.message" = "Choose the default location for saving screenshots"; -"settings.save.location.reveal" = "Show in Finder"; - -/* Export Format */ -"settings.format" = "Default Format"; -"settings.jpeg.quality" = "JPEG Quality"; -"settings.jpeg.quality.hint" = "Higher quality results in larger file sizes"; - -/* Keyboard Shortcuts */ -"settings.shortcuts" = "Keyboard Shortcuts"; -"settings.shortcut.fullscreen" = "Full Screen Capture"; -"settings.shortcut.selection" = "Selection Capture"; -"settings.shortcut.recording" = "Press keys..."; -"settings.shortcut.reset" = "Reset to default"; -"settings.shortcut.error.no.modifier" = "Shortcuts must include Command, Control, or Option"; -"settings.shortcut.error.conflict" = "This shortcut is already in use"; - -/* Annotations */ -"settings.annotations" = "Annotation Defaults"; -"settings.stroke.color" = "Stroke Color"; -"settings.stroke.width" = "Stroke Width"; -"settings.text.size" = "Text Size"; - -/* Reset */ -"settings.reset.all" = "Reset All to Defaults"; - -/* Errors */ -"settings.error.title" = "Error"; -"settings.error.ok" = "OK"; - -/* Actions */ -"action.save" = "Save"; -"action.copy" = "Copy"; -"action.cancel" = "Cancel"; -"action.undo" = "Undo"; -"action.redo" = "Redo"; - -/* Display Selector */ -"display.selector.title" = "Select Display"; -"display.selector.header" = "Choose display to capture:"; -"display.selector.cancel" = "Cancel"; - -/* Preview Window */ -"preview.window.title" = "Screenshot Preview"; - -/* Error Buttons */ -"error.permission.open.settings" = "Open System Settings"; -"error.dismiss" = "Dismiss"; -"error.ok" = "OK"; -"error.retry.capture" = "Retry"; - -/* Permission Prompt */ -"permission.prompt.title" = "Screen Recording Permission Required"; -"permission.prompt.message" = "ScreenCapture needs permission to capture your screen. This is required to take screenshots.\n\nAfter clicking Continue, macOS will ask you to grant Screen Recording permission. You can grant it in System Settings > Privacy & Security > Screen Recording."; -"permission.prompt.continue" = "Continue"; -"permission.prompt.later" = "Later"; diff --git a/ScreenCapture/Services/ImageExporter.swift b/ScreenCapture/Services/ImageExporter.swift deleted file mode 100644 index 926cbfa..0000000 --- a/ScreenCapture/Services/ImageExporter.swift +++ /dev/null @@ -1,356 +0,0 @@ -import Foundation -import CoreGraphics -import AppKit -import UniformTypeIdentifiers - -/// Service for exporting screenshots to PNG or JPEG files. -/// Uses CGImageDestination for efficient image encoding. -struct ImageExporter: Sendable { - // MARK: - Constants - - /// Date formatter for generating filenames - private static let dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd 'at' HH.mm.ss" - return formatter - }() - - // MARK: - Public API - - /// Exports an image to a file at the specified URL. - /// - Parameters: - /// - image: The CGImage to export - /// - annotations: Annotations to composite onto the image - /// - url: The destination file URL - /// - format: The export format (PNG or JPEG) - /// - quality: JPEG quality (0.0-1.0), ignored for PNG - /// - Throws: ScreenCaptureError if export fails - func save( - _ image: CGImage, - annotations: [Annotation], - to url: URL, - format: ExportFormat, - quality: Double = 0.9 - ) throws { - // Composite annotations onto the image if any exist - let finalImage: CGImage - if annotations.isEmpty { - finalImage = image - } else { - finalImage = try compositeAnnotations(annotations, onto: image) - } - - // Verify parent directory exists and is writable - let directory = url.deletingLastPathComponent() - guard FileManager.default.isWritableFile(atPath: directory.path) else { - throw ScreenCaptureError.invalidSaveLocation(directory) - } - - // Check for available disk space (rough estimate: 4 bytes per pixel for PNG) - let estimatedSize = Int64(finalImage.width * finalImage.height * 4) - do { - let resourceValues = try directory.resourceValues(forKeys: [.volumeAvailableCapacityKey]) - if let availableCapacity = resourceValues.volumeAvailableCapacity, - Int64(availableCapacity) < estimatedSize { - throw ScreenCaptureError.diskFull - } - } catch let error as ScreenCaptureError { - throw error - } catch { - // Ignore disk space check errors, proceed with save - } - - // Create image destination - guard let destination = CGImageDestinationCreateWithURL( - url as CFURL, - format.uti.identifier as CFString, - 1, - nil - ) else { - throw ScreenCaptureError.exportEncodingFailed(format: format) - } - - // Configure export options - var options: [CFString: Any] = [:] - if format == .jpeg || format == .heic { - options[kCGImageDestinationLossyCompressionQuality] = quality - } - - // Add image and finalize - CGImageDestinationAddImage(destination, finalImage, options as CFDictionary) - - guard CGImageDestinationFinalize(destination) else { - throw ScreenCaptureError.exportEncodingFailed(format: format) - } - } - - /// Generates a filename with the current timestamp. - /// - Parameter format: The export format to determine file extension - /// - Returns: A filename like "Screenshot 2024-01-15 at 14.30.45.png" - func generateFilename(format: ExportFormat) -> String { - let timestamp = Self.dateFormatter.string(from: Date()) - return "Screenshot \(timestamp).\(format.fileExtension)" - } - - /// Generates a full file URL for saving. - /// - Parameters: - /// - directory: The save directory - /// - format: The export format - /// - Returns: A URL with a unique filename - func generateFileURL(in directory: URL, format: ExportFormat) -> URL { - let filename = generateFilename(format: format) - var url = directory.appendingPathComponent(filename) - - // Ensure unique filename if file already exists - var counter = 1 - while FileManager.default.fileExists(atPath: url.path) { - let baseName = "Screenshot \(Self.dateFormatter.string(from: Date())) (\(counter))" - url = directory.appendingPathComponent("\(baseName).\(format.fileExtension)") - counter += 1 - } - - return url - } - - /// Estimates the file size for an image in the given format. - /// - Parameters: - /// - image: The image to estimate size for - /// - format: The export format - /// - quality: JPEG quality (affects JPEG estimate) - /// - Returns: Estimated file size in bytes - func estimateFileSize( - for image: CGImage, - format: ExportFormat, - quality: Double = 0.9 - ) -> Int { - let pixelCount = image.width * image.height - - switch format { - case .png: - // PNG is lossless, estimate ~4 bytes per pixel (varies with content) - return pixelCount * 4 - case .jpeg: - // JPEG size varies with quality and content - // At quality 0.9, roughly 0.5-1.0 bytes per pixel - let bytesPerPixel = 0.5 + (0.5 * quality) - return Int(Double(pixelCount) * bytesPerPixel) - case .heic: - // HEIC has better compression than JPEG - // At quality 0.9, roughly 0.3-0.6 bytes per pixel - let bytesPerPixel = 0.3 + (0.3 * quality) - return Int(Double(pixelCount) * bytesPerPixel) - } - } - - // MARK: - Annotation Compositing - - /// Composites annotations onto an image. - /// - Parameters: - /// - annotations: The annotations to draw - /// - image: The base image - /// - Returns: A new CGImage with annotations rendered - /// - Throws: ScreenCaptureError if compositing fails - private func compositeAnnotations( - _ annotations: [Annotation], - onto image: CGImage - ) throws -> CGImage { - let width = image.width - let height = image.height - - // Create drawing context - guard let colorSpace = image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB), - let context = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: 0, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { - throw ScreenCaptureError.exportEncodingFailed(format: .png) - } - - // Draw base image - context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) - - // Configure for drawing annotations - context.setLineCap(.round) - context.setLineJoin(.round) - - // Draw each annotation - for annotation in annotations { - renderAnnotation(annotation, in: context, imageHeight: CGFloat(height)) - } - - // Create final image - guard let result = context.makeImage() else { - throw ScreenCaptureError.exportEncodingFailed(format: .png) - } - - return result - } - - /// Renders a single annotation into a graphics context. - /// - Parameters: - /// - annotation: The annotation to render - /// - context: The graphics context - /// - imageHeight: The image height (for coordinate transformation) - private func renderAnnotation( - _ annotation: Annotation, - in context: CGContext, - imageHeight: CGFloat - ) { - switch annotation { - case .rectangle(let rect): - renderRectangle(rect, in: context, imageHeight: imageHeight) - case .freehand(let freehand): - renderFreehand(freehand, in: context, imageHeight: imageHeight) - case .arrow(let arrow): - renderArrow(arrow, in: context, imageHeight: imageHeight) - case .text(let text): - renderText(text, in: context, imageHeight: imageHeight) - } - } - - /// Renders a rectangle annotation. - private func renderRectangle( - _ annotation: RectangleAnnotation, - in context: CGContext, - imageHeight: CGFloat - ) { - // Transform from SwiftUI coordinates (origin top-left) to CG coordinates (origin bottom-left) - let rect = CGRect( - x: annotation.rect.origin.x, - y: imageHeight - annotation.rect.origin.y - annotation.rect.height, - width: annotation.rect.width, - height: annotation.rect.height - ) - - if annotation.isFilled { - // Filled rectangle - solid color to hide underlying content - context.setFillColor(annotation.style.color.cgColor) - context.fill(rect) - } else { - // Hollow rectangle - outline only - context.setStrokeColor(annotation.style.color.cgColor) - context.setLineWidth(annotation.style.lineWidth) - context.stroke(rect) - } - } - - /// Renders a freehand annotation. - private func renderFreehand( - _ annotation: FreehandAnnotation, - in context: CGContext, - imageHeight: CGFloat - ) { - guard annotation.points.count >= 2 else { return } - - context.setStrokeColor(annotation.style.color.cgColor) - context.setLineWidth(annotation.style.lineWidth) - - // Transform points and draw path - context.beginPath() - let firstPoint = annotation.points[0] - context.move(to: CGPoint(x: firstPoint.x, y: imageHeight - firstPoint.y)) - - for point in annotation.points.dropFirst() { - context.addLine(to: CGPoint(x: point.x, y: imageHeight - point.y)) - } - - context.strokePath() - } - - /// Renders an arrow annotation. - private func renderArrow( - _ annotation: ArrowAnnotation, - in context: CGContext, - imageHeight: CGFloat - ) { - // Transform from SwiftUI coordinates (origin top-left) to CG coordinates (origin bottom-left) - let start = CGPoint(x: annotation.startPoint.x, y: imageHeight - annotation.startPoint.y) - let end = CGPoint(x: annotation.endPoint.x, y: imageHeight - annotation.endPoint.y) - let lineWidth = annotation.style.lineWidth - - context.setStrokeColor(annotation.style.color.cgColor) - context.setFillColor(annotation.style.color.cgColor) - context.setLineWidth(lineWidth) - context.setLineCap(.round) - context.setLineJoin(.round) - - // Draw the main line - context.beginPath() - context.move(to: start) - context.addLine(to: end) - context.strokePath() - - // Draw the arrowhead - let arrowHeadLength = max(lineWidth * 4, 12) - let arrowHeadAngle: CGFloat = .pi / 6 - - let dx = end.x - start.x - let dy = end.y - start.y - let angle = atan2(dy, dx) - - let arrowPoint1 = CGPoint( - x: end.x - arrowHeadLength * cos(angle - arrowHeadAngle), - y: end.y - arrowHeadLength * sin(angle - arrowHeadAngle) - ) - let arrowPoint2 = CGPoint( - x: end.x - arrowHeadLength * cos(angle + arrowHeadAngle), - y: end.y - arrowHeadLength * sin(angle + arrowHeadAngle) - ) - - context.beginPath() - context.move(to: end) - context.addLine(to: arrowPoint1) - context.addLine(to: arrowPoint2) - context.closePath() - context.fillPath() - } - - /// Renders a text annotation. - private func renderText( - _ annotation: TextAnnotation, - in context: CGContext, - imageHeight: CGFloat - ) { - guard !annotation.content.isEmpty else { return } - - // Create attributed string - let font = NSFont(name: annotation.style.fontName, size: annotation.style.fontSize) - ?? NSFont.systemFont(ofSize: annotation.style.fontSize) - - let attributes: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: annotation.style.color.nsColor - ] - - let attributedString = NSAttributedString(string: annotation.content, attributes: attributes) - - // Draw text at position (transform Y coordinate) - let position = CGPoint( - x: annotation.position.x, - y: imageHeight - annotation.position.y - annotation.style.fontSize - ) - - // Save context state - context.saveGState() - - // Create line and draw - let line = CTLineCreateWithAttributedString(attributedString) - context.textPosition = position - CTLineDraw(line, context) - - // Restore context state - context.restoreGState() - } -} - -// MARK: - Shared Instance - -extension ImageExporter { - /// Shared instance for convenience - static let shared = ImageExporter() -} diff --git a/ScreenCapture/Services/RecentCapturesStore.swift b/ScreenCapture/Services/RecentCapturesStore.swift deleted file mode 100644 index 0e9d8c3..0000000 --- a/ScreenCapture/Services/RecentCapturesStore.swift +++ /dev/null @@ -1,171 +0,0 @@ -import Foundation -import AppKit -import CoreGraphics - -/// Manages the list of recent captures with thumbnail generation and persistence. -/// Runs on the main actor for UI integration. -@MainActor -final class RecentCapturesStore: ObservableObject { - // MARK: - Constants - - /// Maximum number of recent captures to store - private static let maxCaptures = 5 - - /// Maximum thumbnail dimension in pixels - private static let maxThumbnailSize: CGFloat = 128 - - /// Maximum thumbnail data size in bytes (10KB) - private static let maxThumbnailDataSize = 10 * 1024 - - /// JPEG quality for thumbnail compression - private static let thumbnailQuality: CGFloat = 0.7 - - // MARK: - Properties - - /// The list of recent captures (newest first) - @Published private(set) var captures: [RecentCapture] = [] - - /// App settings for persistence - private let settings: AppSettings - - // MARK: - Initialization - - init(settings: AppSettings = .shared) { - self.settings = settings - loadCaptures() - } - - // MARK: - Public API - - /// Adds a new capture to the recent list. - /// Generates a thumbnail and persists the update. - /// - Parameters: - /// - filePath: The URL where the screenshot was saved - /// - image: The captured image for thumbnail generation - /// - date: The capture date (defaults to now) - func add(filePath: URL, image: CGImage, date: Date = Date()) { - let thumbnailData = generateThumbnail(from: image) - - let capture = RecentCapture( - filePath: filePath, - captureDate: date, - thumbnailData: thumbnailData - ) - - captures.insert(capture, at: 0) - - // Enforce maximum count - if captures.count > Self.maxCaptures { - captures = Array(captures.prefix(Self.maxCaptures)) - } - - saveCaptures() - } - - /// Removes a capture from the recent list. - /// - Parameter capture: The capture to remove - func remove(capture: RecentCapture) { - captures.removeAll { $0.id == capture.id } - saveCaptures() - } - - /// Removes the capture at the specified index. - /// - Parameter index: The index of the capture to remove - func remove(at index: Int) { - guard index >= 0 && index < captures.count else { return } - captures.remove(at: index) - saveCaptures() - } - - /// Clears all recent captures. - func clear() { - captures.removeAll() - saveCaptures() - } - - /// Removes captures whose files no longer exist. - func pruneInvalidCaptures() { - captures.removeAll { !$0.fileExists } - saveCaptures() - } - - // MARK: - Persistence - - /// Loads captures from UserDefaults via AppSettings - private func loadCaptures() { - captures = settings.recentCaptures - pruneInvalidCaptures() - } - - /// Saves captures to UserDefaults via AppSettings - private func saveCaptures() { - settings.recentCaptures = captures - } - - // MARK: - Thumbnail Generation - - /// Generates a JPEG thumbnail from a CGImage. - /// - Parameter image: The source image - /// - Returns: JPEG data for the thumbnail, or nil if generation fails - private func generateThumbnail(from image: CGImage) -> Data? { - let width = CGFloat(image.width) - let height = CGFloat(image.height) - - // Calculate scaled size maintaining aspect ratio - let scale: CGFloat - if width > height { - scale = Self.maxThumbnailSize / width - } else { - scale = Self.maxThumbnailSize / height - } - - // Only scale down, not up - let finalScale = min(scale, 1.0) - let newWidth = Int(width * finalScale) - let newHeight = Int(height * finalScale) - - // Create thumbnail context - guard let context = CGContext( - data: nil, - width: newWidth, - height: newHeight, - bitsPerComponent: 8, - bytesPerRow: 0, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { - return nil - } - - // Draw scaled image - context.interpolationQuality = .high - context.draw(image, in: CGRect(x: 0, y: 0, width: newWidth, height: newHeight)) - - // Get thumbnail image - guard let thumbnailImage = context.makeImage() else { - return nil - } - - // Convert to JPEG data - let nsImage = NSImage(cgImage: thumbnailImage, size: NSSize(width: newWidth, height: newHeight)) - guard let tiffData = nsImage.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiffData), - let jpegData = bitmap.representation(using: .jpeg, properties: [.compressionFactor: Self.thumbnailQuality]) else { - return nil - } - - // Check size and reduce quality if needed - if jpegData.count > Self.maxThumbnailDataSize { - // Try with lower quality - let lowerQuality: CGFloat = 0.5 - if let reducedData = bitmap.representation(using: .jpeg, properties: [.compressionFactor: lowerQuality]), - reducedData.count <= Self.maxThumbnailDataSize { - return reducedData - } - // If still too large, return nil - return nil - } - - return jpegData - } -} diff --git a/ScreenCapture.xcodeproj/project.pbxproj b/ScreenTranslate.xcodeproj/project.pbxproj similarity index 51% rename from ScreenCapture.xcodeproj/project.pbxproj rename to ScreenTranslate.xcodeproj/project.pbxproj index 2cd4795..7a780cc 100644 --- a/ScreenCapture.xcodeproj/project.pbxproj +++ b/ScreenTranslate.xcodeproj/project.pbxproj @@ -6,14 +6,47 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 86DCC59B2F56BB9D000ECF4B /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 86DCC59A2F56BB9D000ECF4B /* Sparkle */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + SC000032 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = SC000010 /* Project object */; + proxyType = 1; + remoteGlobalIDString = SC000006; + remoteInfo = ScreenTranslate; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ - SC000001 /* ScreenCapture.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ScreenCapture.app; sourceTree = BUILT_PRODUCTS_DIR; }; + SC000001 /* ScreenTranslate.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ScreenTranslate.app; sourceTree = BUILT_PRODUCTS_DIR; }; + SC000022 /* ScreenTranslateTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ScreenTranslateTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 862C98FC2F32309800ABAC92 /* Exceptions for "ScreenTranslate" folder in "ScreenTranslate" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + "Supporting Files/Info.plist", + ); + target = SC000006 /* ScreenTranslate */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ - SC000002 /* ScreenCapture */ = { + SC000002 /* ScreenTranslate */ = { isa = PBXFileSystemSynchronizedRootGroup; - path = ScreenCapture; + exceptions = ( + 862C98FC2F32309800ABAC92 /* Exceptions for "ScreenTranslate" folder in "ScreenTranslate" target */, + ); + path = ScreenTranslate; + sourceTree = ""; + }; + SC000021 /* ScreenTranslateTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ScreenTranslateTests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -23,6 +56,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 86DCC59B2F56BB9D000ECF4B /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -32,7 +66,8 @@ SC000004 = { isa = PBXGroup; children = ( - SC000002 /* ScreenCapture */, + SC000002 /* ScreenTranslate */, + SC000021 /* ScreenTranslateTests */, SC000005 /* Products */, ); sourceTree = ""; @@ -40,7 +75,8 @@ SC000005 /* Products */ = { isa = PBXGroup; children = ( - SC000001 /* ScreenCapture.app */, + SC000001 /* ScreenTranslate.app */, + SC000022 /* ScreenTranslateTests.xctest */, ); name = Products; sourceTree = ""; @@ -48,28 +84,53 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - SC000006 /* ScreenCapture */ = { + SC000006 /* ScreenTranslate */ = { isa = PBXNativeTarget; - buildConfigurationList = SC000007 /* Build configuration list for PBXNativeTarget "ScreenCapture" */; + buildConfigurationList = SC000007 /* Build configuration list for PBXNativeTarget "ScreenTranslate" */; buildPhases = ( SC000008 /* Sources */, SC000003 /* Frameworks */, SC000009 /* Resources */, + SC000020 /* ShellScript */, ); buildRules = ( ); dependencies = ( ); fileSystemSynchronizedGroups = ( - SC000002 /* ScreenCapture */, + SC000002 /* ScreenTranslate */, ); - name = ScreenCapture; + name = ScreenTranslate; packageProductDependencies = ( + 86DCC59A2F56BB9D000ECF4B /* Sparkle */, ); - productName = ScreenCapture; - productReference = SC000001 /* ScreenCapture.app */; + productName = ScreenTranslate; + productReference = SC000001 /* ScreenTranslate.app */; productType = "com.apple.product-type.application"; }; + SC000023 /* ScreenTranslateTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = SC000026 /* Build configuration list for PBXNativeTarget "ScreenTranslateTests" */; + buildPhases = ( + SC000024 /* Sources */, + SC000025 /* Frameworks */, + SC000027 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + SC000033 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + SC000021 /* ScreenTranslateTests */, + ); + name = ScreenTranslateTests; + packageProductDependencies = ( + ); + productName = ScreenTranslateTests; + productReference = SC000022 /* ScreenTranslateTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -83,23 +144,40 @@ SC000006 = { CreatedOnToolsVersion = 26.2; }; + SC000023 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = SC000006; + }; }; }; - buildConfigurationList = SC000011 /* Build configuration list for PBXProject "ScreenCapture" */; + buildConfigurationList = SC000011 /* Build configuration list for PBXProject "ScreenTranslate" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( - en, Base, + de, + en, + es, + fr, + it, + ja, + ko, + pt, + ru, + "zh-Hans", ); mainGroup = SC000004; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 86DCC5992F56BB9D000ECF4B /* XCRemoteSwiftPackageReference "Sparkle" */, + ); preferredProjectObjectVersion = 77; productRefGroup = SC000005 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - SC000006 /* ScreenCapture */, + SC000006 /* ScreenTranslate */, + SC000023 /* ScreenTranslateTests */, ); }; /* End PBXProject section */ @@ -112,8 +190,31 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + SC000027 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + SC000020 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Sign embedded frameworks with ad-hoc signature to match main app\nFRAMEWORKS_DIR=\"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Frameworks\"\nif [ -d \"$FRAMEWORKS_DIR\" ]; then\n for FRAMEWORK in \"$FRAMEWORKS_DIR\"/*.framework; do\n if [ -d \"$FRAMEWORK\" ]; then\n echo \"Re-signing framework with ad-hoc: $(basename \"$FRAMEWORK\")\"\n codesign --force --sign - --deep \"$FRAMEWORK\"\n fi\n done\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ SC000008 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -122,8 +223,33 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + SC000024 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXFrameworksBuildPhase section */ + SC000025 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXTargetDependency section */ + SC000033 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = SC000006 /* ScreenTranslate */; + targetProxy = SC000032 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ SC000012 /* Debug */ = { isa = XCBuildConfiguration; @@ -163,7 +289,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -228,7 +354,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -250,30 +376,33 @@ SC000014 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = "ScreenCapture/Supporting Files/ScreenCapture.entitlements"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_ENTITLEMENTS = "ScreenTranslate/Supporting Files/ScreenTranslate.entitlements"; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 32PQ3Q9PL3; - ENABLE_APP_SANDBOX = YES; - ENABLE_HARDENED_RUNTIME = YES; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = ""; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = NO; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readwrite; + EXCLUDED_SOURCE_FILE_NAMES = "**/CLAUDE.md"; GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = "ScreenCapture/Supporting Files/Info.plist"; + INFOPLIST_FILE = "ScreenTranslate/Supporting Files/Info.plist"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright 2026. All rights reserved."; - INFOPLIST_KEY_NSScreenCaptureUsageDescription = "ScreenCapture needs access to record your screen to capture screenshots."; + INFOPLIST_KEY_NSScreenCaptureUsageDescription = "ScreenTranslate needs access to record your screen to capture and translate text."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.screencapture.app; + MARKETING_VERSION = 1.3.3; + PRODUCT_BUNDLE_IDENTIFIER = com.screentranslate.app; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 6.0; @@ -283,40 +412,85 @@ SC000015 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = "ScreenCapture/Supporting Files/ScreenCapture.entitlements"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_ENTITLEMENTS = "ScreenTranslate/Supporting Files/ScreenTranslate.entitlements"; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 32PQ3Q9PL3; - ENABLE_APP_SANDBOX = YES; - ENABLE_HARDENED_RUNTIME = YES; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = ""; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = NO; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readwrite; + EXCLUDED_SOURCE_FILE_NAMES = "**/CLAUDE.md"; GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = "ScreenCapture/Supporting Files/Info.plist"; + INFOPLIST_FILE = "ScreenTranslate/Supporting Files/Info.plist"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright 2026. All rights reserved."; - INFOPLIST_KEY_NSScreenCaptureUsageDescription = "ScreenCapture needs access to record your screen to capture screenshots."; + INFOPLIST_KEY_NSScreenCaptureUsageDescription = "ScreenTranslate needs access to record your screen to capture and translate text."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.screencapture.app; + MARKETING_VERSION = 1.3.3; + PRODUCT_BUNDLE_IDENTIFIER = com.screentranslate.app; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 6.0; }; name = Release; }; + SC000028 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + PRODUCT_BUNDLE_IDENTIFIER = com.screentranslate.appTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ScreenTranslate.app/Contents/MacOS/ScreenTranslate"; + TEST_TARGET_NAME = ScreenTranslate; + }; + name = Debug; + }; + SC000029 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + PRODUCT_BUNDLE_IDENTIFIER = com.screentranslate.appTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ScreenTranslate.app/Contents/MacOS/ScreenTranslate"; + TEST_TARGET_NAME = ScreenTranslate; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - SC000007 /* Build configuration list for PBXNativeTarget "ScreenCapture" */ = { + SC000007 /* Build configuration list for PBXNativeTarget "ScreenTranslate" */ = { isa = XCConfigurationList; buildConfigurations = ( SC000014 /* Debug */, @@ -325,7 +499,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - SC000011 /* Build configuration list for PBXProject "ScreenCapture" */ = { + SC000011 /* Build configuration list for PBXProject "ScreenTranslate" */ = { isa = XCConfigurationList; buildConfigurations = ( SC000012 /* Debug */, @@ -334,7 +508,35 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + SC000026 /* Build configuration list for PBXNativeTarget "ScreenTranslateTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + SC000028 /* Debug */, + SC000029 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 86DCC5992F56BB9D000ECF4B /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.9.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 86DCC59A2F56BB9D000ECF4B /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 86DCC5992F56BB9D000ECF4B /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = SC000010 /* Project object */; } diff --git a/ScreenCapture.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ScreenTranslate.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from ScreenCapture.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to ScreenTranslate.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/ScreenTranslate/App/AppDelegate.swift b/ScreenTranslate/App/AppDelegate.swift new file mode 100644 index 0000000..d07548e --- /dev/null +++ b/ScreenTranslate/App/AppDelegate.swift @@ -0,0 +1,259 @@ +import AppKit +import os +import UserNotifications +import Sparkle + +/// Application delegate responsible for menu bar setup, coordinator management, and app lifecycle. +/// Runs on the main actor to ensure thread-safe UI operations. +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + // MARK: - Coordinators + + /// Coordinates capture functionality (full screen, selection, translation mode) + private(set) var captureCoordinator: CaptureCoordinator? + + /// Coordinates text translation functionality + private(set) var textTranslationCoordinator: TextTranslationCoordinator? + + /// Coordinates hotkey management + private(set) var hotkeyCoordinator: HotkeyCoordinator? + + // MARK: - Other Properties + + private var menuBarController: MenuBarController? + private let settings = AppSettings.shared + + private lazy var updaterController: SPUStandardUpdaterController = { + let controller = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: self, + userDriverDelegate: nil + ) + // Listen for check for updates notification from About window + NotificationCenter.default.addObserver( + self, + selector: #selector(checkForUpdates(_:)), + name: .checkForUpdates, + object: nil + ) + return controller + }() + + // MARK: - NSApplicationDelegate + + func applicationDidFinishLaunching(_ notification: Notification) { + // Ensure we're a menu bar only app (no dock icon) + NSApp.setActivationPolicy(.accessory) + + // Initialize coordinators + captureCoordinator = CaptureCoordinator(appDelegate: self) + textTranslationCoordinator = TextTranslationCoordinator(appDelegate: self) + hotkeyCoordinator = HotkeyCoordinator(appDelegate: self) + + // Set up menu bar + menuBarController = MenuBarController(appDelegate: self) + menuBarController?.setup() + + // Register global hotkeys via coordinator + Task { + await hotkeyCoordinator?.registerAllHotkeys() + } + + // Show onboarding for first launch, otherwise check screen recording permission + Task { + await checkFirstLaunchAndShowOnboarding() + } + + // Check PaddleOCR availability in background (non-blocking) + PaddleOCRChecker.checkAvailabilityAsync() + + // Initialize updater controller to register notification observer + _ = updaterController + + Logger.general.info("ScreenTranslate launched - settings loaded from: \(self.settings.saveLocation.path)") + } + + /// Checks if this is the first launch and shows onboarding if needed. + private func checkFirstLaunchAndShowOnboarding() async { + if !settings.onboardingCompleted { + // Show onboarding for first-time users - already @MainActor + OnboardingWindowController.shared.showOnboarding(settings: settings) + } + // Note: Don't auto-check screen recording permission on launch + // to avoid triggering system dialog. Users can check in Settings. + } + + func applicationWillTerminate(_ notification: Notification) { + // Unregister hotkeys synchronously with timeout + // Use semaphore to ensure completion before process exits + let semaphore = DispatchSemaphore(value: 0) + Task { + await hotkeyCoordinator?.unregisterAllHotkeys() + semaphore.signal() + } + // Wait up to 2 seconds for hotkey unregistration + _ = semaphore.wait(timeout: .now() + 2.0) + + // Remove menu bar item + menuBarController?.teardown() + + Logger.general.info("ScreenTranslate terminating") + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + // For menu bar apps, we don't need to do anything special on reopen + // The menu bar icon is always visible + return false + } + + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + // Enable secure state restoration + return true + } + + // MARK: - Hotkey Management (Delegated to HotkeyCoordinator) + + /// Re-registers hotkeys after settings change + func updateHotkeys() { + hotkeyCoordinator?.updateHotkeys() + } + + // MARK: - Capture Actions (Delegated to CaptureCoordinator) + + /// Triggers a full screen capture + @objc func captureFullScreen() { + captureCoordinator?.captureFullScreen() + } + + /// Triggers a selection capture + @objc func captureSelection() { + captureCoordinator?.captureSelection() + } + + /// Starts translation mode - presents region selection for translation + @objc func startTranslationMode() { + captureCoordinator?.startTranslationMode() + } + + // MARK: - Text Translation Actions (Delegated to TextTranslationCoordinator) + + /// Triggers text selection translation + @objc func translateSelectedText() { + textTranslationCoordinator?.translateSelectedText() + } + + /// Triggers translate and insert workflow + @objc func translateClipboardAndInsert() { + textTranslationCoordinator?.translateClipboardAndInsert() + } + + // MARK: - UI Actions + + /// Opens the settings window + @objc func openSettings() { + Logger.ui.debug("Opening settings window") + + SettingsWindowController.shared.showSettings(appDelegate: self) + } + + /// Opens the about window + @objc func openAbout() { + Logger.ui.debug("Opening about window") + + AboutWindowController.shared.showAbout() + } + + /// Checks for app updates via Sparkle + @objc func checkForUpdates(_ sender: Any?) { + Logger.ui.info("Check for updates triggered") + // Activate the app to ensure Sparkle's window is visible + NSApp.activate(ignoringOtherApps: true) + updaterController.checkForUpdates(sender) + } + + /// Opens the translation history window + @objc func openHistory() { + Logger.ui.debug("Opening translation history window") + + HistoryWindowController.shared.showHistory() + } + + // MARK: - Error Handling + + /// Shows an error alert for capture failures + func showCaptureError(_ error: ScreenTranslateError) { + Logger.general.error("Capture error: \(error.localizedDescription)") + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = error.errorDescription ?? NSLocalizedString("error.capture.failed", comment: "") + alert.informativeText = error.recoverySuggestion ?? "" + + switch error { + case .permissionDenied: + let openSettingsTitle = NSLocalizedString( + "error.permission.open.settings", + comment: "Open System Settings" + ) + alert.addButton(withTitle: openSettingsTitle) + alert.addButton(withTitle: NSLocalizedString("error.dismiss", comment: "Dismiss")) + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + // Open System Settings > Privacy > Screen Recording + let urlString = "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture" + if let url = URL(string: urlString) { + NSWorkspace.shared.open(url) + } + } + + case .displayDisconnected: + // Offer to retry capture on a different display + alert.addButton(withTitle: NSLocalizedString( + "error.retry.capture", + comment: "Retry" + )) + alert.addButton(withTitle: NSLocalizedString("error.dismiss", comment: "Dismiss")) + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + // Retry the capture on the remaining displays + captureFullScreen() + } + + case .diskFull, .invalidSaveLocation: + // Offer to open settings to change save location + alert.addButton(withTitle: NSLocalizedString("menu.settings", comment: "Settings...")) + alert.addButton(withTitle: NSLocalizedString("error.dismiss", comment: "Dismiss")) + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + openSettings() + } + + default: + alert.addButton(withTitle: NSLocalizedString("error.ok", comment: "OK")) + alert.runModal() + } + } +} + +// MARK: - SPUUpdaterDelegate + +extension AppDelegate: SPUUpdaterDelegate { + nonisolated func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) { + Logger.ui.info("Sparkle: didFinishLoading appcast") + } + + nonisolated func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { + Logger.ui.info("Sparkle: didFindValidUpdate - \(item.displayVersionString)") + } + + nonisolated func updaterDidNotFindUpdate(_ updater: SPUUpdater) { + Logger.ui.info("Sparkle: didNotFindUpdate") + } + + nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { + Logger.ui.error("Sparkle: didAbortWithError - \(error.localizedDescription)") + } +} diff --git a/ScreenTranslate/App/Coordinators/CaptureCoordinator.swift b/ScreenTranslate/App/Coordinators/CaptureCoordinator.swift new file mode 100644 index 0000000..2b32c77 --- /dev/null +++ b/ScreenTranslate/App/Coordinators/CaptureCoordinator.swift @@ -0,0 +1,225 @@ +// +// CaptureCoordinator.swift +// ScreenTranslate +// +// Created during architecture refactoring - extracts capture logic from AppDelegate +// +// ## Responsibilities +// - Full screen capture: Captures entire display via CaptureManager +// - Selection capture: Shows overlay for user to select region +// - Translation mode: Captures region and initiates translation flow +// +// ## Usage +// Access via AppDelegate.captureCoordinator: +// ```swift +// appDelegate.captureCoordinator?.captureFullScreen() +// appDelegate.captureCoordinator?.captureSelection() +// appDelegate.captureCoordinator?.startTranslationMode() +// ``` +// + +import AppKit +import os + +/// Coordinates all capture-related functionality: +/// full screen capture, selection capture, and translation mode capture. +/// +/// This coordinator was extracted from AppDelegate as part of the architecture +/// refactoring to improve separation of concerns and testability. +@MainActor +final class CaptureCoordinator { + // MARK: - Properties + + /// Reference to app delegate for action routing + private weak var appDelegate: AppDelegate? + + /// Whether a capture operation is currently in progress + private var isCaptureInProgress = false + + /// Display selector for multi-display support + private let displaySelector = DisplaySelector() + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ScreenTranslate", category: "CaptureCoordinator") + + // MARK: - Initialization + + init(appDelegate: AppDelegate) { + self.appDelegate = appDelegate + } + + // MARK: - Public API + + /// Triggers a full screen capture + func captureFullScreen() { + // Prevent overlapping captures + guard !isCaptureInProgress else { + logger.debug("Capture already in progress, ignoring request") + return + } + + logger.info("Full screen capture triggered via hotkey or menu") + + isCaptureInProgress = true + + Task { + defer { isCaptureInProgress = false } + + do { + // Get available displays + let displays = try await CaptureManager.shared.availableDisplays() + + // Select display (shows menu if multiple) + guard let selectedDisplay = await displaySelector.selectDisplay(from: displays) else { + logger.debug("Display selection cancelled") + return + } + + logger.info("Capturing display: \(selectedDisplay.name)") + + // Perform capture + let screenshot = try await CaptureManager.shared.captureFullScreen(display: selectedDisplay) + + logger.info("Capture successful: \(screenshot.formattedDimensions)") + + // Show preview window + PreviewWindowController.shared.showPreview(for: screenshot) + + } catch let error as ScreenTranslateError { + appDelegate?.showCaptureError(error) + } catch { + appDelegate?.showCaptureError(.captureFailure(underlying: error)) + } + } + } + + /// Triggers a selection capture + func captureSelection() { + // Prevent overlapping captures + guard !isCaptureInProgress else { + logger.debug("Capture already in progress, ignoring request") + return + } + + logger.info("Selection capture triggered via hotkey or menu") + + isCaptureInProgress = true + + Task { + do { + // Present the selection overlay on all displays + let overlayController = SelectionOverlayController.shared + + // Set up callbacks before presenting + overlayController.onSelectionComplete = { [weak self] rect, display in + Task { @MainActor in + await self?.handleSelectionComplete(rect: rect, display: display) + } + } + + overlayController.onSelectionCancel = { [weak self] in + Task { @MainActor in + self?.handleSelectionCancel() + } + } + + try await overlayController.presentOverlay() + + } catch { + isCaptureInProgress = false + logger.error("Failed to present selection overlay: \(error.localizedDescription)") + appDelegate?.showCaptureError(.captureFailure(underlying: error)) + } + } + } + + /// Starts translation mode - presents region selection for translation + func startTranslationMode() { + guard !isCaptureInProgress else { + logger.debug("Capture already in progress, ignoring translation mode request") + return + } + + logger.info("Translation mode triggered via hotkey or menu") + + isCaptureInProgress = true + + Task { + do { + let overlayController = SelectionOverlayController.shared + + overlayController.onSelectionComplete = { [weak self] rect, display in + Task { @MainActor in + await self?.handleTranslationSelection(rect: rect, display: display) + } + } + + overlayController.onSelectionCancel = { [weak self] in + Task { @MainActor in + self?.handleSelectionCancel() + } + } + + try await overlayController.presentOverlay() + + } catch { + isCaptureInProgress = false + logger.error("Failed to present translation overlay: \(error.localizedDescription)") + appDelegate?.showCaptureError(.captureFailure(underlying: error)) + } + } + } + + // MARK: - Private Handlers + + /// Handles successful selection completion + private func handleSelectionComplete(rect: CGRect, display: DisplayInfo) async { + defer { isCaptureInProgress = false } + + do { + logger.info("Selection complete: \(Int(rect.width))×\(Int(rect.height)) on \(display.name)") + + // Capture the selected region + let screenshot = try await CaptureManager.shared.captureRegion(rect, from: display) + + logger.info("Region capture successful: \(screenshot.formattedDimensions)") + + await MainActor.run { + PreviewWindowController.shared.showPreview(for: screenshot) + } + + } catch let error as ScreenTranslateError { + appDelegate?.showCaptureError(error) + } catch { + appDelegate?.showCaptureError(.captureFailure(underlying: error)) + } + } + + /// Handles translation mode selection completion + private func handleTranslationSelection(rect: CGRect, display: DisplayInfo) async { + defer { isCaptureInProgress = false } + + do { + logger.info("Translation selection: \(Int(rect.width))×\(Int(rect.height)) on \(display.name)") + + let screenshot = try await CaptureManager.shared.captureRegion(rect, from: display) + + logger.info("Translation capture successful: \(screenshot.formattedDimensions)") + + TranslationFlowController.shared.startTranslation( + image: screenshot.image, + scaleFactor: screenshot.sourceDisplay.scaleFactor + ) + + } catch let error as ScreenTranslateError { + appDelegate?.showCaptureError(error) + } catch { + appDelegate?.showCaptureError(.captureFailure(underlying: error)) + } + } + + /// Handles selection cancellation + private func handleSelectionCancel() { + isCaptureInProgress = false + logger.debug("Selection cancelled by user") + } +} diff --git a/ScreenTranslate/App/Coordinators/HotkeyCoordinator.swift b/ScreenTranslate/App/Coordinators/HotkeyCoordinator.swift new file mode 100644 index 0000000..27174dd --- /dev/null +++ b/ScreenTranslate/App/Coordinators/HotkeyCoordinator.swift @@ -0,0 +1,173 @@ +// +// HotkeyCoordinator.swift +// ScreenTranslate +// +// Created during architecture refactoring - extracts hotkey management from AppDelegate +// +// ## Responsibilities +// - Register all global hotkeys on app launch +// - Unregister hotkeys on app termination +// - Update hotkeys when settings change +// +// ## Usage +// Access via AppDelegate.hotkeyCoordinator: +// ```swift +// await appDelegate.hotkeyCoordinator?.registerAllHotkeys() +// await appDelegate.hotkeyCoordinator?.unregisterAllHotkeys() +// appDelegate.hotkeyCoordinator?.updateHotkeys() +// ``` +// + +import AppKit +import os + +/// Coordinates global hotkey management: +/// registration, unregistration, and updates based on settings changes. +/// +/// This coordinator was extracted from AppDelegate as part of the architecture +/// refactoring to improve separation of concerns and testability. +@MainActor +final class HotkeyCoordinator { + // MARK: - Types + + /// Types of hotkeys managed by the coordinator + enum HotkeyType: String, CaseIterable { + case fullScreen + case selection + case translationMode + case textSelectionTranslation + case translateAndInsert + } + + // MARK: - Properties + + /// Reference to app delegate for action routing + private weak var appDelegate: AppDelegate? + + /// Registered hotkey references by type + private var registrations: [HotkeyType: HotkeyManager.Registration] = [:] + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ScreenTranslate", category: "HotkeyCoordinator") + + // MARK: - Initialization + + init(appDelegate: AppDelegate) { + self.appDelegate = appDelegate + } + + // MARK: - Public API + + /// Registers all hotkeys based on current settings + func registerAllHotkeys() async { + logger.info("Registering all hotkeys") + + // Unregister existing hotkeys first + await unregisterAllHotkeys() + + let settings = AppSettings.shared + + // Register full screen capture hotkey + await registerHotkey( + type: .fullScreen, + shortcut: settings.fullScreenShortcut, + description: "Full Screen Capture" + ) { [weak self] in + Task { @MainActor in + self?.appDelegate?.captureCoordinator?.captureFullScreen() + } + } + + // Register selection capture hotkey + await registerHotkey( + type: .selection, + shortcut: settings.selectionShortcut, + description: "Selection Capture" + ) { [weak self] in + Task { @MainActor in + self?.appDelegate?.captureCoordinator?.captureSelection() + } + } + + // Register translation mode hotkey + await registerHotkey( + type: .translationMode, + shortcut: settings.translationModeShortcut, + description: "Translation Mode" + ) { [weak self] in + Task { @MainActor in + self?.appDelegate?.captureCoordinator?.startTranslationMode() + } + } + + // Register text selection translation hotkey + await registerHotkey( + type: .textSelectionTranslation, + shortcut: settings.textSelectionTranslationShortcut, + description: "Text Selection Translation" + ) { [weak self] in + Task { @MainActor in + self?.appDelegate?.textTranslationCoordinator?.translateSelectedText() + } + } + + // Register translate and insert hotkey + await registerHotkey( + type: .translateAndInsert, + shortcut: settings.translateAndInsertShortcut, + description: "Translate and Insert" + ) { [weak self] in + Task { @MainActor in + self?.appDelegate?.textTranslationCoordinator?.translateClipboardAndInsert() + } + } + + logger.info("All hotkeys registered successfully") + } + + /// Unregisters all hotkeys + func unregisterAllHotkeys() async { + logger.info("Unregistering all hotkeys") + + await HotkeyManager.shared.unregisterAll() + registrations.removeAll() + } + + /// Updates hotkeys when settings change + func updateHotkeys() { + Task { + logger.info("Hotkey settings changed, re-registering") + await registerAllHotkeys() + } + } + + /// Returns the currently registered hotkey for a type + func registration(for type: HotkeyType) -> HotkeyManager.Registration? { + registrations[type] + } + + /// Returns all currently registered hotkey types + var registeredTypes: [HotkeyType] { + Array(registrations.keys) + } + + // MARK: - Private Helpers + + /// Registers a single hotkey + private func registerHotkey( + type: HotkeyType, + shortcut: KeyboardShortcut, + description: String, + handler: @escaping @Sendable () -> Void + ) async { + do { + let registration = try await HotkeyManager.shared.register( + shortcut: shortcut, + handler: handler + ) + registrations[type] = registration + logger.debug("Registered hotkey: \(description) -> \(shortcut.displayString)") + } catch { + logger.error("Failed to register hotkey \(type.rawValue): \(error.localizedDescription)") + } + } +} diff --git a/ScreenTranslate/App/Coordinators/TextTranslationCoordinator.swift b/ScreenTranslate/App/Coordinators/TextTranslationCoordinator.swift new file mode 100644 index 0000000..3a3e650 --- /dev/null +++ b/ScreenTranslate/App/Coordinators/TextTranslationCoordinator.swift @@ -0,0 +1,297 @@ +// +// TextTranslationCoordinator.swift +// ScreenTranslate +// +// Created during architecture refactoring - extracts text translation logic from AppDelegate +// +// ## Responsibilities +// - Text selection translation: Captures selected text, translates, shows popup +// - Translate and insert: Captures text, translates, replaces original text +// +// ## Usage +// Access via AppDelegate.textTranslationCoordinator: +// ```swift +// appDelegate.textTranslationCoordinator?.translateSelectedText() +// appDelegate.textTranslationCoordinator?.translateClipboardAndInsert() +// ``` +// + +import AppKit +import os + +/// Coordinates text translation functionality: +/// text selection translation and translate-and-insert workflows. +/// +/// This coordinator was extracted from AppDelegate as part of the architecture +/// refactoring to improve separation of concerns and testability. +@MainActor +final class TextTranslationCoordinator { + // MARK: - Properties + + /// Reference to app delegate for error handling + private weak var appDelegate: AppDelegate? + + /// Whether a translation operation is currently in progress + private var isTranslating = false + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ScreenTranslate", category: "TextTranslationCoordinator") + + // MARK: - Initialization + + init(appDelegate: AppDelegate) { + self.appDelegate = appDelegate + } + + // MARK: - Public API + + /// Triggers text selection translation workflow + func translateSelectedText() { + guard !isTranslating else { + logger.debug("Translation already in progress, ignoring request") + return + } + + logger.info("Text selection translation triggered") + + isTranslating = true + + Task { [weak self] in + defer { self?.isTranslating = false } + await self?.handleTextSelectionTranslation() + } + } + + /// Triggers translate-and-insert workflow + func translateClipboardAndInsert() { + guard !isTranslating else { + logger.debug("Translation already in progress, ignoring request") + return + } + + logger.info("Translate and insert triggered") + + isTranslating = true + + Task { [weak self] in + defer { self?.isTranslating = false } + await self?.handleTranslateClipboardAndInsert() + } + } + + // MARK: - Private Implementation + + /// Ensures accessibility permission is granted before performing text operations. + /// - Returns: true if permission is available, false otherwise (error already shown) + private func ensureAccessibilityPermission() async -> Bool { + let permissionManager = PermissionManager.shared + permissionManager.refreshPermissionStatus() + + if !permissionManager.hasAccessibilityPermission { + // Directly trigger system permission prompt + permissionManager.requestAccessibilityPermission() + permissionManager.refreshPermissionStatus() + + if permissionManager.hasAccessibilityPermission { + return true + } + + // Still not granted (user denied) — guide to System Settings + permissionManager.showPermissionDeniedError(for: .accessibility) + return false + } + return true + } + + /// Handles the complete text selection translation flow + private func handleTextSelectionTranslation() async { + // Check accessibility permission before attempting text capture + guard await ensureAccessibilityPermission() else { return } + + do { + // Step 1: Capture selected text + let textSelectionService = TextSelectionService.shared + let selectionResult = try await textSelectionService.captureSelectedText() + + logger.info("Captured selected text: \(selectionResult.text.count) characters") + logger.info("Source app: \(selectionResult.sourceApplication ?? "unknown")") + + // Step 2: Show loading indicator + await showLoadingIndicator() + + // Step 3: Translate the captured text + if #available(macOS 13.0, *) { + let config = TextTranslationConfig.fromAppSettings() + let translationResult = try await TextTranslationFlow.shared.translate( + selectionResult.text, + config: config + ) + + logger.info("Translation completed in \(translationResult.processingTime * 1000)ms") + + // Step 4: Hide loading and display result popup + await hideLoadingIndicator() + + await MainActor.run { + TextTranslationPopupController.shared.presentPopup(result: translationResult) + } + + } else { + await hideLoadingIndicator() + appDelegate?.showCaptureError(.captureFailure(underlying: NSError( + domain: "ScreenTranslate", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "macOS 13.0+ required for text translation"] + ))) + } + + } catch let error as TextSelectionService.CaptureError { + await hideLoadingIndicator() + + // Handle empty selection with user notification (no crash) + switch error { + case .noSelection: + logger.info("No text selected for translation") + await showNoSelectionNotification() + case .accessibilityPermissionDenied: + logger.error("Accessibility permission denied") + appDelegate?.showCaptureError(.captureFailure(underlying: error)) + default: + logger.error("Failed to capture selected text: \(error.localizedDescription)") + appDelegate?.showCaptureError(.captureFailure(underlying: error)) + } + + } catch let error as TextTranslationError { + await hideLoadingIndicator() + logger.error("Translation failed: \(error.localizedDescription)") + showTranslationError(error) + + } catch { + await hideLoadingIndicator() + logger.error("Unexpected error during text translation: \(error.localizedDescription)") + appDelegate?.showCaptureError(.captureFailure(underlying: error)) + } + } + + /// Handles the translate selected text and insert flow + private func handleTranslateClipboardAndInsert() async { + // Check accessibility permission before attempting text capture and insertion + guard await ensureAccessibilityPermission() else { return } + + // Step 1: Capture selected text + let textSelectionService = TextSelectionService.shared + let selectedText: String + + do { + let selectionResult = try await textSelectionService.captureSelectedText() + selectedText = selectionResult.text + logger.info("Captured selected text: \(selectedText.count) characters") + } catch let error as TextSelectionService.CaptureError { + switch error { + case .noSelection: + logger.info("No text selected for translate-and-insert") + return + default: + logger.error("Failed to capture selected text: \(error.localizedDescription)") + appDelegate?.showCaptureError(.captureFailure(underlying: error)) + return + } + } catch { + logger.error("Unexpected error capturing text: \(error.localizedDescription)") + appDelegate?.showCaptureError(.captureFailure(underlying: error)) + return + } + + // Step 2: Translate the text + let translatedText: String + + do { + if #available(macOS 13.0, *) { + let config = TextTranslationConfig.forTranslateAndInsert() + let translationResult = try await TextTranslationFlow.shared.translate(selectedText, config: config) + translatedText = translationResult.translatedText + + logger.info("Translation completed in \(translationResult.processingTime * 1000)ms") + } else { + appDelegate?.showCaptureError(.captureFailure(underlying: NSError( + domain: "ScreenTranslate", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "macOS 13.0+ required for text translation"] + ))) + return + } + } catch let error as TextTranslationError { + logger.error("Translation failed: \(error.localizedDescription)") + showTranslationError(error) + return + } catch { + logger.error("Unexpected error during translation: \(error.localizedDescription)") + appDelegate?.showCaptureError(.captureFailure(underlying: error)) + return + } + + // Step 3: Delete selection and insert translated text + do { + try await TextInsertService.shared.deleteSelectionAndInsert(translatedText) + logger.info("Successfully inserted translated text") + } catch let error as TextInsertService.InsertError { + logger.error("Text insertion failed: \(error.localizedDescription)") + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "textTranslation.error.insertFailed") + alert.informativeText = error.localizedDescription + alert.addButton(withTitle: String(localized: "common.ok")) + alert.runModal() + } catch { + logger.error("Unexpected error during translate and insert: \(error.localizedDescription)") + appDelegate?.showCaptureError(.captureFailure(underlying: error)) + } + } + + /// Shows an error alert for translation failures + private func showTranslationError(_ error: TextTranslationError) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = error.errorDescription ?? String(localized: "error.translation.failed") + alert.informativeText = error.recoverySuggestion ?? "" + alert.addButton(withTitle: String(localized: "common.ok")) + alert.runModal() + } + + // MARK: - UI Helpers + + /// Shows a brief loading indicator for text translation + private func showLoadingIndicator() async { + // Already @MainActor, no need for MainActor.run + let placeholderImage = NSImage( + systemSymbolName: "character.textbox", + accessibilityDescription: "Translating" + ) + + let scaleFactor = NSScreen.main?.backingScaleFactor ?? 2.0 + + if let cgImage = placeholderImage?.cgImage(forProposedRect: nil, context: nil, hints: nil) { + BilingualResultWindowController.shared.showLoading( + originalImage: cgImage, + scaleFactor: scaleFactor, + message: String(localized: "textTranslation.loading") + ) + } + } + + /// Hides the loading indicator + private func hideLoadingIndicator() async { + // Already @MainActor, no need for MainActor.run + BilingualResultWindowController.shared.close() + } + + /// Shows a notification when no text is selected + private func showNoSelectionNotification() async { + // Already @MainActor, no need for MainActor.run + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = String(localized: "textTranslation.noSelection.title") + alert.informativeText = String(localized: "textTranslation.noSelection.message") + alert.addButton(withTitle: String(localized: "common.ok")) + alert.runModal() + } +} diff --git a/ScreenCapture/App/ScreenCaptureApp.swift b/ScreenTranslate/App/ScreenTranslateApp.swift similarity index 83% rename from ScreenCapture/App/ScreenCaptureApp.swift rename to ScreenTranslate/App/ScreenTranslateApp.swift index e021b89..972adf4 100644 --- a/ScreenCapture/App/ScreenCaptureApp.swift +++ b/ScreenTranslate/App/ScreenTranslateApp.swift @@ -1,9 +1,9 @@ import SwiftUI -/// Main entry point for the ScreenCapture application. +/// Main entry point for the ScreenTranslate application. /// Uses SwiftUI App lifecycle with NSApplicationDelegate for menu bar integration. @main -struct ScreenCaptureApp: App { +struct ScreenTranslateApp: App { /// AppDelegate for handling menu bar setup and hotkey registration @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate diff --git a/ScreenCapture/Errors/ScreenCaptureError.swift b/ScreenTranslate/Errors/ScreenTranslateError.swift similarity index 74% rename from ScreenCapture/Errors/ScreenCaptureError.swift rename to ScreenTranslate/Errors/ScreenTranslateError.swift index d65034e..cafcb79 100644 --- a/ScreenCapture/Errors/ScreenCaptureError.swift +++ b/ScreenTranslate/Errors/ScreenTranslateError.swift @@ -1,9 +1,9 @@ import Foundation import CoreGraphics -/// Typed error enum for all ScreenCapture failure cases. +/// Typed error enum for all ScreenTranslate failure cases. /// Provides localized descriptions and recovery suggestions for user-friendly error handling. -enum ScreenCaptureError: LocalizedError, Sendable { +enum ScreenTranslateError: LocalizedError, Sendable { // MARK: - Capture Errors /// Screen recording permission was denied by the user or system @@ -42,6 +42,20 @@ enum ScreenCaptureError: LocalizedError, Sendable { /// The keyboard shortcut conflicts with another application case hotkeyConflict(existingApp: String?) + // MARK: - OCR Errors + + /// OCR operation is currently in progress + case ocrOperationInProgress + + /// The image provided for OCR is invalid + case ocrInvalidImage + + /// Text recognition failed + case ocrRecognitionFailed + + /// No text was found in the image + case ocrNoTextFound + // MARK: - LocalizedError Conformance var errorDescription: String? { @@ -66,6 +80,14 @@ enum ScreenCaptureError: LocalizedError, Sendable { return NSLocalizedString("error.hotkey.registration.failed", comment: "") case .hotkeyConflict: return NSLocalizedString("error.hotkey.conflict", comment: "") + case .ocrOperationInProgress: + return NSLocalizedString("error.ocr.in.progress", comment: "") + case .ocrInvalidImage: + return NSLocalizedString("error.ocr.invalid.image", comment: "") + case .ocrRecognitionFailed: + return NSLocalizedString("error.ocr.recognition.failed", comment: "") + case .ocrNoTextFound: + return NSLocalizedString("error.ocr.no.text.found", comment: "") } } @@ -91,15 +113,23 @@ enum ScreenCaptureError: LocalizedError, Sendable { return NSLocalizedString("error.hotkey.registration.failed.recovery", comment: "") case .hotkeyConflict: return NSLocalizedString("error.hotkey.conflict.recovery", comment: "") + case .ocrOperationInProgress: + return NSLocalizedString("error.ocr.in.progress.recovery", comment: "") + case .ocrInvalidImage: + return NSLocalizedString("error.ocr.invalid.image.recovery", comment: "") + case .ocrRecognitionFailed: + return NSLocalizedString("error.ocr.recognition.failed.recovery", comment: "") + case .ocrNoTextFound: + return NSLocalizedString("error.ocr.no.text.found.recovery", comment: "") } } } // MARK: - Sendable Conformance for Underlying Error -extension ScreenCaptureError { +extension ScreenTranslateError { /// Creates a capture failure error with a sendable error description - static func captureError(message: String) -> ScreenCaptureError { + static func captureError(message: String) -> ScreenTranslateError { .captureFailure(underlying: CaptureFailureError(message: message)) } } diff --git a/ScreenCapture/Extensions/CGImage+Extensions.swift b/ScreenTranslate/Extensions/CGImage+Extensions.swift similarity index 96% rename from ScreenCapture/Extensions/CGImage+Extensions.swift rename to ScreenTranslate/Extensions/CGImage+Extensions.swift index 54f89a4..2a152de 100644 --- a/ScreenCapture/Extensions/CGImage+Extensions.swift +++ b/ScreenTranslate/Extensions/CGImage+Extensions.swift @@ -115,7 +115,7 @@ extension CGImage { /// - url: The destination file URL /// - format: The export format /// - quality: Compression quality for JPEG (0.0-1.0) - /// - Throws: ScreenCaptureError if writing fails + /// - Throws: ScreenTranslateError if writing fails func write(to url: URL, format: ExportFormat, quality: CGFloat = 0.9) throws { guard let destination = CGImageDestinationCreateWithURL( url as CFURL, @@ -123,7 +123,7 @@ extension CGImage { 1, nil ) else { - throw ScreenCaptureError.exportEncodingFailed(format: format) + throw ScreenTranslateError.exportEncodingFailed(format: format) } var options: [CFString: Any] = [:] @@ -134,7 +134,7 @@ extension CGImage { CGImageDestinationAddImage(destination, self, options as CFDictionary) guard CGImageDestinationFinalize(destination) else { - throw ScreenCaptureError.exportEncodingFailed(format: format) + throw ScreenTranslateError.exportEncodingFailed(format: format) } } diff --git a/ScreenCapture/Extensions/NSImage+Extensions.swift b/ScreenTranslate/Extensions/NSImage+Extensions.swift similarity index 100% rename from ScreenCapture/Extensions/NSImage+Extensions.swift rename to ScreenTranslate/Extensions/NSImage+Extensions.swift diff --git a/ScreenCapture/Extensions/View+Cursor.swift b/ScreenTranslate/Extensions/View+Cursor.swift similarity index 99% rename from ScreenCapture/Extensions/View+Cursor.swift rename to ScreenTranslate/Extensions/View+Cursor.swift index 1fdec60..9e62380 100644 --- a/ScreenCapture/Extensions/View+Cursor.swift +++ b/ScreenTranslate/Extensions/View+Cursor.swift @@ -49,7 +49,7 @@ private class ScrollWheelCaptureView: NSView { override func scrollWheel(with event: NSEvent) { // Only handle scroll wheel zoom when Command key is held // or when using a mouse (not trackpad for scrolling) - if event.modifierFlags.contains(.command) || event.phase == .none { + if event.modifierFlags.contains(.command) || event.phase.isEmpty { // Use deltaY for vertical scroll (zoom) let delta = event.scrollingDeltaY if abs(delta) > 0.1 { diff --git a/ScreenTranslate/Features/About/AboutView.swift b/ScreenTranslate/Features/About/AboutView.swift new file mode 100644 index 0000000..8df7694 --- /dev/null +++ b/ScreenTranslate/Features/About/AboutView.swift @@ -0,0 +1,138 @@ +import SwiftUI +import Sparkle +import Combine + +struct AboutView: View { + @State private var showingAcknowledgements = false + @State private var isCheckingUpdates = false + + private var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" + } + + private var buildNumber: String { + Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" + } + + var body: some View { + VStack(spacing: 0) { + headerSection + Divider() + infoSection + Divider() + buttonSection + } + .frame(width: 400) + .background(VisualEffectView(material: .windowBackground, blendingMode: .behindWindow)) + .sheet(isPresented: $showingAcknowledgements) { + AcknowledgementsView() + } + } + + private var headerSection: some View { + HStack(spacing: 16) { + Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) + .resizable() + .frame(width: 80, height: 80) + + VStack(alignment: .leading, spacing: 4) { + Text(LocalizedStringKey("about.app.name")) + .font(.title) + .fontWeight(.semibold) + + Text(String( + format: NSLocalizedString("about.version.format", comment: ""), + appVersion, buildNumber + )) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding(24) + } + + private var infoSection: some View { + VStack(alignment: .leading, spacing: 12) { + infoRow( + icon: "character.book.closed", + label: NSLocalizedString("about.copyright", comment: "Copyright"), + value: NSLocalizedString("about.copyright.value", comment: "") + ) + + infoRow( + icon: "doc.text", + label: NSLocalizedString("about.license", comment: "License"), + value: NSLocalizedString("about.license.value", comment: "") + ) + + Link(destination: URL(string: "https://github.com/hubo1989/ScreenTranslate")!) { + HStack(spacing: 8) { + Image(systemName: "link") + .frame(width: 20) + .foregroundStyle(.secondary) + + Text(LocalizedStringKey("about.github.link")) + .font(.subheadline) + .foregroundStyle(.link) + + Spacer() + } + } + .buttonStyle(.plain) + } + .padding(24) + } + + private func infoRow(icon: String, label: String, value: String) -> some View { + HStack(spacing: 8) { + Image(systemName: icon) + .frame(width: 20) + .foregroundStyle(.secondary) + + Text(label) + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(value) + .font(.subheadline) + + Spacer() + } + } + + private var buttonSection: some View { + HStack(spacing: 12) { + Button { + // Trigger Sparkle update check - Sparkle shows its own UI + NotificationCenter.default.post(name: .checkForUpdates, object: nil) + } label: { + Label( + NSLocalizedString("about.check.for.updates", comment: "Check for Updates"), + systemImage: "arrow.clockwise" + ) + } + + Button { + showingAcknowledgements = true + } label: { + Label( + NSLocalizedString("about.acknowledgements", comment: "Acknowledgements"), + systemImage: "heart.fill" + ) + } + + Spacer() + } + .padding(24) + } +} + +extension Notification.Name { + static let checkForUpdates = Notification.Name("checkForUpdates") +} + +#Preview { + AboutView() +} diff --git a/ScreenTranslate/Features/About/AboutWindowController.swift b/ScreenTranslate/Features/About/AboutWindowController.swift new file mode 100644 index 0000000..4f358b6 --- /dev/null +++ b/ScreenTranslate/Features/About/AboutWindowController.swift @@ -0,0 +1,50 @@ +import AppKit +import SwiftUI + +@MainActor +final class AboutWindowController: NSObject { + static let shared = AboutWindowController() + + private var window: NSWindow? + + private override init() { + super.init() + } + + func showAbout() { + if let window = window, window.isVisible { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let aboutView = AboutView() + let hostingView = NSHostingView(rootView: aboutView) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.title = NSLocalizedString("about.title", comment: "About ScreenTranslate") + window.contentView = hostingView + window.center() + window.isReleasedWhenClosed = false + window.delegate = self + + self.window = window + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } +} + +extension AboutWindowController: NSWindowDelegate { + nonisolated func windowWillClose(_ notification: Notification) { + let closedWindow = notification.object as? NSWindow + Task { @MainActor [weak self] in + guard let self, let closedWindow, closedWindow === self.window else { return } + self.window = nil + } + } +} diff --git a/ScreenTranslate/Features/About/AcknowledgementsView.swift b/ScreenTranslate/Features/About/AcknowledgementsView.swift new file mode 100644 index 0000000..eea234d --- /dev/null +++ b/ScreenTranslate/Features/About/AcknowledgementsView.swift @@ -0,0 +1,159 @@ +import SwiftUI + +struct AcknowledgementsView: View { + @Environment(\.dismiss) private var dismiss + + private let acknowledgements: [(name: String, license: String, url: String)] = [ + ("Sparkle", "MIT", "https://github.com/sparkle-project/Sparkle"), + ] + + private let upstreamProject: (name: String, author: String, url: String) = ( + "ScreenCapture", + "sadopc", + "https://github.com/sadopc/ScreenCapture" + ) + + var body: some View { + VStack(spacing: 0) { + headerView + Divider() + listView + Divider() + footerView + } + .frame(width: 450, height: 450) + .background(VisualEffectView(material: .windowBackground, blendingMode: .behindWindow)) + } + + private var headerView: some View { + HStack { + Text(NSLocalizedString("about.acknowledgements.title", comment: "Acknowledgements")) + .font(.title2) + .fontWeight(.semibold) + + Spacer() + + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(20) + } + + private var listView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Upstream project section + upstreamSection + + Divider() + + Text(NSLocalizedString("about.acknowledgements.intro", comment: "This software uses the following open source libraries:")) + .font(.subheadline) + .foregroundStyle(.secondary) + + ForEach(acknowledgements, id: \.name) { item in + acknowledgementCard(item) + } + } + .padding(20) + } + } + + private var upstreamSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text(NSLocalizedString("about.acknowledgements.upstream", comment: "Based on")) + .font(.subheadline) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(upstreamProject.name) + .font(.headline) + + Spacer() + + Text(String(format: NSLocalizedString("about.acknowledgements.author.format", comment: ""), upstreamProject.author)) + .font(.caption) + .foregroundStyle(.secondary) + } + + if let url = URL(string: upstreamProject.url) { + Link(destination: url) { + Text(upstreamProject.url) + .font(.caption) + .foregroundStyle(.link) + .lineLimit(1) + .truncationMode(.middle) + } + .buttonStyle(.plain) + } + } + .padding(12) + .background(Color.accentColor.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.accentColor.opacity(0.2), lineWidth: 1) + ) + } + } + + private func acknowledgementCard(_ item: (name: String, license: String, url: String)) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(item.name) + .font(.headline) + + Spacer() + + Text(item.license) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.accentColor.opacity(0.1)) + .clipShape(Capsule()) + } + + if let url = URL(string: item.url) { + Link(destination: url) { + Text(item.url) + .font(.caption) + .foregroundStyle(.link) + .lineLimit(1) + .truncationMode(.middle) + } + .buttonStyle(.plain) + } else { + Text(item.url) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + .padding(12) + .background(Color(.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var footerView: some View { + HStack { + Spacer() + Button(NSLocalizedString("about.close", comment: "Close")) { + dismiss() + } + .keyboardShortcut(.defaultAction) + } + .padding(20) + } +} + +#Preview { + AcknowledgementsView() +} diff --git a/ScreenCapture/Features/Annotations/AnnotationTool.swift b/ScreenTranslate/Features/Annotations/AnnotationTool.swift similarity index 98% rename from ScreenCapture/Features/Annotations/AnnotationTool.swift rename to ScreenTranslate/Features/Annotations/AnnotationTool.swift index 2b1766c..50f4854 100644 --- a/ScreenCapture/Features/Annotations/AnnotationTool.swift +++ b/ScreenTranslate/Features/Annotations/AnnotationTool.swift @@ -41,7 +41,7 @@ protocol AnnotationTool { extension AnnotationTool { var textStyle: TextStyle { get { .default } - set { } + set { _ = newValue } } } diff --git a/ScreenCapture/Features/Annotations/ArrowTool.swift b/ScreenTranslate/Features/Annotations/ArrowTool.swift similarity index 92% rename from ScreenCapture/Features/Annotations/ArrowTool.swift rename to ScreenTranslate/Features/Annotations/ArrowTool.swift index 9a86c15..36d8070 100644 --- a/ScreenCapture/Features/Annotations/ArrowTool.swift +++ b/ScreenTranslate/Features/Annotations/ArrowTool.swift @@ -22,10 +22,10 @@ struct ArrowTool: AnnotationTool { } var currentAnnotation: Annotation? { - guard isActive else { return nil } - guard drawingState.points.count >= 2 else { return nil } + guard isActive, + drawingState.points.count >= 2, + let end = drawingState.points.last else { return nil } let start = drawingState.startPoint - let end = drawingState.points.last! return .arrow(ArrowAnnotation(startPoint: start, endPoint: end, style: strokeStyle)) } diff --git a/ScreenTranslate/Features/Annotations/EllipseTool.swift b/ScreenTranslate/Features/Annotations/EllipseTool.swift new file mode 100644 index 0000000..1b5baf6 --- /dev/null +++ b/ScreenTranslate/Features/Annotations/EllipseTool.swift @@ -0,0 +1,79 @@ +import Foundation +import CoreGraphics + +/// Tool for drawing ellipse annotations. +/// User drags to define the bounding rectangle of the ellipse. +@MainActor +struct EllipseTool: AnnotationTool { + // MARK: - Properties + + let toolType: AnnotationToolType = .ellipse + + var strokeStyle: StrokeStyle = .default + + var textStyle: TextStyle = .default + + /// Whether to create filled (solid) ellipses + var isFilled: Bool = false + + private var drawingState = DrawingState() + + // MARK: - AnnotationTool Conformance + + var isActive: Bool { + drawingState.isDrawing + } + + var currentAnnotation: Annotation? { + guard isActive else { return nil } + let rect = calculateRect() + guard rect.width > 0 && rect.height > 0 else { return nil } + return .ellipse(EllipseAnnotation(rect: rect, style: strokeStyle, isFilled: isFilled)) + } + + mutating func beginDrawing(at point: CGPoint) { + drawingState = DrawingState(startPoint: point) + drawingState.isDrawing = true + } + + mutating func continueDrawing(to point: CGPoint) { + guard isActive else { return } + if drawingState.points.count > 1 { + drawingState.points[1] = point + } else { + drawingState.points.append(point) + } + } + + mutating func endDrawing(at point: CGPoint) -> Annotation? { + guard isActive else { return nil } + + continueDrawing(to: point) + let rect = calculateRect() + drawingState.reset() + + guard rect.width >= 5 && rect.height >= 5 else { return nil } + + return .ellipse(EllipseAnnotation(rect: rect, style: strokeStyle, isFilled: isFilled)) + } + + mutating func cancelDrawing() { + drawingState.reset() + } + + // MARK: - Private Methods + + private func calculateRect() -> CGRect { + guard drawingState.points.count >= 2 else { return .zero } + + let start = drawingState.startPoint + guard let end = drawingState.points.last else { return .zero } + + let minX = min(start.x, end.x) + let minY = min(start.y, end.y) + let maxX = max(start.x, end.x) + let maxY = max(start.y, end.y) + + return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) + } +} diff --git a/ScreenCapture/Features/Annotations/FreehandTool.swift b/ScreenTranslate/Features/Annotations/FreehandTool.swift similarity index 100% rename from ScreenCapture/Features/Annotations/FreehandTool.swift rename to ScreenTranslate/Features/Annotations/FreehandTool.swift diff --git a/ScreenTranslate/Features/Annotations/HighlightTool.swift b/ScreenTranslate/Features/Annotations/HighlightTool.swift new file mode 100644 index 0000000..4ca3140 --- /dev/null +++ b/ScreenTranslate/Features/Annotations/HighlightTool.swift @@ -0,0 +1,83 @@ +import Foundation +import CoreGraphics +import SwiftUI + +/// Tool for creating highlight annotations. +/// User drags to define the region to highlight with semi-transparent color. +@MainActor +struct HighlightTool: AnnotationTool { + // MARK: - Properties + + let toolType: AnnotationToolType = .highlight + + var strokeStyle: StrokeStyle = .default + + var textStyle: TextStyle = .default + + /// Highlight color (default yellow) + var highlightColor: CodableColor = CodableColor(.yellow) + + /// Highlight opacity (default 0.4) + var opacity: Double = 0.4 + + private var drawingState = DrawingState() + + // MARK: - AnnotationTool Conformance + + var isActive: Bool { + drawingState.isDrawing + } + + var currentAnnotation: Annotation? { + guard isActive else { return nil } + let rect = calculateRect() + guard rect.width > 0 && rect.height > 0 else { return nil } + return .highlight(HighlightAnnotation(rect: rect, color: highlightColor, opacity: opacity)) + } + + mutating func beginDrawing(at point: CGPoint) { + drawingState = DrawingState(startPoint: point) + drawingState.isDrawing = true + } + + mutating func continueDrawing(to point: CGPoint) { + guard isActive else { return } + if drawingState.points.count > 1 { + drawingState.points[1] = point + } else { + drawingState.points.append(point) + } + } + + mutating func endDrawing(at point: CGPoint) -> Annotation? { + guard isActive else { return nil } + + continueDrawing(to: point) + let rect = calculateRect() + drawingState.reset() + + guard rect.width >= 5 && rect.height >= 5 else { return nil } + + return .highlight(HighlightAnnotation(rect: rect, color: highlightColor, opacity: opacity)) + } + + mutating func cancelDrawing() { + drawingState.reset() + } + + // MARK: - Private Methods + + private func calculateRect() -> CGRect { + guard drawingState.points.count >= 2 else { return .zero } + + let start = drawingState.startPoint + guard let end = drawingState.points.last else { return .zero } + + let minX = min(start.x, end.x) + let minY = min(start.y, end.y) + let maxX = max(start.x, end.x) + let maxY = max(start.y, end.y) + + return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) + } +} diff --git a/ScreenTranslate/Features/Annotations/LineTool.swift b/ScreenTranslate/Features/Annotations/LineTool.swift new file mode 100644 index 0000000..d9fbc03 --- /dev/null +++ b/ScreenTranslate/Features/Annotations/LineTool.swift @@ -0,0 +1,70 @@ +import Foundation +import CoreGraphics + +/// Tool for drawing straight line annotations. +/// User drags from start point to end point. +@MainActor +struct LineTool: AnnotationTool { + // MARK: - Properties + + let toolType: AnnotationToolType = .line + + var strokeStyle: StrokeStyle = .default + + var textStyle: TextStyle = .default + + private var drawingState = DrawingState() + + // MARK: - AnnotationTool Conformance + + var isActive: Bool { + drawingState.isDrawing + } + + var currentAnnotation: Annotation? { + guard isActive, drawingState.points.count >= 2 else { return nil } + let start = drawingState.startPoint + let end = drawingState.points.last! + return .line(LineAnnotation(startPoint: start, endPoint: end, style: strokeStyle)) + } + + mutating func beginDrawing(at point: CGPoint) { + drawingState = DrawingState(startPoint: point) + drawingState.isDrawing = true + } + + mutating func continueDrawing(to point: CGPoint) { + guard isActive else { return } + if drawingState.points.count > 1 { + drawingState.points[1] = point + } else { + drawingState.points.append(point) + } + } + + mutating func endDrawing(at point: CGPoint) -> Annotation? { + guard isActive else { return nil } + + continueDrawing(to: point) + guard drawingState.points.count >= 2 else { + drawingState.reset() + return nil + } + + let start = drawingState.startPoint + let end = drawingState.points.last! + drawingState.reset() + + // Only create annotation if it has meaningful length + let dx = end.x - start.x + let dy = end.y - start.y + let length = sqrt(dx * dx + dy * dy) + guard length >= 5 else { return nil } + + return .line(LineAnnotation(startPoint: start, endPoint: end, style: strokeStyle)) + } + + mutating func cancelDrawing() { + drawingState.reset() + } +} diff --git a/ScreenTranslate/Features/Annotations/MosaicTool.swift b/ScreenTranslate/Features/Annotations/MosaicTool.swift new file mode 100644 index 0000000..4983036 --- /dev/null +++ b/ScreenTranslate/Features/Annotations/MosaicTool.swift @@ -0,0 +1,79 @@ +import Foundation +import CoreGraphics + +/// Tool for creating mosaic (pixelation) annotations. +/// User drags to define the region to pixelate. +@MainActor +struct MosaicTool: AnnotationTool { + // MARK: - Properties + + let toolType: AnnotationToolType = .mosaic + + var strokeStyle: StrokeStyle = .default + + var textStyle: TextStyle = .default + + /// Block size for pixelation + var blockSize: Int = 10 + + private var drawingState = DrawingState() + + // MARK: - AnnotationTool Conformance + + var isActive: Bool { + drawingState.isDrawing + } + + var currentAnnotation: Annotation? { + guard isActive else { return nil } + let rect = calculateRect() + guard rect.width > 0 && rect.height > 0 else { return nil } + return .mosaic(MosaicAnnotation(rect: rect, blockSize: blockSize)) + } + + mutating func beginDrawing(at point: CGPoint) { + drawingState = DrawingState(startPoint: point) + drawingState.isDrawing = true + } + + mutating func continueDrawing(to point: CGPoint) { + guard isActive else { return } + if drawingState.points.count > 1 { + drawingState.points[1] = point + } else { + drawingState.points.append(point) + } + } + + mutating func endDrawing(at point: CGPoint) -> Annotation? { + guard isActive else { return nil } + + continueDrawing(to: point) + let rect = calculateRect() + drawingState.reset() + + guard rect.width >= 10 && rect.height >= 10 else { return nil } + + return .mosaic(MosaicAnnotation(rect: rect, blockSize: blockSize)) + } + + mutating func cancelDrawing() { + drawingState.reset() + } + + // MARK: - Private Methods + + private func calculateRect() -> CGRect { + guard drawingState.points.count >= 2 else { return .zero } + + let start = drawingState.startPoint + guard let end = drawingState.points.last else { return .zero } + + let minX = min(start.x, end.x) + let minY = min(start.y, end.y) + let maxX = max(start.x, end.x) + let maxY = max(start.y, end.y) + + return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) + } +} diff --git a/ScreenTranslate/Features/Annotations/NumberLabelTool.swift b/ScreenTranslate/Features/Annotations/NumberLabelTool.swift new file mode 100644 index 0000000..563e789 --- /dev/null +++ b/ScreenTranslate/Features/Annotations/NumberLabelTool.swift @@ -0,0 +1,77 @@ +import Foundation +import CoreGraphics +import SwiftUI + +/// Tool for creating numbered label annotations (①②③...). +/// User clicks to place a numbered circle. +@MainActor +struct NumberLabelTool: AnnotationTool { + // MARK: - Properties + + let toolType: AnnotationToolType = .numberLabel + + var strokeStyle: StrokeStyle = .default + + var textStyle: TextStyle = .default + + /// Current number to display (auto-increments after each placement) + var currentNumber: Int = 1 + + /// Circle size (diameter) + var circleSize: CGFloat = 24 + + /// Label color + var labelColor: CodableColor = CodableColor(.red) + + private var pendingPosition: CGPoint? + + // MARK: - AnnotationTool Conformance + + var isActive: Bool { + pendingPosition != nil + } + + var currentAnnotation: Annotation? { + guard let position = pendingPosition else { return nil } + return .numberLabel(NumberLabelAnnotation( + position: position, + number: currentNumber, + size: circleSize, + color: labelColor + )) + } + + mutating func beginDrawing(at point: CGPoint) { + pendingPosition = point + } + + mutating func continueDrawing(to point: CGPoint) { + // Number labels don't drag, they just click + } + + mutating func endDrawing(at point: CGPoint) -> Annotation? { + guard let position = pendingPosition else { return nil } + + let annotation = NumberLabelAnnotation( + position: position, + number: currentNumber, + size: circleSize, + color: labelColor + ) + + // Reset and increment for next label + pendingPosition = nil + currentNumber += 1 + + return .numberLabel(annotation) + } + + mutating func cancelDrawing() { + pendingPosition = nil + } + + /// Reset the number counter back to 1 + mutating func resetNumber() { + currentNumber = 1 + } +} diff --git a/ScreenCapture/Features/Annotations/RectangleTool.swift b/ScreenTranslate/Features/Annotations/RectangleTool.swift similarity index 96% rename from ScreenCapture/Features/Annotations/RectangleTool.swift rename to ScreenTranslate/Features/Annotations/RectangleTool.swift index 492c93e..263d1db 100644 --- a/ScreenCapture/Features/Annotations/RectangleTool.swift +++ b/ScreenTranslate/Features/Annotations/RectangleTool.swift @@ -71,7 +71,9 @@ struct RectangleTool: AnnotationTool { } let start = drawingState.startPoint - let end = drawingState.points.last! + guard let end = drawingState.points.last else { + return .zero + } let minX = min(start.x, end.x) let minY = min(start.y, end.y) diff --git a/ScreenCapture/Features/Annotations/TextTool.swift b/ScreenTranslate/Features/Annotations/TextTool.swift similarity index 100% rename from ScreenCapture/Features/Annotations/TextTool.swift rename to ScreenTranslate/Features/Annotations/TextTool.swift diff --git a/ScreenTranslate/Features/BilingualResult/BilingualResultView.swift b/ScreenTranslate/Features/BilingualResult/BilingualResultView.swift new file mode 100644 index 0000000..89fa994 --- /dev/null +++ b/ScreenTranslate/Features/BilingualResult/BilingualResultView.swift @@ -0,0 +1,172 @@ +import SwiftUI +import AppKit + +struct BilingualResultView: View { + @Bindable var viewModel: BilingualResultViewModel + @State private var imageScale: CGFloat = 1.0 + + var body: some View { + VStack(spacing: 0) { + ScrollView([.horizontal, .vertical], showsIndicators: true) { + Image(decorative: viewModel.image, scale: viewModel.displayScaleFactor) + .resizable() + .aspectRatio(contentMode: .fit) + .scaleEffect(viewModel.scale) + .frame( + width: viewModel.imagePointWidth * viewModel.scale, + height: viewModel.imagePointHeight * viewModel.scale + ) + .onScrollWheelZoom { delta in + if delta > 0 { + viewModel.zoomIn() + } else { + viewModel.zoomOut() + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .windowBackgroundColor)) + + Divider() + + toolBar + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.bar) + } + .onKeyPress(.escape) { + BilingualResultWindowController.shared.close() + return .handled + } + .overlay(alignment: .top) { + if let message = viewModel.copySuccessMessage { + successToast(message: message, icon: "doc.on.clipboard.fill") + } + if let message = viewModel.saveSuccessMessage { + successToast(message: message, icon: "checkmark.circle.fill") + } + } + .alert( + String(localized: "error.title"), + isPresented: .constant(viewModel.errorMessage != nil), + presenting: viewModel.errorMessage + ) { _ in + Button(String(localized: "button.ok")) { + viewModel.errorMessage = nil + } + } message: { message in + Text(message) + } + } + + private var toolBar: some View { + HStack(spacing: 12) { + if viewModel.isLoading { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(viewModel.loadingMessage) + .font(.system(.callout, design: .default)) + .foregroundStyle(.secondary) + } + + Spacer() + } else { + Text(viewModel.dimensionsText) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + + Divider() + .frame(height: 16) + + HStack(spacing: 4) { + Button(action: viewModel.zoomOut) { + Image(systemName: "minus.magnifyingglass") + } + .buttonStyle(.borderless) + .help(String(localized: "bilingualResult.zoomOut")) + + Button(action: viewModel.resetZoom) { + Text("\(Int(viewModel.scale * 100))%") + .font(.system(.caption, design: .monospaced)) + .frame(minWidth: 45) + } + .buttonStyle(.borderless) + .help(String(localized: "bilingualResult.resetZoom")) + + Button(action: viewModel.zoomIn) { + Image(systemName: "plus.magnifyingglass") + } + .buttonStyle(.borderless) + .help(String(localized: "bilingualResult.zoomIn")) + } + + Spacer() + + HStack(spacing: 8) { + Button(action: viewModel.copyImageToClipboard) { + Label(String(localized: "bilingualResult.copyImage"), systemImage: "photo") + } + .buttonStyle(.bordered) + + Button(action: viewModel.copyTextToClipboard) { + Label(String(localized: "bilingualResult.copyText"), systemImage: "doc.on.clipboard") + } + .buttonStyle(.bordered) + .disabled(viewModel.translatedText.isEmpty) + + Button(action: viewModel.saveImage) { + Label(String(localized: "bilingualResult.save"), systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) + } + } + } + } + + private func successToast(message: String, icon: String) -> some View { + HStack(spacing: 8) { + Image(systemName: icon) + .foregroundColor(.green) + Text(message) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.ultraThinMaterial) + .cornerRadius(8) + .shadow(radius: 4) + .padding(.top, 20) + .transition(.move(edge: .top).combined(with: .opacity)) + .animation(.easeInOut(duration: 0.3), value: message) + } + +} + +#if DEBUG +#Preview { + let testImage: CGImage = { + let width = 800 + let height = 400 + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ), + let image = context.makeImage() else { + fatalError("Unable to create test image") + } + context.setFillColor(NSColor.systemBlue.cgColor) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + return image + }() + + let viewModel = BilingualResultViewModel(image: testImage) + return BilingualResultView(viewModel: viewModel) + .frame(width: 600, height: 400) +} +#endif diff --git a/ScreenTranslate/Features/BilingualResult/BilingualResultViewModel.swift b/ScreenTranslate/Features/BilingualResult/BilingualResultViewModel.swift new file mode 100644 index 0000000..9f97a5b --- /dev/null +++ b/ScreenTranslate/Features/BilingualResult/BilingualResultViewModel.swift @@ -0,0 +1,152 @@ +import AppKit +import Observation + +@MainActor +@Observable +final class BilingualResultViewModel { + private(set) var image: CGImage + private(set) var scale: CGFloat = 1.0 + var displayScaleFactor: CGFloat + var isLoading: Bool = false + var loadingMessage: String = "" + var copySuccessMessage: String? + var saveSuccessMessage: String? + var errorMessage: String? + + /// Translated text for copy functionality + private(set) var translatedText: String = "" + + private let minScale: CGFloat = 0.1 + private let maxScale: CGFloat = 5.0 + private let scaleStep: CGFloat = 0.1 + + var imageWidth: Int { image.width } + var imageHeight: Int { image.height } + + /// Image size in points (for display sizing) + var imagePointWidth: CGFloat { CGFloat(image.width) / displayScaleFactor } + var imagePointHeight: CGFloat { CGFloat(image.height) / displayScaleFactor } + + var dimensionsText: String { + "\(imageWidth) × \(imageHeight)" + } + + init(image: CGImage, displayScaleFactor: CGFloat = 1.0) { + self.image = image + self.displayScaleFactor = displayScaleFactor + } + + func showLoading(originalImage: CGImage, message: String? = nil) { + self.image = originalImage + self.isLoading = true + self.loadingMessage = message ?? String(localized: "bilingualResult.loading") + self.errorMessage = nil + self.translatedText = "" // Clear previous translation when loading new content + } + + func showResult(image: CGImage, displayScaleFactor: CGFloat? = nil, translatedText: String? = nil) { + self.image = image + if let sf = displayScaleFactor { self.displayScaleFactor = sf } + if let text = translatedText { self.translatedText = text } + self.isLoading = false + self.loadingMessage = "" + self.errorMessage = nil + self.scale = 1.0 + } + + func showError(_ message: String) { + self.isLoading = false + self.errorMessage = message + } + + func updateImage(_ newImage: CGImage, displayScaleFactor: CGFloat? = nil) { + self.image = newImage + if let sf = displayScaleFactor { self.displayScaleFactor = sf } + self.errorMessage = nil + self.scale = 1.0 + } + + func zoomIn() { + let newScale = min(scale + scaleStep, maxScale) + if newScale != scale { + scale = newScale + } + } + + func zoomOut() { + let newScale = max(scale - scaleStep, minScale) + if newScale != scale { + scale = newScale + } + } + + func resetZoom() { + scale = 1.0 + } + + func copyImageToClipboard() { + do { + try ClipboardService.shared.copy(image) + showCopySuccess() + } catch { + errorMessage = String(localized: "bilingualResult.copyFailed") + } + } + + func copyTextToClipboard() { + guard !translatedText.isEmpty else { + errorMessage = String(localized: "bilingualResult.noTextToCopy") + return + } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + let success = pasteboard.setString(translatedText, forType: .string) + if success { + showCopyTextSuccess() + } else { + errorMessage = String(localized: "bilingualResult.copyFailed") + } + } + + func saveImage() { + let savePanel = NSSavePanel() + savePanel.allowedContentTypes = [.png] + savePanel.nameFieldStringValue = ImageExporter.shared.generateFilename(format: .png) + savePanel.canCreateDirectories = true + + guard savePanel.runModal() == .OK, let url = savePanel.url else { + return + } + + do { + try ImageExporter.shared.save(image, annotations: [], to: url, format: .png, quality: 1.0) + showSaveSuccess() + } catch { + errorMessage = String(localized: "bilingualResult.saveFailed") + } + } + + private func showCopySuccess() { + copySuccessMessage = String(localized: "bilingualResult.copySuccess") + Task { + try? await Task.sleep(for: .seconds(2)) + copySuccessMessage = nil + } + } + + private func showCopyTextSuccess() { + copySuccessMessage = String(localized: "bilingualResult.copyTextSuccess") + Task { + try? await Task.sleep(for: .seconds(2)) + copySuccessMessage = nil + } + } + + private func showSaveSuccess() { + saveSuccessMessage = String(localized: "bilingualResult.saveSuccess") + Task { + try? await Task.sleep(for: .seconds(2)) + saveSuccessMessage = nil + } + } +} diff --git a/ScreenTranslate/Features/BilingualResult/BilingualResultWindowController.swift b/ScreenTranslate/Features/BilingualResult/BilingualResultWindowController.swift new file mode 100644 index 0000000..348480a --- /dev/null +++ b/ScreenTranslate/Features/BilingualResult/BilingualResultWindowController.swift @@ -0,0 +1,168 @@ +import AppKit +import SwiftUI + +@MainActor +final class BilingualResultWindowController: NSObject { + static let shared = BilingualResultWindowController() + + private var window: NSWindow? + private var viewModel: BilingualResultViewModel? + + private override init() { + super.init() + } + + /// Calculate window size from image point dimensions + private func calculateWindowSize( + imagePointWidth: CGFloat, + imagePointHeight: CGFloat, + maxWidth: CGFloat, + maxHeight: CGFloat, + minWidth: CGFloat = 400, + minHeight: CGFloat = 300 + ) -> NSSize { + // Default to 100% scale, only shrink if image exceeds screen bounds + let scale: CGFloat + if imagePointWidth > maxWidth || imagePointHeight > maxHeight { + scale = min(maxWidth / imagePointWidth, maxHeight / imagePointHeight) + } else { + scale = 1.0 + } + let windowWidth = max(minWidth, imagePointWidth * scale) + let windowHeight = max(minHeight, imagePointHeight * scale + 50) + return NSSize(width: windowWidth, height: windowHeight) + } + + func showLoading(originalImage: CGImage, scaleFactor: CGFloat, message: String? = nil) { + if let existingWindow = window, existingWindow.isVisible { + viewModel?.showLoading(originalImage: originalImage, message: message) + existingWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let newViewModel = BilingualResultViewModel(image: originalImage, displayScaleFactor: scaleFactor) + newViewModel.isLoading = true + newViewModel.loadingMessage = message ?? String(localized: "bilingualResult.loading") + self.viewModel = newViewModel + + let contentView = BilingualResultView(viewModel: newViewModel) + let hostingView = NSHostingView(rootView: contentView) + + let imagePointWidth = CGFloat(originalImage.width) / scaleFactor + let imagePointHeight = CGFloat(originalImage.height) / scaleFactor + let screenWidth = NSScreen.main?.frame.width ?? 1920 + let screenHeight = NSScreen.main?.frame.height ?? 1080 + + let windowSize = calculateWindowSize( + imagePointWidth: imagePointWidth, + imagePointHeight: imagePointHeight, + maxWidth: screenWidth * 0.9, + maxHeight: screenHeight * 0.85 + ) + + let newWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: windowSize.width, height: windowSize.height), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + + newWindow.contentView = hostingView + newWindow.title = String(localized: "bilingualResult.window.title") + newWindow.center() + newWindow.isReleasedWhenClosed = false + newWindow.delegate = self + newWindow.minSize = NSSize(width: 400, height: 300) + newWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + self.window = newWindow + newWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + func showResult(image: CGImage, scaleFactor: CGFloat, translatedText: String? = nil) { + viewModel?.showResult(image: image, displayScaleFactor: scaleFactor, translatedText: translatedText) + + if let window = window { + let imagePointWidth = CGFloat(image.width) / scaleFactor + let imagePointHeight = CGFloat(image.height) / scaleFactor + let screenWidth = NSScreen.main?.frame.width ?? 1920 + let screenHeight = NSScreen.main?.frame.height ?? 1080 + + let windowSize = calculateWindowSize( + imagePointWidth: imagePointWidth, + imagePointHeight: imagePointHeight, + maxWidth: screenWidth * 0.9, + maxHeight: screenHeight * 0.85 + ) + + let newFrame = NSRect( + x: window.frame.origin.x, + y: window.frame.origin.y + window.frame.height - windowSize.height, + width: windowSize.width, + height: windowSize.height + ) + window.setFrame(newFrame, display: true, animate: true) + } + } + + func showError(_ message: String) { + viewModel?.showError(message) + } + + func show(image: CGImage, scaleFactor: CGFloat) { + if let existingWindow = window, existingWindow.isVisible { + viewModel?.updateImage(image, displayScaleFactor: scaleFactor) + existingWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let newViewModel = BilingualResultViewModel(image: image, displayScaleFactor: scaleFactor) + self.viewModel = newViewModel + + let contentView = BilingualResultView(viewModel: newViewModel) + let hostingView = NSHostingView(rootView: contentView) + + let imagePointWidth = CGFloat(image.width) / scaleFactor + let imagePointHeight = CGFloat(image.height) / scaleFactor + + let windowSize = calculateWindowSize( + imagePointWidth: imagePointWidth, + imagePointHeight: imagePointHeight, + maxWidth: 1200, + maxHeight: 800 + ) + + let newWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: windowSize.width, height: windowSize.height), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + + newWindow.contentView = hostingView + newWindow.title = String(localized: "bilingualResult.window.title") + newWindow.center() + newWindow.isReleasedWhenClosed = false + newWindow.delegate = self + newWindow.minSize = NSSize(width: 400, height: 300) + newWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + self.window = newWindow + newWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + func close() { + window?.close() + } +} + +extension BilingualResultWindowController: NSWindowDelegate { + func windowWillClose(_ notification: Notification) { + window = nil + viewModel = nil + } +} diff --git a/ScreenCapture/Features/Capture/CaptureManager.swift b/ScreenTranslate/Features/Capture/CaptureManager.swift similarity index 59% rename from ScreenCapture/Features/Capture/CaptureManager.swift rename to ScreenTranslate/Features/Capture/CaptureManager.swift index 64bd7b3..f093b85 100644 --- a/ScreenCapture/Features/Capture/CaptureManager.swift +++ b/ScreenTranslate/Features/Capture/CaptureManager.swift @@ -20,7 +20,7 @@ actor CaptureManager { // MARK: - Performance Logging private static let performanceLog = OSLog( - subsystem: Bundle.main.bundleIdentifier ?? "ScreenCapture", + subsystem: Bundle.main.bundleIdentifier ?? "ScreenTranslate", category: .pointsOfInterest ) @@ -43,43 +43,35 @@ actor CaptureManager { // MARK: - Permission Handling /// Checks if the app has screen recording permission. - /// - Returns: True if permission is granted + /// Uses SCShareableContent to actually verify permission works (not just cached status). + /// - Returns: True if permission is granted and functional var hasPermission: Bool { get async { - await screenDetector.hasPermission - } - } - - /// Requests screen recording permission by triggering the system prompt. - /// Note: ScreenCaptureKit automatically prompts for permission on first capture attempt. - /// - Returns: True if permission is now granted - func requestPermission() async -> Bool { - // Attempt a capture to trigger the permission prompt - do { - let displays = try await screenDetector.availableDisplays() - guard let display = displays.first else { return false } - - // Create a minimal capture configuration just to trigger the prompt - guard let scContent = try? await SCShareableContent.current, - let scDisplay = scContent.displays.first(where: { $0.displayID == display.id }) else { + // Quick check first + guard CGPreflightScreenCaptureAccess() else { return false } + // Actually verify by trying to get shareable content + do { + _ = try await SCShareableContent.current + return true + } catch { + return false + } + } + } - let filter = SCContentFilter(display: scDisplay, excludingWindows: []) - let config = SCStreamConfiguration() - config.width = 1 - config.height = 1 - - // This will trigger the permission prompt if not already granted - _ = try? await SCScreenshotManager.captureImage( - contentFilter: filter, - configuration: config - ) + /// Synchronous permission check using only CGPreflightScreenCaptureAccess. + /// Use only when async check is not possible. + var hasPermissionSync: Bool { + CGPreflightScreenCaptureAccess() + } - return await hasPermission - } catch { - return false - } + /// Requests screen recording permission. + /// Uses CGRequestScreenCaptureAccess() which may open System Settings. + /// - Returns: True if permission is now granted + func requestPermission() -> Bool { + CGRequestScreenCaptureAccess() } // MARK: - Full Screen Capture @@ -87,35 +79,25 @@ actor CaptureManager { /// Captures the full screen of the specified display. /// - Parameter display: The display to capture /// - Returns: Screenshot containing the captured image and metadata - /// - Throws: ScreenCaptureError if capture fails + /// - Throws: ScreenTranslateError if capture fails func captureFullScreen(display: DisplayInfo) async throws -> Screenshot { // Prevent concurrent captures guard !isCapturing else { - throw ScreenCaptureError.captureError(message: "Capture already in progress") + throw ScreenTranslateError.captureError(message: "Capture already in progress") } isCapturing = true defer { isCapturing = false } - // Check permission + // Check permission using async method guard await hasPermission else { - throw ScreenCaptureError.permissionDenied + throw ScreenTranslateError.permissionDenied } // Invalidate cache to get fresh display list await screenDetector.invalidateCache() // Get the SCDisplay for this display - let scContent: SCShareableContent - do { - scContent = try await SCShareableContent.current - } catch { - throw ScreenCaptureError.captureFailure(underlying: error) - } - - guard let scDisplay = scContent.displays.first(where: { $0.displayID == display.id }) else { - // Display was disconnected - throw ScreenCaptureError.displayDisconnected(displayName: display.name) - } + let scDisplay = try await getSCDisplay(for: display) // Configure capture let filter = SCContentFilter(display: scDisplay, excludingWindows: []) @@ -133,15 +115,13 @@ actor CaptureManager { ) } catch { os_signpost(.end, log: Self.performanceLog, name: "FullScreenCapture", signpostID: Self.signpostID) - throw ScreenCaptureError.captureFailure(underlying: error) + throw ScreenTranslateError.captureFailure(underlying: error) } let captureLatency = (CFAbsoluteTimeGetCurrent() - captureStartTime) * 1000 os_signpost(.end, log: Self.performanceLog, name: "FullScreenCapture", signpostID: Self.signpostID) - #if DEBUG - print("Capture latency: \(String(format: "%.1f", captureLatency))ms") - #endif + Logger.capture.info("Capture latency: \(String(format: "%.1f", captureLatency))ms") // Create screenshot with metadata let screenshot = Screenshot( @@ -155,7 +135,7 @@ actor CaptureManager { /// Captures the full screen of the primary display. /// - Returns: Screenshot containing the captured image and metadata - /// - Throws: ScreenCaptureError if capture fails + /// - Throws: ScreenTranslateError if capture fails func captureFullScreen() async throws -> Screenshot { let display = try await screenDetector.primaryDisplay() return try await captureFullScreen(display: display) @@ -168,71 +148,29 @@ actor CaptureManager { /// - rect: The region to capture in display coordinates /// - display: The display to capture from /// - Returns: Screenshot containing the captured region and metadata - /// - Throws: ScreenCaptureError if capture fails + /// - Throws: ScreenTranslateError if capture fails func captureRegion(_ rect: CGRect, from display: DisplayInfo) async throws -> Screenshot { // Prevent concurrent captures guard !isCapturing else { - throw ScreenCaptureError.captureError(message: "Capture already in progress") + throw ScreenTranslateError.captureError(message: "Capture already in progress") } isCapturing = true defer { isCapturing = false } - // Check permission + // Check permission using async method guard await hasPermission else { - throw ScreenCaptureError.permissionDenied + throw ScreenTranslateError.permissionDenied } // Invalidate cache to get fresh display list await screenDetector.invalidateCache() // Get the SCDisplay for this display - let scContent: SCShareableContent - do { - scContent = try await SCShareableContent.current - } catch { - throw ScreenCaptureError.captureFailure(underlying: error) - } + let scDisplay = try await getSCDisplay(for: display) - guard let scDisplay = scContent.displays.first(where: { $0.displayID == display.id }) else { - // Display was disconnected - throw ScreenCaptureError.displayDisconnected(displayName: display.name) - } - - // Configure capture for the full display first + // Configure capture let filter = SCContentFilter(display: scDisplay, excludingWindows: []) - let config = createCaptureConfiguration(for: display) - - // Set source rect for region capture - // sourceRect must be in PIXEL coordinates (not normalized!) - // The rect is in points from SelectionOverlayWindow, convert to pixels - // IMPORTANT: Round to integers to avoid fractional pixel boundaries - // which cause ScreenCaptureKit to apply anti-aliasing/interpolation - let pixelX = round(rect.origin.x * display.scaleFactor) - let pixelY = round(rect.origin.y * display.scaleFactor) - let pixelWidth = round(rect.width * display.scaleFactor) - let pixelHeight = round(rect.height * display.scaleFactor) - - let sourceRect = CGRect( - x: pixelX, - y: pixelY, - width: pixelWidth, - height: pixelHeight - ) - - #if DEBUG - print("=== CAPTURE MANAGER DEBUG ===") - print("[CAP-1] Input rect (points): \(rect)") - print("[CAP-2] display.frame (points): \(display.frame)") - print("[CAP-3] display.scaleFactor: \(display.scaleFactor)") - print("[CAP-4] sourceRect (pixels, rounded): \(sourceRect)") - print("=== END CAPTURE MANAGER DEBUG ===") - #endif - - config.sourceRect = sourceRect - - // Adjust output size to match the region (use same rounded values) - config.width = Int(pixelWidth) - config.height = Int(pixelHeight) + let config = createRegionCaptureConfiguration(for: rect, display: display) // Perform capture with signpost for profiling os_signpost(.begin, log: Self.performanceLog, name: "RegionCapture", signpostID: Self.signpostID) @@ -246,38 +184,34 @@ actor CaptureManager { ) } catch { os_signpost(.end, log: Self.performanceLog, name: "RegionCapture", signpostID: Self.signpostID) - throw ScreenCaptureError.captureFailure(underlying: error) + throw ScreenTranslateError.captureFailure(underlying: error) } let captureLatency = (CFAbsoluteTimeGetCurrent() - captureStartTime) * 1000 os_signpost(.end, log: Self.performanceLog, name: "RegionCapture", signpostID: Self.signpostID) - #if DEBUG - print("Region capture latency: \(String(format: "%.1f", captureLatency))ms") - #endif + Logger.capture.info("Region capture latency: \(String(format: "%.1f", captureLatency))ms") // Create screenshot with metadata - let screenshot = Screenshot( + return Screenshot( image: cgImage, captureDate: Date(), sourceDisplay: display ) - - return screenshot } // MARK: - Display Enumeration /// Returns all available displays for capture. /// - Returns: Array of DisplayInfo for all connected displays - /// - Throws: ScreenCaptureError if enumeration fails + /// - Throws: ScreenTranslateError if enumeration fails func availableDisplays() async throws -> [DisplayInfo] { try await screenDetector.availableDisplays() } /// Returns the primary display. /// - Returns: DisplayInfo for the main display - /// - Throws: ScreenCaptureError if no primary display found + /// - Throws: ScreenTranslateError if no primary display found func primaryDisplay() async throws -> DisplayInfo { try await screenDetector.primaryDisplay() } @@ -304,4 +238,69 @@ actor CaptureManager { return config } + + /// Retrieves the SCDisplay corresponding to the given DisplayInfo. + /// - Parameter display: The display to find + /// - Returns: The matching SCDisplay + /// - Throws: ScreenTranslateError if display not found or content retrieval fails + private func getSCDisplay(for display: DisplayInfo) async throws -> SCDisplay { + let scContent: SCShareableContent + do { + scContent = try await SCShareableContent.current + } catch { + throw ScreenTranslateError.captureFailure(underlying: error) + } + + guard let scDisplay = scContent.displays.first(where: { $0.displayID == display.id }) else { + throw ScreenTranslateError.displayDisconnected(displayName: display.name) + } + return scDisplay + } + + /// Creates a capture configuration for a specific region. + /// - Parameters: + /// - rect: The region to capture in points + /// - display: The display to capture from + /// - Returns: Configured SCStreamConfiguration + private func createRegionCaptureConfiguration(for rect: CGRect, display: DisplayInfo) -> SCStreamConfiguration { + let config = SCStreamConfiguration() + + // sourceRect is in POINTS (same coordinate system as display.frame) + let clampedX = min(max(rect.origin.x, 0), display.frame.width - 1) + let clampedY = min(max(rect.origin.y, 0), display.frame.height - 1) + let clampedWidth = min(rect.width, display.frame.width - clampedX) + let clampedHeight = min(rect.height, display.frame.height - clampedY) + + let sourceRect = CGRect( + x: clampedX, + y: clampedY, + width: clampedWidth, + height: clampedHeight + ) + + config.sourceRect = sourceRect + + // Output size should be in PIXELS for crisp capture + let outputWidth = Int(clampedWidth * display.scaleFactor) + let outputHeight = Int(clampedHeight * display.scaleFactor) + config.width = outputWidth + config.height = outputHeight + + // High quality settings + config.minimumFrameInterval = CMTime(value: 1, timescale: 1) + config.pixelFormat = kCVPixelFormatType_32BGRA + config.showsCursor = false + config.colorSpaceName = CGColorSpace.sRGB + + // Debug logging + Logger.capture.debug("=== CAPTURE MANAGER DEBUG ===") + Logger.capture.debug("[CAP-1] Input rect (points): \(String(describing: rect))") + Logger.capture.debug("[CAP-2] display.frame (points): \(String(describing: display.frame))") + Logger.capture.debug("[CAP-3] display.scaleFactor: \(display.scaleFactor)") + Logger.capture.debug("[CAP-4] sourceRect (points, clamped): \(String(describing: sourceRect))") + Logger.capture.debug("[CAP-5] outputSize (pixels): \(outputWidth)x\(outputHeight)") + Logger.capture.debug("=== END CAPTURE MANAGER DEBUG ===") + + return config + } } diff --git a/ScreenCapture/Features/Capture/DisplaySelector.swift b/ScreenTranslate/Features/Capture/DisplaySelector.swift similarity index 90% rename from ScreenCapture/Features/Capture/DisplaySelector.swift rename to ScreenTranslate/Features/Capture/DisplaySelector.swift index 06f37c1..0cb8e9c 100644 --- a/ScreenCapture/Features/Capture/DisplaySelector.swift +++ b/ScreenTranslate/Features/Capture/DisplaySelector.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import os /// Manages display selection UI when multiple displays are connected. /// Provides a popup menu for the user to select which display to capture. @@ -134,12 +135,8 @@ final class DisplaySelector: NSObject, NSMenuDelegate { selectionMenu = menu - // Show menu at mouse location on the main screen - if let screen = NSScreen.main { - let mouseLocation = NSEvent.mouseLocation - // Convert to screen coordinates for the menu - menu.popUp(positioning: nil, at: mouseLocation, in: nil) - } + let mouseLocation = NSEvent.mouseLocation + menu.popUp(positioning: nil, at: mouseLocation, in: nil) } /// Called when a display item is clicked @@ -147,18 +144,14 @@ final class DisplaySelector: NSObject, NSMenuDelegate { let index = sender.tag if index >= 0 && index < currentDisplays.count { selectedDisplay = currentDisplays[index] - #if DEBUG - print("Display item clicked: \(selectedDisplay?.name ?? "nil")") - #endif + Logger.ui.debug("Display item clicked: \(self.selectedDisplay?.name ?? "nil")") } } /// Called when cancel item is clicked @objc func cancelItemClicked(_ sender: NSMenuItem) { selectedDisplay = nil - #if DEBUG - print("Cancel item clicked") - #endif + Logger.ui.debug("Cancel item clicked") } // MARK: - NSMenuDelegate @@ -169,9 +162,7 @@ final class DisplaySelector: NSObject, NSMenuDelegate { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - #if DEBUG - print("Menu did close (delayed), selectedDisplay: \(self.selectedDisplay?.name ?? "nil")") - #endif + Logger.ui.debug("Menu did close (delayed), selectedDisplay: \(self.selectedDisplay?.name ?? "nil")") // Complete the selection based on what was selected if let display = self.selectedDisplay { diff --git a/ScreenCapture/Features/Capture/ScreenDetector.swift b/ScreenTranslate/Features/Capture/ScreenDetector.swift similarity index 67% rename from ScreenCapture/Features/Capture/ScreenDetector.swift rename to ScreenTranslate/Features/Capture/ScreenDetector.swift index ac63b2f..42f4623 100644 --- a/ScreenCapture/Features/Capture/ScreenDetector.swift +++ b/ScreenTranslate/Features/Capture/ScreenDetector.swift @@ -1,10 +1,12 @@ import Foundation @preconcurrency import ScreenCaptureKit import AppKit +import os /// Service responsible for enumerating connected displays using ScreenCaptureKit. /// Thread-safe actor that provides display discovery and matching with NSScreen. actor ScreenDetector { + private let logger = Logger.capture // MARK: - Types /// Error types specific to screen detection @@ -36,6 +38,9 @@ actor ScreenDetector { /// Cache validity duration (5 seconds) private let cacheValidityDuration: TimeInterval = 5.0 + /// Cached permission status - only check once to avoid repeated dialogs + private var cachedPermissionStatus: Bool? + // MARK: - Initialization private init() {} @@ -45,7 +50,7 @@ actor ScreenDetector { /// Returns all available displays for capture. /// Uses ScreenCaptureKit's SCShareableContent to enumerate displays. /// - Returns: Array of DisplayInfo for all connected displays - /// - Throws: ScreenCaptureError if enumeration fails or no displays found + /// - Throws: ScreenTranslateError if enumeration fails or no displays found func availableDisplays() async throws -> [DisplayInfo] { // Check cache validity if let lastTime = lastEnumerationTime, @@ -59,13 +64,13 @@ actor ScreenDetector { do { content = try await SCShareableContent.current } catch { - throw ScreenCaptureError.captureFailure(underlying: error) + throw ScreenTranslateError.captureFailure(underlying: error) } let scDisplays = content.displays guard !scDisplays.isEmpty else { - throw ScreenCaptureError.captureError(message: "No displays available") + throw ScreenTranslateError.captureError(message: "No displays available") } // Map SCDisplay to DisplayInfo with NSScreen matching @@ -85,12 +90,12 @@ actor ScreenDetector { /// Returns the primary (main) display. /// - Returns: DisplayInfo for the main display - /// - Throws: ScreenCaptureError if no primary display found + /// - Throws: ScreenTranslateError if no primary display found func primaryDisplay() async throws -> DisplayInfo { let displays = try await availableDisplays() guard let primary = displays.first(where: { $0.isPrimary }) ?? displays.first else { - throw ScreenCaptureError.captureError(message: "No primary display available") + throw ScreenTranslateError.captureError(message: "No primary display available") } return primary @@ -107,28 +112,56 @@ actor ScreenDetector { /// Returns the display with the specified ID. /// - Parameter displayID: The CGDirectDisplayID to find /// - Returns: DisplayInfo for the specified display - /// - Throws: ScreenCaptureError.displayNotFound if not found + /// - Throws: ScreenTranslateError.displayNotFound if not found func display(withID displayID: CGDirectDisplayID) async throws -> DisplayInfo { let displays = try await availableDisplays() guard let display = displays.first(where: { $0.id == displayID }) else { - throw ScreenCaptureError.displayNotFound(displayID) + throw ScreenTranslateError.displayNotFound(displayID) } return display } /// Checks if the app has screen recording permission. + /// Uses SCShareableContent to actually verify permission works (not just cached status). + /// - Parameter silent: If true, suppresses logging (default: true) /// - Returns: True if permission is granted - var hasPermission: Bool { - get async { - do { - // Attempt to get shareable content - this will fail if no permission - _ = try await SCShareableContent.current - return true - } catch { - return false - } + func hasPermission(silent: Bool = true) async -> Bool { + // Quick check first using CGPreflightScreenCaptureAccess + guard CGPreflightScreenCaptureAccess() else { + cachedPermissionStatus = false + if !silent { logger.debug("Permission check: denied (CGPreflight)") } + return false + } + // Actually verify by trying to get shareable content + do { + _ = try await SCShareableContent.current + cachedPermissionStatus = true + if !silent { logger.debug("Permission check: granted") } + return true + } catch { + cachedPermissionStatus = false + if !silent { logger.debug("Permission check: denied (SCShareableContent)") } + return false + } + } + + /// Forces a fresh permission check (clears cache) + func refreshPermissionStatus() async -> Bool { + cachedPermissionStatus = nil + return await hasPermission() + } + + /// Triggers the system permission dialog for screen recording. + /// Returns true if screen content is currently accessible (does not guarantee user granted permission). + func requestPermission() async -> Bool { + do { + // This triggers the system permission dialog + _ = try await SCShareableContent.current + return true + } catch { + return false } } @@ -146,7 +179,10 @@ actor ScreenDetector { @MainActor private static func findMatchingScreen(for scDisplay: SCDisplay) -> NSScreen? { NSScreen.screens.first { screen in - guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else { + let deviceDescription = screen.deviceDescription + let screenNumberKey = NSDeviceDescriptionKey("NSScreenNumber") + + guard let screenNumber = deviceDescription[screenNumberKey] as? CGDirectDisplayID else { return false } return screenNumber == scDisplay.displayID diff --git a/ScreenTranslate/Features/Capture/SelectionOverlayWindow.swift b/ScreenTranslate/Features/Capture/SelectionOverlayWindow.swift new file mode 100644 index 0000000..1ac5e5c --- /dev/null +++ b/ScreenTranslate/Features/Capture/SelectionOverlayWindow.swift @@ -0,0 +1,859 @@ +import AppKit +import CoreGraphics +import os + +// MARK: - SelectionOverlayDelegate + +/// Delegate protocol for selection overlay events. +@MainActor +protocol SelectionOverlayDelegate: AnyObject { + /// Called when user completes a selection. + /// - Parameters: + /// - rect: The selected rectangle in screen coordinates + /// - display: The display containing the selection + func selectionOverlay(didSelectRect rect: CGRect, on display: DisplayInfo) + + /// Called when user cancels the selection. + func selectionOverlayDidCancel() +} + +// MARK: - SelectionOverlayWindow + +/// NSPanel subclass for displaying the selection overlay. +/// Provides a full-screen transparent overlay with crosshair cursor, +/// dim effect, and selection rectangle drawing. +final class SelectionOverlayWindow: NSPanel { + // MARK: - Properties + + /// The screen this overlay covers + let targetScreen: NSScreen + + /// The display info for this screen + let displayInfo: DisplayInfo + + /// The content view handling drawing and interaction + private var overlayView: SelectionOverlayView? + + // MARK: - Initialization + + /// Creates a new selection overlay window for the specified screen. + /// - Parameters: + /// - screen: The NSScreen to overlay + /// - displayInfo: The DisplayInfo for the screen + @MainActor + init(screen: NSScreen, displayInfo: DisplayInfo) { + self.targetScreen = screen + self.displayInfo = displayInfo + + super.init( + contentRect: screen.frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + configureWindow() + setupOverlayView() + } + + // MARK: - Configuration + + @MainActor + private func configureWindow() { + // Window properties for full-screen overlay + level = .screenSaver // Above most windows but below alerts + isOpaque = false + backgroundColor = .clear + ignoresMouseEvents = false + hasShadow = false + + // Don't hide on deactivation + hidesOnDeactivate = false + + // Behavior + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] + isMovable = false + isMovableByWindowBackground = false + + // Accept mouse events + acceptsMouseMovedEvents = true + } + + @MainActor + private func setupOverlayView() { + let view = SelectionOverlayView(frame: targetScreen.frame) + view.autoresizingMask = [.width, .height] + self.contentView = view + self.overlayView = view + } + + // MARK: - Public API + + /// Sets the delegate for selection events + @MainActor + func setDelegate(_ delegate: SelectionOverlayDelegate) { + overlayView?.delegate = delegate + overlayView?.displayInfo = displayInfo + } + + /// Updates the current mouse position for crosshair drawing + @MainActor + func updateMousePosition(_ point: NSPoint) { + overlayView?.mousePosition = point + overlayView?.needsDisplay = true + } + + /// Updates the selection state (start point and current point) + @MainActor + func updateSelection(start: NSPoint?, current: NSPoint?) { + overlayView?.selectionStart = start + overlayView?.selectionCurrent = current + overlayView?.needsDisplay = true + } + + /// Shows the overlay window + @MainActor + func showOverlay() { + makeKeyAndOrderFront(nil) + } + + /// Hides and closes the overlay window + @MainActor + func hideOverlay() { + orderOut(nil) + close() + } + + // MARK: - NSWindow Overrides + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + + // Make the window accept first responder + override var acceptsFirstResponder: Bool { true } +} + +// MARK: - SelectionOverlayView + +/// Custom NSView for drawing the selection overlay. +/// Handles crosshair cursor, dim overlay, and selection rectangle. +final class SelectionOverlayView: NSView { + // MARK: - Properties + + /// Delegate for selection events + weak var delegate: SelectionOverlayDelegate? + + /// Display info for coordinate conversion + var displayInfo: DisplayInfo? + + /// Current mouse position (in window coordinates) + var mousePosition: NSPoint? + + /// Selection start point (in window coordinates) + var selectionStart: NSPoint? + + /// Current selection end point (in window coordinates) + var selectionCurrent: NSPoint? + + /// Currently highlighted window rect (in view coordinates, nil if no window under cursor) + private var highlightedWindowRect: CGRect? { + didSet { + // Only trigger display update when rect actually changes + if oldValue != highlightedWindowRect { + needsDisplay = true + } + } + } + + /// Window detector for detecting windows under cursor + private let windowDetector = WindowDetector.shared + + /// Cached window list for current interaction (refreshed on mouseDown) + private var cachedWindows: [WindowInfo] = [] + + /// Whether the user is currently dragging + private var isDragging = false + + /// Last mouse moved timestamp for throttling + private var lastMouseMovedTime: TimeInterval = 0 + + /// Throttle interval for window detection (16ms ≈ 60fps) + private let windowDetectionThrottleInterval: TimeInterval = 0.016 + + /// Minimum window size to highlight (10x10 pixels) + private let minimumWindowSize: CGFloat = 10 + + /// Dim overlay color + private let dimColor = NSColor.black.withAlphaComponent(0.3) + + /// Selection rectangle stroke color + private let selectionStrokeColor = NSColor.white + + /// Selection rectangle fill color + private let selectionFillColor = NSColor.white.withAlphaComponent(0.1) + + /// Dimensions label background color + private let labelBackgroundColor = NSColor.black.withAlphaComponent(0.75) + + /// Dimensions label text color + private let labelTextColor = NSColor.white + + /// Crosshair line color + private let crosshairColor = NSColor.white.withAlphaComponent(0.8) + + /// Window highlight fill color (#46E7F0 with 15% alpha - brighter for better visibility) + private let windowHighlightFillColor = NSColor(red: 0.275, green: 0.906, blue: 0.941, alpha: 0.15) + + /// Window highlight stroke color (#46E7F0 with 90% alpha - much brighter and more visible) + private let windowHighlightStrokeColor = NSColor(red: 0.275, green: 0.906, blue: 0.941, alpha: 0.9) + + /// Window highlight stroke width (thicker for better visibility) + private let windowHighlightStrokeWidth: CGFloat = 4.0 + + /// Drag threshold for distinguishing click from drag (in points) + private let dragThreshold: CGFloat = 4.0 + + /// Mouse down position for click/drag detection + private var mouseDownPoint: NSPoint? + + /// Tracking area for mouse moved events + private var trackingArea: NSTrackingArea? + + // MARK: - Initialization + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupTrackingArea() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupTrackingArea() { + let options: NSTrackingArea.Options = [ + .activeAlways, + .mouseMoved, + .mouseEnteredAndExited, + .inVisibleRect + ] + + trackingArea = NSTrackingArea( + rect: bounds, + options: options, + owner: self, + userInfo: nil + ) + if let area = trackingArea { + addTrackingArea(area) + } + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + + if let existing = trackingArea { + removeTrackingArea(existing) + } + + setupTrackingArea() + } + + // MARK: - Drawing + + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext else { return } + + // Draw dim overlay (with cutout for selection or highlighted window) + drawDimOverlay(context: context) + + // If we have a selection, cut it out and draw the rectangle + if let start = selectionStart, let current = selectionCurrent { + let selectionRect = normalizedRect(from: start, to: current) + drawSelectionRect(selectionRect, context: context) + drawDimensionsLabel(for: selectionRect, context: context) + } else { + // Draw window highlight if there's a highlighted window + if let highlightRect = highlightedWindowRect { + drawWindowHighlight(highlightRect, context: context) + drawDimensionsLabel(for: highlightRect, context: context) + } + + // Draw crosshair at mouse position + if let mousePos = mousePosition { + drawCrosshair(at: mousePos, context: context) + } + } + } + + /// Draws the semi-transparent dim overlay + /// When there's a selection or highlighted window, creates a cutout using even-odd fill rule + private func drawDimOverlay(context: CGContext) { + let hasSelection = selectionStart != nil && selectionCurrent != nil + let hasHighlightedWindow = highlightedWindowRect != nil && !isDragging + + guard hasSelection || hasHighlightedWindow else { + // Full dim when not selecting and no highlighted window + dimColor.setFill() + bounds.fill() + return + } + + context.saveGState() + + // Create path for the entire view + context.addRect(bounds) + + // Add cutout for selection if present + if let start = selectionStart, let current = selectionCurrent { + let selectionRect = normalizedRect(from: start, to: current) + context.addRect(selectionRect) + } + + // Add cutout for highlighted window if present (and not dragging) + if !isDragging, let highlightRect = highlightedWindowRect { + context.addRect(highlightRect) + } + + // Use even-odd rule to create the cutout + context.setFillColor(dimColor.cgColor) + context.fillPath(using: .evenOdd) + + context.restoreGState() + } + + /// Draws the selection rectangle with border + private func drawSelectionRect(_ rect: CGRect, context: CGContext) { + // Fill + selectionFillColor.setFill() + rect.fill() + + // Stroke + let strokePath = NSBezierPath(rect: rect) + strokePath.lineWidth = 1.5 + selectionStrokeColor.setStroke() + strokePath.stroke() + + // Draw dashed inner border + context.saveGState() + context.setLineDash(phase: 0, lengths: [4, 4]) + context.setStrokeColor(NSColor.black.withAlphaComponent(0.5).cgColor) + context.setLineWidth(1.0) + context.addRect(rect.insetBy(dx: 1, dy: 1)) + context.strokePath() + context.restoreGState() + } + + /// Draws the crosshair cursor at the specified position + private func drawCrosshair(at point: NSPoint, context: CGContext) { + context.saveGState() + context.setStrokeColor(crosshairColor.cgColor) + context.setLineWidth(1.0) + + // Horizontal line + context.move(to: CGPoint(x: 0, y: point.y)) + context.addLine(to: CGPoint(x: bounds.width, y: point.y)) + + // Vertical line + context.move(to: CGPoint(x: point.x, y: 0)) + context.addLine(to: CGPoint(x: point.x, y: bounds.height)) + + context.strokePath() + context.restoreGState() + } + + /// Draws the window highlight rectangle with border + private func drawWindowHighlight(_ rect: CGRect, context: CGContext) { + // Fill + windowHighlightFillColor.setFill() + rect.fill() + + // Stroke + let strokePath = NSBezierPath(rect: rect) + strokePath.lineWidth = windowHighlightStrokeWidth + windowHighlightStrokeColor.setStroke() + strokePath.stroke() + } + + /// Draws the dimensions label near the selection rectangle + private func drawDimensionsLabel(for rect: CGRect, context: CGContext) { + // Get dimensions in pixels (accounting for scale factor) + let scaleFactor = displayInfo?.scaleFactor ?? 1.0 + let pixelWidth = Int(rect.width * scaleFactor) + let pixelHeight = Int(rect.height * scaleFactor) + + let dimensionsText = "\(pixelWidth) × \(pixelHeight)" + + // Text attributes - use fallback font if system font unavailable + let font = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium) ?? NSFont.systemFont(ofSize: 12) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: labelTextColor + ] + + let textSize = (dimensionsText as NSString).size(withAttributes: attributes) + let labelPadding: CGFloat = 6 + let labelSize = CGSize( + width: textSize.width + labelPadding * 2, + height: textSize.height + labelPadding * 2 + ) + + // Position the label below and to the right of the selection + var labelOrigin = CGPoint( + x: rect.maxX - labelSize.width, + y: rect.minY - labelSize.height - 8 + ) + + // Ensure label stays within screen bounds + if labelOrigin.x < 0 { + labelOrigin.x = rect.minX + } + if labelOrigin.y < 0 { + labelOrigin.y = rect.maxY + 8 + } + if labelOrigin.x + labelSize.width > bounds.width { + labelOrigin.x = bounds.width - labelSize.width + } + + let labelRect = CGRect(origin: labelOrigin, size: labelSize) + + // Draw background + let backgroundPath = NSBezierPath(roundedRect: labelRect, xRadius: 4, yRadius: 4) + labelBackgroundColor.setFill() + backgroundPath.fill() + + // Draw text + let textPoint = CGPoint( + x: labelRect.origin.x + labelPadding, + y: labelRect.origin.y + labelPadding + ) + (dimensionsText as NSString).draw(at: textPoint, withAttributes: attributes) + } + + /// Creates a normalized rectangle from two points (handles any drag direction) + private func normalizedRect(from start: NSPoint, to end: NSPoint) -> CGRect { + let minX = min(start.x, end.x) + let minY = min(start.y, end.y) + let width = abs(end.x - start.x) + let height = abs(end.y - start.y) + return CGRect(x: minX, y: minY, width: width, height: height) + } + + // MARK: - Mouse Events + + override func mouseDown(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + mouseDownPoint = point + + // Refresh window cache when starting interaction to get fresh window list + Task { + await windowDetector.invalidateCache() + let windows = await windowDetector.visibleWindows() + await MainActor.run { + self.cachedWindows = windows + } + } + + // Don't start selection yet - wait to determine if it's a click or drag + needsDisplay = true + } + + override func mouseDragged(with event: NSEvent) { + guard let mouseDownPoint = mouseDownPoint else { return } + + let point = convert(event.locationInWindow, from: nil) + + // Calculate distance from mouse down point + let distance = hypot(point.x - mouseDownPoint.x, point.y - mouseDownPoint.y) + + // If we haven't started dragging yet and moved beyond threshold, enter drag mode + if !isDragging && distance > dragThreshold { + isDragging = true + selectionStart = mouseDownPoint + selectionCurrent = point + + // Clear highlighted window when entering drag mode + highlightedWindowRect = nil + } else if isDragging { + selectionCurrent = point + } + + needsDisplay = true + } + + override func mouseUp(with event: NSEvent) { + guard mouseDownPoint != nil else { return } + + if isDragging { + // === DRAG MODE === + guard let start = selectionStart, let current = selectionCurrent else { + resetStateAndCancel() + return + } + + isDragging = false + + // Calculate final selection rectangle + let selectionRect = normalizedRect(from: start, to: current) + + // Only accept selection if it has meaningful size + if selectionRect.width >= 10 && selectionRect.height >= 10 { + // Convert to screen coordinates + guard let window = self.window, + let displayInfo = displayInfo else { + resetStateAndCancel() + return + } + + Logger.capture.debug("=== SELECTION COORDINATE DEBUG ===") + Logger.capture.debug("[1] selectionRect (view coords): \(String(describing: selectionRect))") + Logger.capture.debug("[2] window.frame: \(String(describing: window.frame))") + Logger.capture.debug("[3] window.screen?.frame: \(String(describing: window.screen?.frame))") + + // The selectionRect is in view coordinates, convert to screen coordinates + // screenRect is in Cocoa coordinates (Y=0 at bottom of primary screen) + let screenRect = window.convertToScreen(selectionRect) + + Logger.capture.debug("[4] screenRect (after convertToScreen): \(String(describing: screenRect))") + let firstScreenFrame = NSScreen.screens.first?.frame + Logger.capture.debug("[5] NSScreen.screens.first?.frame: \(String(describing: firstScreenFrame))") + + // Get the screen height for coordinate conversion + // Use the window's screen, not necessarily the primary screen + // Cocoa uses Y=0 at bottom, ScreenCaptureKit/Quartz uses Y=0 at top + let screenHeight = window.screen?.frame.height ?? NSScreen.screens.first?.frame.height ?? 0 + + Logger.capture.debug("[6] screenHeight for conversion: \(screenHeight)") + + // Convert from Cocoa coordinates (Y=0 at bottom) to Quartz coordinates (Y=0 at top) + let quartzY = screenHeight - screenRect.origin.y - screenRect.height + + Logger.capture.debug("[7] quartzY (converted): \(quartzY)") + + // displayFrame is in Quartz coordinates (from SCDisplay) + let displayFrame = displayInfo.frame + + Logger.capture.debug("[8] displayInfo.frame (SCDisplay): \(String(describing: displayFrame))") + Logger.capture.debug("[9] displayInfo.isPrimary: \(displayInfo.isPrimary)") + + // Now compute display-relative coordinates (both in Quartz coordinate system) + // Round to whole points to minimize fractional pixel issues when scaled + let relativeRect = CGRect( + x: round(screenRect.origin.x - displayFrame.origin.x), + y: round(quartzY - displayFrame.origin.y), + width: round(selectionRect.width), + height: round(selectionRect.height) + ) + + Logger.capture.debug("[10] FINAL relativeRect (rounded): \(String(describing: relativeRect))") + let normX = relativeRect.origin.x / displayFrame.width + let normY = relativeRect.origin.y / displayFrame.height + Logger.capture.debug("[11] Normalized would be: x=\(normX), y=\(normY)") + Logger.capture.debug("=== END COORDINATE DEBUG ===") + + resetState() + delegate?.selectionOverlay(didSelectRect: relativeRect, on: displayInfo) + } else { + // Too small - cancel + resetStateAndCancel() + } + } else { + // === CLICK MODE === + if let highlightRect = highlightedWindowRect { + // Click on a highlighted window - use window rect + guard let window = self.window, + let displayInfo = displayInfo else { + resetStateAndCancel() + return + } + + Logger.capture.debug("=== CLICK MODE - WINDOW SELECTION ===") + Logger.capture.debug("[1] highlightRect (view coords): \(String(describing: highlightRect))") + + // Convert highlight rect to screen coordinates + let screenRect = window.convertToScreen(highlightRect) + + Logger.capture.debug("[2] screenRect (after convertToScreen): \(String(describing: screenRect))") + + // Get screen height for coordinate conversion + let screenHeight = window.screen?.frame.height ?? NSScreen.screens.first?.frame.height ?? 0 + Logger.capture.debug("[3] screenHeight for conversion: \(screenHeight)") + + // Convert from Cocoa to Quartz coordinates + let quartzY = screenHeight - screenRect.origin.y - screenRect.height + + Logger.capture.debug("[4] quartzY (converted): \(quartzY)") + + let displayFrame = displayInfo.frame + Logger.capture.debug("[5] displayInfo.frame (SCDisplay): \(String(describing: displayFrame))") + + // Compute display-relative coordinates + let relativeRect = CGRect( + x: round(screenRect.origin.x - displayFrame.origin.x), + y: round(quartzY - displayFrame.origin.y), + width: round(highlightRect.width), + height: round(highlightRect.height) + ) + + Logger.capture.debug("[6] FINAL relativeRect (rounded): \(String(describing: relativeRect))") + Logger.capture.debug("=== END CLICK MODE ===") + + resetState() + delegate?.selectionOverlay(didSelectRect: relativeRect, on: displayInfo) + } else { + // Click on empty area - cancel + resetStateAndCancel() + } + } + } + + /// Resets all state variables + private func resetState() { + mouseDownPoint = nil + selectionStart = nil + selectionCurrent = nil + isDragging = false + highlightedWindowRect = nil + cachedWindows = [] + lastMouseMovedTime = 0 + needsDisplay = true + } + + /// Resets state and notifies delegate of cancellation + private func resetStateAndCancel() { + resetState() + delegate?.selectionOverlayDidCancel() + } + + override func mouseMoved(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + mousePosition = point + + // Only detect windows when not dragging + if !isDragging { + // Throttle window detection to ~60fps (16ms) + let currentTime = Date.timeIntervalSinceReferenceDate + if currentTime - lastMouseMovedTime >= windowDetectionThrottleInterval { + lastMouseMovedTime = currentTime + updateHighlightedWindow(at: point) + } + } + + // Always update crosshair position (not throttled) + needsDisplay = true + } + + /// Updates the highlighted window based on the current mouse position. + /// Detects the window under the cursor and converts its frame to view coordinates. + /// - Parameter point: The current mouse position in view coordinates + private func updateHighlightedWindow(at point: NSPoint) { + guard let window = self.window else { + highlightedWindowRect = nil + return + } + + // Convert point from view coordinates to screen coordinates (Cocoa) + let screenPoint = window.convertToScreen( + NSRect(origin: point, size: .zero) + ).origin + + // Convert from Cocoa coordinates (origin at bottom-left) to Quartz coordinates (origin at top-left) + let screenHeight = window.screen?.frame.height ?? NSScreen.main?.frame.height ?? 0 + let quartzPoint = WindowDetector.cocoaToQuartz(screenPoint, screenHeight: screenHeight) + + // Find window under point using WindowDetector (synchronous call) + // WindowDetector has its own internal cache for performance + Task { + if let windowInfo = await windowDetector.windowUnderPoint(quartzPoint) { + // Skip windows smaller than minimum size + guard windowInfo.frame.width >= minimumWindowSize && + windowInfo.frame.height >= minimumWindowSize else { + await MainActor.run { + highlightedWindowRect = nil + } + return + } + + // Convert window frame from Quartz to Cocoa coordinates + let cocoaFrame = WindowDetector.quartzToCocoa(windowInfo.frame, screenHeight: screenHeight) + + // Convert from screen coordinates to view coordinates + var viewFrame = self.convertFromScreen(cocoaFrame) + + // Clip the highlight rect to the visible screen bounds + viewFrame = viewFrame.intersection(self.bounds) + + // Only set if the clipped rect is still valid + await MainActor.run { + if !viewFrame.isEmpty { + highlightedWindowRect = viewFrame + } else { + highlightedWindowRect = nil + } + } + } else { + await MainActor.run { + highlightedWindowRect = nil + } + } + } + } + + /// Converts a rectangle from screen coordinates to view coordinates. + /// - Parameter screenRect: Rectangle in screen coordinates (Cocoa) + /// - Returns: Rectangle in view coordinates + private func convertFromScreen(_ screenRect: CGRect) -> CGRect { + guard let window = self.window else { + return screenRect + } + + // Get the window's frame in screen coordinates + let windowFrame = window.frame + + // View coordinates are relative to the window's content view + // The view's origin (0,0) is at the bottom-left of the window in Cocoa coordinates + let viewX = screenRect.origin.x - windowFrame.origin.x + let viewY = screenRect.origin.y - windowFrame.origin.y + + return CGRect( + x: viewX, + y: viewY, + width: screenRect.width, + height: screenRect.height + ) + } + + override func mouseEntered(with event: NSEvent) { + // Change cursor to crosshair + NSCursor.crosshair.set() + } + + override func mouseExited(with event: NSEvent) { + // Reset cursor + NSCursor.arrow.set() + mousePosition = nil + needsDisplay = true + } + + // MARK: - Keyboard Events + + override var acceptsFirstResponder: Bool { true } + + override func keyDown(with event: NSEvent) { + // Escape key cancels selection and closes overlay + if event.keyCode == 53 { // Escape + // Reset all state including window highlight + resetStateAndCancel() + return + } + + super.keyDown(with: event) + } + + // MARK: - Cursor + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .crosshair) + } +} + +// MARK: - SelectionOverlayController + +/// Controller for managing selection overlay windows across all displays. +/// Creates and coordinates overlay windows for multi-display spanning selection. +@MainActor +final class SelectionOverlayController { + // MARK: - Properties + + /// Shared instance + static let shared = SelectionOverlayController() + + /// All active overlay windows (one per display) + private var overlayWindows: [SelectionOverlayWindow] = [] + + /// Delegate for selection events + weak var delegate: SelectionOverlayDelegate? + + /// Callback for when selection completes + var onSelectionComplete: ((CGRect, DisplayInfo) -> Void)? + + /// Callback for when selection is cancelled + var onSelectionCancel: (() -> Void)? + + // MARK: - Initialization + + private init() {} + + // MARK: - Public API + + /// Presents selection overlay on all connected displays. + func presentOverlay() async throws { + // Get all available displays + let displays = try await ScreenDetector.shared.availableDisplays() + + // Get matching screens + let screens = NSScreen.screens + + // Create overlay window for each display + for display in displays { + guard let screen = screens.first(where: { screen in + guard let screenNumber = screen.deviceDescription[ + NSDeviceDescriptionKey("NSScreenNumber") + ] as? CGDirectDisplayID else { + return false + } + return screenNumber == display.id + }) else { + continue + } + + let overlayWindow = SelectionOverlayWindow(screen: screen, displayInfo: display) + overlayWindow.setDelegate(self) + overlayWindows.append(overlayWindow) + } + + // Show all overlay windows + for window in overlayWindows { + window.showOverlay() + } + + // Make the first window (primary display) key + if let primaryWindow = overlayWindows.first { + primaryWindow.makeKey() + NSApp.activate(ignoringOtherApps: true) + } + } + + /// Dismisses all overlay windows. + func dismissOverlay() { + for window in overlayWindows { + window.hideOverlay() + } + overlayWindows.removeAll() + + // Reset cursor + NSCursor.arrow.set() + } +} + +// MARK: - SelectionOverlayController + SelectionOverlayDelegate + +extension SelectionOverlayController: SelectionOverlayDelegate { + func selectionOverlay(didSelectRect rect: CGRect, on display: DisplayInfo) { + // Dismiss all overlays first + dismissOverlay() + + // Notify via callback + onSelectionComplete?(rect, display) + } + + func selectionOverlayDidCancel() { + // Dismiss all overlays + dismissOverlay() + + // Notify via callback + onSelectionCancel?() + } +} diff --git a/ScreenTranslate/Features/History/HistoryView.swift b/ScreenTranslate/Features/History/HistoryView.swift new file mode 100644 index 0000000..47308b0 --- /dev/null +++ b/ScreenTranslate/Features/History/HistoryView.swift @@ -0,0 +1,335 @@ +import SwiftUI +import AppKit + +/// Main view for browsing and managing translation history. +struct HistoryView: View { + @ObservedObject var store: HistoryStore + + /// Scroll position for detecting load more + @Namespace private var scrollNamespace + + var body: some View { + VStack(spacing: 0) { + // Search bar + SearchBar(store: store) + + Divider() + + // History list + if store.filteredEntries.isEmpty { + EmptyStateView(store: store) + } else { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + // Load more trigger at top + if store.hasMoreEntries && store.searchQuery.isEmpty { + LoadMoreTrigger() + .onAppear { + store.loadMore() + } + } + + // History entries + ForEach(store.filteredEntries) { entry in + HistoryEntryRow(entry: entry, store: store) + } + } + } + } + } + .frame(minWidth: 500, minHeight: 400) + } +} + +// MARK: - Search Bar + +/// Search bar for filtering history entries. +private struct SearchBar: View { + @ObservedObject var store: HistoryStore + @FocusState private var isFocused: Bool + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + + TextField(String(localized: "history.search.placeholder"), text: Binding( + get: { store.searchQuery }, + set: { store.search($0) } + )) + .focused($isFocused) + .textFieldStyle(.plain) + .onExitCommand { + isFocused = false + } + + if !store.searchQuery.isEmpty { + Button { + store.search("") + isFocused = true + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + + Spacer() + + // Clear all button + if !store.entries.isEmpty { + Button { + showClearConfirmation() + } label: { + Image(systemName: "trash") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help(String(localized: "history.clear.all")) + } + } + .padding(12) + .background(Color(nsColor: .controlBackgroundColor)) + } + + private func showClearConfirmation() { + let alert = NSAlert() + alert.messageText = NSLocalizedString( + "history.clear.alert.title", + comment: "Clear History" + ) + alert.informativeText = NSLocalizedString( + "history.clear.alert.message", + comment: "Are you sure you want to clear all translation history?" + ) + alert.alertStyle = .warning + alert.addButton(withTitle: NSLocalizedString("button.clear", comment: "Clear")) + alert.addButton(withTitle: NSLocalizedString("button.cancel", comment: "Cancel")) + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + store.clear() + } + } +} + +// MARK: - Empty State + +/// View shown when no history entries exist. +private struct EmptyStateView: View { + @ObservedObject var store: HistoryStore + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + + if store.searchQuery.isEmpty { + Text("history.empty.title") + .font(.headline) + .foregroundStyle(.secondary) + + Text("history.empty.message") + .font(.body) + .foregroundStyle(.tertiary) + } else { + Text("history.no.results.title") + .font(.headline) + .foregroundStyle(.secondary) + + Text("history.no.results.message") + .font(.body) + .foregroundStyle(.tertiary) + + Button { + store.search("") + } label: { + Text("history.clear.search") + } + .buttonStyle(.borderedProminent) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Load More Trigger + +/// Invisible view that triggers loading more entries when visible. +private struct LoadMoreTrigger: View { + var body: some View { + Color.clear + .frame(height: 1) + } +} + +// MARK: - History Entry Row + +/// Row displaying a single history entry with source and translated text. +/// Layout adapts based on text shape: wide text → vertical (top/bottom), tall text → horizontal (side-by-side). +private struct HistoryEntryRow: View { + let entry: TranslationHistory + @ObservedObject var store: HistoryStore + + /// Determines if the text content is "wide" (few lines, long characters per line). + /// Wide content uses vertical layout (top/bottom), tall content uses horizontal layout (side-by-side). + private var isWideContent: Bool { + let text = entry.sourceText + let lines = text.components(separatedBy: .newlines) + let lineCount = lines.count + let maxLineLength = lines.map(\.count).max() ?? 0 + // Wide: few lines with long content, or single line + return lineCount <= 3 || maxLineLength > 40 + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + // Header with languages and timestamp + HStack { + Text(entry.description) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Text(entry.formattedTimestamp) + .font(.caption2) + .foregroundStyle(.tertiary) + } + + if isWideContent { + verticalLayout + } else { + horizontalLayout + } + } + .padding(12) + .background(Color(nsColor: .controlBackgroundColor)) + .contextMenu { + EntryContextMenu(entry: entry, store: store) + } + .help(entry.fullDateString) + } + + /// Vertical layout: source on top, translation below (for wide/short text) + private var verticalLayout: some View { + VStack(alignment: .leading, spacing: 0) { + TextSection( + text: entry.sourceText, + label: String(localized: "history.source") + ) + + HStack(spacing: 4) { + Rectangle() + .fill(.secondary.opacity(0.3)) + .frame(width: 20, height: 1) + Image(systemName: "arrow.down") + .font(.caption2) + .foregroundStyle(.secondary) + Rectangle() + .fill(.secondary.opacity(0.3)) + .frame(width: 20, height: 1) + } + .padding(.vertical, 4) + + TextSection( + text: entry.translatedText, + label: String(localized: "history.translation") + ) + } + } + + /// Horizontal layout: source on left, translation on right (for tall/narrow text) + private var horizontalLayout: some View { + HStack(alignment: .top, spacing: 0) { + TextSection( + text: entry.sourceText, + label: String(localized: "history.source") + ) + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(spacing: 4) { + Rectangle() + .fill(.secondary.opacity(0.3)) + .frame(width: 1, height: 20) + Image(systemName: "arrow.right") + .font(.caption2) + .foregroundStyle(.secondary) + Rectangle() + .fill(.secondary.opacity(0.3)) + .frame(width: 1, height: 20) + } + .padding(.horizontal, 8) + + TextSection( + text: entry.translatedText, + label: String(localized: "history.translation") + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +// MARK: - Text Section + +/// Displays a section of text. +private struct TextSection: View { + let text: String + let label: String + + var body: some View { + Text(text) + .font(.system(.body, design: .rounded)) + .foregroundStyle(.primary) + .textSelection(.enabled) + .accessibilityLabel(label) + } +} + +// MARK: - Entry Context Menu + +/// Context menu for history entries. +private struct EntryContextMenu: View { + let entry: TranslationHistory + @ObservedObject var store: HistoryStore + + var body: some View { + Group { + Button { + store.copyTranslation(entry) + } label: { + Label(String(localized: "history.copy.translation"), systemImage: "doc.on.doc") + } + + Button { + store.copySource(entry) + } label: { + Label(String(localized: "history.copy.source"), systemImage: "doc.on.doc") + } + + Button { + store.copyBoth(entry) + } label: { + Label(String(localized: "history.copy.both"), systemImage: "doc.on.clipboard") + } + + Divider() + + Button(role: .destructive) { + store.remove(entry) + } label: { + Label(String(localized: "history.delete"), systemImage: "trash") + } + } + } +} + +// MARK: - Preview + +#if DEBUG +#Preview { + HistoryView(store: HistoryStore()) + .frame(width: 700, height: 500) +} +#endif diff --git a/ScreenTranslate/Features/History/HistoryWindowController.swift b/ScreenTranslate/Features/History/HistoryWindowController.swift new file mode 100644 index 0000000..a8f4e4a --- /dev/null +++ b/ScreenTranslate/Features/History/HistoryWindowController.swift @@ -0,0 +1,99 @@ +import AppKit +import SwiftUI + +/// Controller for presenting and managing the translation history window. +/// Uses a singleton pattern to ensure only one history window is open at a time. +@MainActor +final class HistoryWindowController: NSObject { + // MARK: - Singleton + + /// Shared instance + static let shared = HistoryWindowController() + + // MARK: - Properties + + /// The history window + private var window: NSWindow? + + /// The history store + private let store: HistoryStore + + // MARK: - Initialization + + private override init() { + self.store = HistoryStore() + super.init() + } + + // MARK: - Public API + + /// Presents the history window. + /// If already open, brings it to front. + func showHistory() { + // If window already exists, bring it to front + if let window = window, window.isVisible { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + // Create the SwiftUI view + let historyView = HistoryView(store: store) + + // Create the hosting view + let hostingView = NSHostingView(rootView: historyView) + hostingView.translatesAutoresizingMaskIntoConstraints = false + + // Create the window + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 500), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = NSLocalizedString("history.title", comment: "Translation History") + window.contentView = hostingView + window.center() + window.isReleasedWhenClosed = false + window.delegate = self + + // Set window behavior + window.level = .floating + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + // Set minimum size + window.minSize = NSSize(width: 500, height: 400) + + // Store reference + self.window = window + + // Show the window + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + /// Closes the history window if open. + func closeHistory() { + window?.close() + window = nil + } + + /// Adds a translation result to the history. + /// - Parameters: + /// - result: The translation result to save + /// - image: Optional screenshot image for thumbnail generation + func addTranslation(result: TranslationResult, image: CGImage? = nil) { + store.add(result: result, image: image) + } +} + +// MARK: - NSWindowDelegate + +extension HistoryWindowController: NSWindowDelegate { + nonisolated func windowWillClose(_ notification: Notification) { + Task { @MainActor in + // Clear reference + window = nil + } + } +} diff --git a/ScreenTranslate/Features/MenuBar/MenuBarController.swift b/ScreenTranslate/Features/MenuBar/MenuBarController.swift new file mode 100644 index 0000000..b40325e --- /dev/null +++ b/ScreenTranslate/Features/MenuBar/MenuBarController.swift @@ -0,0 +1,191 @@ +import AppKit + +/// Manages the menu bar status item and its menu. +/// Responsible for setting up the menu bar icon and building the app menu. +@MainActor +final class MenuBarController { + // MARK: - Properties + + /// The status item displayed in the menu bar + private var statusItem: NSStatusItem? + + /// Reference to the app delegate for action routing + private weak var appDelegate: AppDelegate? + + // MARK: - Initialization + + init(appDelegate: AppDelegate) { + self.appDelegate = appDelegate + + NotificationCenter.default.addObserver( + forName: LanguageManager.languageDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.rebuildMenu() + } + } + + NotificationCenter.default.addObserver( + forName: AppSettings.shortcutDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.rebuildMenu() + } + } + } + + // MARK: - Setup + + /// Sets up the menu bar status item with icon and menu + func setup() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + + if let button = statusItem?.button { + button.image = NSImage(systemSymbolName: "camera.viewfinder", accessibilityDescription: "ScreenTranslate") + button.image?.isTemplate = true + } + + statusItem?.menu = buildMenu() + } + + /// Removes the status item from the menu bar + func teardown() { + if let item = statusItem { + NSStatusBar.system.removeStatusItem(item) + statusItem = nil + } + } + + /// Rebuilds the menu when language changes + func rebuildMenu() { + statusItem?.menu = buildMenu() + } + + // MARK: - Menu Construction + + /// Builds the complete menu for the status item + private func buildMenu() -> NSMenu { + let menu = NSMenu() + let settings = AppSettings.shared + + // Capture Full Screen + menu.addItem(createMenuItem( + titleKey: "menu.capture.full.screen", + comment: "Capture Full Screen", + action: #selector(AppDelegate.captureFullScreen), + shortcut: settings.fullScreenShortcut, + target: appDelegate, + imageName: "camera.fill" + )) + + // Capture Selection + menu.addItem(createMenuItem( + titleKey: "menu.capture.selection", + comment: "Capture Selection", + action: #selector(AppDelegate.captureSelection), + shortcut: settings.selectionShortcut, + target: appDelegate, + imageName: "crop" + )) + + // Translation Mode + menu.addItem(createMenuItem( + titleKey: "menu.translation.mode", + comment: "Translation Mode", + action: #selector(AppDelegate.startTranslationMode), + shortcut: settings.translationModeShortcut, + target: appDelegate, + imageName: "character" + )) + + menu.addItem(NSMenuItem.separator()) + + // Translation History + menu.addItem(createMenuItem( + titleKey: "menu.translation.history", + comment: "Translation History", + action: #selector(AppDelegate.openHistory), + keyEquivalent: "h", + target: appDelegate, + imageName: "clock.arrow.circlepath" + )) + + menu.addItem(NSMenuItem.separator()) + + // Settings + menu.addItem(createMenuItem( + titleKey: "menu.settings", + comment: "Settings...", + action: #selector(AppDelegate.openSettings), + keyEquivalent: ",", + modifierMask: [.command], + target: appDelegate, + imageName: "gearshape" + )) + + // About + menu.addItem(createMenuItem( + titleKey: "menu.about", + comment: "About ScreenTranslate", + action: #selector(AppDelegate.openAbout), + target: appDelegate, + imageName: "info.circle" + )) + + menu.addItem(NSMenuItem.separator()) + + // Quit + menu.addItem(createMenuItem( + titleKey: "menu.quit", + comment: "Quit ScreenTranslate", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q", + modifierMask: [.command], + imageName: "power" + )) + + return menu + } + + /// Creates a localized menu item with common properties + private func createMenuItem( + titleKey: String, + comment: String, + action: Selector?, + shortcut: KeyboardShortcut? = nil, + keyEquivalent: String = "", + modifierMask: NSEvent.ModifierFlags = [], + target: AnyObject? = nil, + imageName: String? = nil + ) -> NSMenuItem { + let finalKeyEquivalent: String + let finalModifierMask: NSEvent.ModifierFlags + + if let shortcut = shortcut { + finalKeyEquivalent = shortcut.mainKey.lowercased() + finalModifierMask = shortcut.nsModifierFlags + } else { + finalKeyEquivalent = keyEquivalent + finalModifierMask = modifierMask + } + + let item = NSMenuItem( + title: NSLocalizedString(titleKey, tableName: "Localizable", bundle: .main, comment: comment), + action: action, + keyEquivalent: finalKeyEquivalent + ) + item.keyEquivalentModifierMask = finalModifierMask + item.target = target + + if let imageName = imageName, + let image = NSImage(systemSymbolName: imageName, accessibilityDescription: nil) { + item.image = image + } + + return item + } +} diff --git a/ScreenTranslate/Features/Onboarding/OnboardingCompleteStepView.swift b/ScreenTranslate/Features/Onboarding/OnboardingCompleteStepView.swift new file mode 100644 index 0000000..22a3178 --- /dev/null +++ b/ScreenTranslate/Features/Onboarding/OnboardingCompleteStepView.swift @@ -0,0 +1,53 @@ +import SwiftUI + +struct OnboardingCompleteStepView: View { + let onStart: () -> Void + + var body: some View { + VStack(spacing: 24) { + Spacer() + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 60)) + .foregroundStyle(.green) + + VStack(spacing: 12) { + Text(NSLocalizedString("onboarding.complete.title", comment: "")) + .font(.largeTitle) + .fontWeight(.semibold) + + Text(NSLocalizedString("onboarding.complete.message", comment: "")) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + VStack(alignment: .leading, spacing: 12) { + OnboardingInfoRow( + icon: "command", + text: NSLocalizedString("onboarding.complete.shortcuts", comment: "") + ) + OnboardingInfoRow( + icon: "rectangle.and.hand.point.up.and.hand.point.down", + text: NSLocalizedString("onboarding.complete.selection", comment: "") + ) + OnboardingInfoRow( + icon: "gear", + text: NSLocalizedString("onboarding.complete.settings", comment: "") + ) + } + .padding(.vertical, 8) + + Spacer() + + Button { + onStart() + } label: { + Text(NSLocalizedString("onboarding.complete.start", comment: "")) + .frame(minWidth: 150) + } + .buttonStyle(.borderedProminent) + } + .padding(32) + } +} diff --git a/ScreenTranslate/Features/Onboarding/OnboardingComponents.swift b/ScreenTranslate/Features/Onboarding/OnboardingComponents.swift new file mode 100644 index 0000000..b98eb79 --- /dev/null +++ b/ScreenTranslate/Features/Onboarding/OnboardingComponents.swift @@ -0,0 +1,121 @@ +import SwiftUI + +struct OnboardingFeatureRow: View { + let icon: String + let title: String + let description: String + + var body: some View { + HStack(spacing: 16) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(.blue) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + } + } +} + +struct OnboardingInfoRow: View { + let icon: String + let text: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon) + .foregroundStyle(.blue) + .frame(width: 24) + Text(text) + .font(.body) + .foregroundStyle(.secondary) + Spacer() + } + } +} + +struct OnboardingNavigationButtons: View { + let canGoPrevious: Bool + let canGoNext: Bool + let isLastStep: Bool + let onPrevious: () -> Void + let onNext: () -> Void + + var body: some View { + HStack(spacing: 16) { + if canGoPrevious { + Button { + onPrevious() + } label: { + Text(NSLocalizedString("onboarding.back", comment: "")) + } + .buttonStyle(.bordered) + } + + Spacer() + + if canGoNext && !isLastStep { + Button { + onNext() + } label: { + Text(NSLocalizedString("onboarding.continue", comment: "")) + } + .buttonStyle(.borderedProminent) + } + } + } +} + +struct OnboardingPermissionRow: View { + let icon: String + let title: String + let isGranted: Bool + let requestAction: () -> Void + let openSettingsAction: () -> Void + + var body: some View { + HStack(spacing: 16) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(isGranted ? .green : .orange) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + Text(isGranted + ? NSLocalizedString("onboarding.permission.granted", comment: "") + : NSLocalizedString("onboarding.permission.not.granted", comment: "")) + .font(.caption) + .foregroundStyle(isGranted ? .green : .secondary) + } + + Spacer() + + if isGranted { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.title2) + } else { + Button { + openSettingsAction() + requestAction() + } label: { + Text(NSLocalizedString("onboarding.permission.grant", comment: "")) + } + .buttonStyle(.borderedProminent) + } + } + .padding() + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(.rect(cornerRadius: 8)) + } +} diff --git a/ScreenTranslate/Features/Onboarding/OnboardingConfigurationStepView.swift b/ScreenTranslate/Features/Onboarding/OnboardingConfigurationStepView.swift new file mode 100644 index 0000000..0e95199 --- /dev/null +++ b/ScreenTranslate/Features/Onboarding/OnboardingConfigurationStepView.swift @@ -0,0 +1,199 @@ +import SwiftUI + +struct OnboardingConfigurationStepView: View { + @Bindable var viewModel: OnboardingViewModel + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 24) { + Image(systemName: "gearshape.2.fill") + .font(.system(size: 50)) + .foregroundStyle(.blue) + .padding(.top, 16) + + VStack(spacing: 12) { + Text(NSLocalizedString("onboarding.configuration.title", comment: "")) + .font(.largeTitle) + .fontWeight(.semibold) + + Text(NSLocalizedString("onboarding.configuration.message", comment: "")) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + VStack(alignment: .leading, spacing: 16) { + OnboardingPaddleOCRSection(viewModel: viewModel) + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text(NSLocalizedString("onboarding.configuration.mtran", comment: "")) + .font(.headline) + Text(NSLocalizedString("onboarding.configuration.mtran.hint", comment: "")) + .font(.caption) + .foregroundStyle(.secondary) + TextField( + NSLocalizedString("onboarding.configuration.placeholder.address", comment: ""), + text: $viewModel.mtranServerURL + ) + .textFieldStyle(.roundedBorder) + } + + VStack(alignment: .leading, spacing: 8) { + Text(NSLocalizedString("onboarding.configuration.test", comment: "")) + .font(.headline) + + if let result = viewModel.translationTestResult { + let imageName = viewModel.translationTestSuccess ? "checkmark.circle.fill" : "xmark.circle.fill" + HStack(spacing: 8) { + Image(systemName: imageName) + .foregroundStyle(viewModel.translationTestSuccess ? .green : .red) + Text(result) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Button { + Task { + await viewModel.testTranslation() + } + } label: { + if viewModel.isTestingTranslation { + Text(NSLocalizedString("onboarding.configuration.testing", comment: "")) + } else { + Text(NSLocalizedString("onboarding.configuration.test.button", comment: "")) + } + } + .buttonStyle(.bordered) + .disabled(viewModel.isTestingTranslation) + } + } + .frame(maxWidth: 400) + } + .padding(32) + } + + Divider() + + HStack(spacing: 16) { + Button { + viewModel.skipConfiguration() + } label: { + Text(NSLocalizedString("onboarding.skip", comment: "")) + .fontWeight(.medium) + } + .buttonStyle(.borderedProminent) + .tint(.secondary) + + Spacer() + + Button { + viewModel.goToNextStep() + } label: { + Text(NSLocalizedString("onboarding.complete", comment: "")) + .fontWeight(.semibold) + } + .buttonStyle(.borderedProminent) + } + .padding(16) + .background(Color(nsColor: .windowBackgroundColor)) + } + } +} + +struct OnboardingPaddleOCRSection: View { + @Bindable var viewModel: OnboardingViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(NSLocalizedString("onboarding.paddleocr.title", comment: "")) + .font(.headline) + + Spacer() + + if viewModel.isPaddleOCRInstalled { + HStack(spacing: 4) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text(NSLocalizedString("onboarding.paddleocr.installed", comment: "")) + .font(.caption) + .foregroundStyle(.green) + } + } else { + HStack(spacing: 4) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + Text(NSLocalizedString("onboarding.paddleocr.not.installed", comment: "")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + Text(NSLocalizedString("onboarding.paddleocr.description", comment: "")) + .font(.caption) + .foregroundStyle(.secondary) + + if !viewModel.isPaddleOCRInstalled { + VStack(alignment: .leading, spacing: 8) { + Text(NSLocalizedString("onboarding.paddleocr.install.hint", comment: "")) + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + Button { + viewModel.installPaddleOCR() + } label: { + if viewModel.isInstallingPaddleOCR { + ProgressView() + .controlSize(.small) + .frame(width: 16, height: 16) + Text(NSLocalizedString("onboarding.paddleocr.installing", comment: "")) + } else { + Image(systemName: "arrow.down.circle") + Text(NSLocalizedString("onboarding.paddleocr.install", comment: "")) + } + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.isInstallingPaddleOCR) + + Button { + viewModel.copyInstallCommand() + } label: { + Image(systemName: "doc.on.doc") + Text(NSLocalizedString("onboarding.paddleocr.copy.command", comment: "")) + } + .buttonStyle(.bordered) + + Button { + viewModel.refreshPaddleOCRStatus() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help(NSLocalizedString("onboarding.paddleocr.refresh", comment: "")) + } + + if let error = viewModel.paddleOCRInstallError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + } else { + if let version = viewModel.paddleOCRVersion { + Text(String(format: NSLocalizedString("onboarding.paddleocr.version", comment: ""), version)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .padding() + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(.rect(cornerRadius: 8)) + } +} diff --git a/ScreenTranslate/Features/Onboarding/OnboardingPermissionsStepView.swift b/ScreenTranslate/Features/Onboarding/OnboardingPermissionsStepView.swift new file mode 100644 index 0000000..7a31886 --- /dev/null +++ b/ScreenTranslate/Features/Onboarding/OnboardingPermissionsStepView.swift @@ -0,0 +1,69 @@ +import SwiftUI + +struct OnboardingPermissionsStepView: View { + let hasScreenRecordingPermission: Bool + let hasAccessibilityPermission: Bool + let canGoPrevious: Bool + let canGoNext: Bool + let isLastStep: Bool + let onRequestScreenRecording: () -> Void + let onOpenScreenRecordingSettings: () -> Void + let onRequestAccessibility: () -> Void + let onOpenAccessibilitySettings: () -> Void + let onPrevious: () -> Void + let onNext: () -> Void + + var body: some View { + VStack(spacing: 24) { + Spacer() + + Image(systemName: "lock.shield.fill") + .font(.system(size: 50)) + .foregroundStyle(.orange) + + VStack(spacing: 12) { + Text(NSLocalizedString("onboarding.permissions.title", comment: "")) + .font(.largeTitle) + .fontWeight(.semibold) + + Text(NSLocalizedString("onboarding.permissions.message", comment: "")) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + VStack(spacing: 16) { + OnboardingPermissionRow( + icon: "video.fill", + title: NSLocalizedString("onboarding.permission.screen.recording", comment: ""), + isGranted: hasScreenRecordingPermission, + requestAction: onRequestScreenRecording, + openSettingsAction: onOpenScreenRecordingSettings + ) + + OnboardingPermissionRow( + icon: "command.square.fill", + title: NSLocalizedString("onboarding.permission.accessibility", comment: ""), + isGranted: hasAccessibilityPermission, + requestAction: onRequestAccessibility, + openSettingsAction: onOpenAccessibilitySettings + ) + } + + Spacer() + + Text(NSLocalizedString("onboarding.permissions.hint", comment: "")) + .font(.caption) + .foregroundStyle(.secondary) + + OnboardingNavigationButtons( + canGoPrevious: canGoPrevious, + canGoNext: canGoNext, + isLastStep: isLastStep, + onPrevious: onPrevious, + onNext: onNext + ) + } + .padding(32) + } +} diff --git a/ScreenTranslate/Features/Onboarding/OnboardingView.swift b/ScreenTranslate/Features/Onboarding/OnboardingView.swift new file mode 100644 index 0000000..dbea3d7 --- /dev/null +++ b/ScreenTranslate/Features/Onboarding/OnboardingView.swift @@ -0,0 +1,81 @@ +import SwiftUI + +struct OnboardingView: View { + @Environment(\.dismiss) private var dismiss + @State private var viewModel: OnboardingViewModel + + init(viewModel: OnboardingViewModel) { + self._viewModel = State(initialValue: viewModel) + } + + var body: some View { + VStack(spacing: 0) { + progressIndicator + + Divider() + + Group { + switch viewModel.currentStep { + case 0: + OnboardingWelcomeStepView( + onContinue: { viewModel.goToNextStep() }, + canGoNext: viewModel.canGoNext + ) + case 1: + OnboardingPermissionsStepView( + hasScreenRecordingPermission: viewModel.hasScreenRecordingPermission, + hasAccessibilityPermission: viewModel.hasAccessibilityPermission, + canGoPrevious: viewModel.canGoPrevious, + canGoNext: viewModel.canGoNext, + isLastStep: viewModel.isLastStep, + onRequestScreenRecording: { viewModel.requestScreenRecordingPermission() }, + onOpenScreenRecordingSettings: { viewModel.openScreenRecordingSettings() }, + onRequestAccessibility: { viewModel.requestAccessibilityPermission() }, + onOpenAccessibilitySettings: { viewModel.openAccessibilitySettings() }, + onPrevious: { viewModel.goToPreviousStep() }, + onNext: { viewModel.goToNextStep() } + ) + case 2: + OnboardingConfigurationStepView(viewModel: viewModel) + case 3: + OnboardingCompleteStepView(onStart: { viewModel.goToNextStep() }) + default: + OnboardingWelcomeStepView( + onContinue: { viewModel.goToNextStep() }, + canGoNext: viewModel.canGoNext + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(width: 600, height: 620) + .onReceive(NotificationCenter.default.publisher(for: .onboardingCompleted)) { _ in + dismiss() + } + } + + private var progressIndicator: some View { + HStack(spacing: 8) { + ForEach(0..? + + // MARK: - Computed Properties + + /// Whether we can move to the next step + var canGoNext: Bool { + switch currentStep { + case 0: + // Welcome step - always can proceed + return true + case 1: + // Permissions step - need both permissions + return hasScreenRecordingPermission && hasAccessibilityPermission + case 2: + // Configuration step - optional, always can proceed + return true + case 3: + // Complete step - can finish + return true + default: + return false + } + } + + /// Whether we can move to the previous step + var canGoPrevious: Bool { + currentStep > 0 + } + + /// Whether this is the last step + var isLastStep: Bool { + currentStep == totalSteps - 1 + } + + // MARK: - Initialization + + init(settings: AppSettings = .shared) { + self.settings = settings + Task { + await MainActor.run { + // Only check accessibility permission on init (no system dialog) + hasAccessibilityPermission = AccessibilityPermissionChecker.hasPermission + // Don't auto-check screen recording - it may trigger system dialog + refreshPaddleOCRStatus() + } + } + } + + // MARK: - Actions + + /// Moves to the next step if validation passes + func goToNextStep() { + guard canGoNext else { return } + guard currentStep < totalSteps - 1 else { + // Complete onboarding + completeOnboarding() + return + } + currentStep += 1 + // Check permissions when entering the permissions step + if currentStep == 1 { + checkPermissions() + } + } + + /// Moves to the previous step + func goToPreviousStep() { + guard canGoPrevious else { return } + currentStep -= 1 + // Check permissions when entering the permissions step + if currentStep == 1 { + checkPermissions() + } + } + + /// Checks all permission statuses + func checkPermissions() { + hasAccessibilityPermission = AccessibilityPermissionChecker.hasPermission + + // Check screen recording permission using async method + Task { + hasScreenRecordingPermission = await checkScreenRecordingPermission() + } + } + + /// Checks screen recording permission using ScreenCaptureKit for reliable detection + private func checkScreenRecordingPermission() async -> Bool { + // First do a quick check with CGPreflightScreenCaptureAccess + if !CGPreflightScreenCaptureAccess() { + return false + } + + // Verify by actually trying to get shareable content + do { + _ = try await SCShareableContent.current + return true + } catch { + return false + } + } + + /// Requests screen recording permission + func requestScreenRecordingPermission() { + // First check if already granted + if CGPreflightScreenCaptureAccess() { + hasScreenRecordingPermission = true + return + } + + // Request permission - CGRequestScreenCaptureAccess() returns true if granted + let granted = CGRequestScreenCaptureAccess() + if granted { + hasScreenRecordingPermission = true + return + } + + // Trigger ScreenCaptureKit API to register app in permission list + Task { + do { + // This will trigger the system to register the app in Screen Recording permissions + _ = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + } catch { + // Expected when permission not granted - app is now registered + } + + // Open System Settings after triggering the API + // Class is @MainActor so no explicit MainActor.run needed + openScreenRecordingSettings() + } + + // Start polling for permission status + startPermissionCheck(for: .screenRecording) + } + + /// Opens System Settings for screen recording permission + func openScreenRecordingSettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { + NSWorkspace.shared.open(url) + } + } + + /// Requests accessibility permission - triggers system dialog only + func requestAccessibilityPermission() { + // Check current status first + if AccessibilityPermissionChecker.hasPermission { + hasAccessibilityPermission = true + return + } + + // Request accessibility - triggers system dialog (will guide user to settings if needed) + _ = AccessibilityPermissionChecker.requestPermission() + // Start checking for permission + startPermissionCheck(for: .accessibility) + } + + /// Opens System Settings for accessibility permission + func openAccessibilitySettings() { + AccessibilityPermissionChecker.openAccessibilitySettings() + } + + /// Starts checking for permission status periodically + private func startPermissionCheck(for type: PermissionType) { + // Cancel any existing permission check task + permissionCheckTask?.cancel() + + permissionCheckTask = Task { + for _ in 0..<60 { + do { + try await Task.sleep(for: .milliseconds(500)) + } catch { + // Task was cancelled + return + } + + switch type { + case .screenRecording: + // Use async ScreenCaptureKit check for reliable detection + let granted = await checkScreenRecordingPermission() + if granted { + hasScreenRecordingPermission = true + permissionCheckTask = nil + return + } + + case .accessibility: + let granted = AccessibilityPermissionChecker.hasPermission + if granted { + hasAccessibilityPermission = granted + permissionCheckTask = nil + return + } + } + } + } + } + + func testTranslation() async { + isTestingTranslation = true + translationTestResult = nil + translationTestSuccess = false + + let testText = "Hello" + + do { + if let (host, port) = parseServerURL(mtranServerURL), !host.isEmpty { + let originalHost = settings.mtranServerHost + let originalPort = settings.mtranServerPort + settings.mtranServerHost = host + settings.mtranServerPort = port + + let result = try await MTranServerEngine.shared.translate(testText, to: "zh") + + settings.mtranServerHost = originalHost + settings.mtranServerPort = originalPort + + translationTestResult = String( + format: NSLocalizedString("onboarding.test.success", comment: ""), + testText, + result.translatedText + ) + translationTestSuccess = true + } else { + let config = TranslationEngine.Configuration( + sourceLanguage: nil, + targetLanguage: TranslationLanguage.chineseSimplified, + timeout: 10.0, + autoDetectSourceLanguage: true + ) + let result = try await TranslationEngine.shared.translate(testText, config: config) + + translationTestResult = String( + format: NSLocalizedString("onboarding.test.success", comment: ""), + testText, + result.translatedText + ) + translationTestSuccess = true + } + } catch { + translationTestResult = String( + format: NSLocalizedString("onboarding.test.failed", comment: ""), + error.localizedDescription + ) + translationTestSuccess = false + } + + isTestingTranslation = false + } + + private func completeOnboarding() { + if !paddleOCRServerAddress.isEmpty { + settings.paddleOCRServerAddress = paddleOCRServerAddress + } + + if let (host, port) = parseServerURL(mtranServerURL), !host.isEmpty { + settings.mtranServerHost = host + settings.mtranServerPort = port + } + + settings.onboardingCompleted = true + NotificationCenter.default.post(name: .onboardingCompleted, object: nil) + } + + private func parseServerURL(_ url: String) -> (host: String, port: Int)? { + let trimmed = url.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + + // Remove protocol if present + var hostPart = trimmed + if hostPart.hasPrefix("http://") { + hostPart = String(hostPart.dropFirst(7)) + } else if hostPart.hasPrefix("https://") { + hostPart = String(hostPart.dropFirst(8)) + } + + // Split by colon for port + if let colonIndex = hostPart.firstIndex(of: ":") { + let host = String(hostPart[.. String? { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/env") + task.arguments = ["pip3", "install", "paddleocr", "paddlepaddle"] + + let stderrPipe = Pipe() + task.standardError = stderrPipe + task.standardOutput = Pipe() + + do { + try task.run() + task.waitUntilExit() + + if task.terminationStatus != 0 { + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderr = String(data: stderrData, encoding: .utf8) ?? "Unknown error" + return stderr.isEmpty ? "Installation failed with exit code \(task.terminationStatus)" : stderr + } + return nil + } catch { + return error.localizedDescription + } + } + + func copyInstallCommand() { + let command = "pip3 install paddleocr paddlepaddle" + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(command, forType: .string) + } +} + +// MARK: - Notification Names + +extension Notification.Name { + /// Posted when onboarding is completed + static let onboardingCompleted = Notification.Name("onboardingCompleted") +} diff --git a/ScreenTranslate/Features/Onboarding/OnboardingWelcomeStepView.swift b/ScreenTranslate/Features/Onboarding/OnboardingWelcomeStepView.swift new file mode 100644 index 0000000..7de9851 --- /dev/null +++ b/ScreenTranslate/Features/Onboarding/OnboardingWelcomeStepView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct OnboardingWelcomeStepView: View { + let onContinue: () -> Void + let canGoNext: Bool + + var body: some View { + VStack(spacing: 24) { + Spacer() + + Image(systemName: "hand.wave.fill") + .font(.system(size: 60)) + .foregroundStyle(.blue) + + VStack(spacing: 12) { + Text(NSLocalizedString("onboarding.welcome.title", comment: "")) + .font(.largeTitle) + .fontWeight(.semibold) + + Text(NSLocalizedString("onboarding.welcome.message", comment: "")) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(alignment: .leading, spacing: 16) { + OnboardingFeatureRow( + icon: "cpu", + title: NSLocalizedString("onboarding.feature.local.ocr.title", comment: ""), + description: NSLocalizedString("onboarding.feature.local.ocr.description", comment: "") + ) + + OnboardingFeatureRow( + icon: "globe", + title: NSLocalizedString("onboarding.feature.local.translation.title", comment: ""), + description: NSLocalizedString("onboarding.feature.local.translation.description", comment: "") + ) + + OnboardingFeatureRow( + icon: "command", + title: NSLocalizedString("onboarding.feature.shortcuts.title", comment: ""), + description: NSLocalizedString("onboarding.feature.shortcuts.description", comment: "") + ) + } + .padding(.vertical, 8) + + Spacer() + + OnboardingNavigationButtons( + canGoPrevious: false, + canGoNext: canGoNext, + isLastStep: false, + onPrevious: {}, + onNext: onContinue + ) + } + .padding(32) + } +} diff --git a/ScreenTranslate/Features/Onboarding/OnboardingWindowController.swift b/ScreenTranslate/Features/Onboarding/OnboardingWindowController.swift new file mode 100644 index 0000000..cbbca81 --- /dev/null +++ b/ScreenTranslate/Features/Onboarding/OnboardingWindowController.swift @@ -0,0 +1,105 @@ +import AppKit +import SwiftUI + +/// Controller for presenting and managing the first launch onboarding window. +/// Uses a singleton pattern to ensure only one onboarding window is shown. +@MainActor +final class OnboardingWindowController: NSObject { + // MARK: - Singleton + + /// Shared instance + static let shared = OnboardingWindowController() + + // MARK: - Properties + + /// The onboarding window + private var window: NSWindow? + + /// Completion handler called when onboarding is completed or dismissed + var completionHandler: (() -> Void)? + + // MARK: - Initialization + + private override init() { + super.init() + } + + // MARK: - Public API + + /// Presents the onboarding window if onboarding hasn't been completed. + /// - Parameter settings: The app settings to check and update + /// - Returns: Whether the onboarding window was shown + @discardableResult + func showOnboarding(settings: AppSettings = .shared) -> Bool { + // Don't show if already completed + guard !settings.onboardingCompleted else { + completionHandler?() + return false + } + + // If window already exists, bring it to front + if let window = window, window.isVisible { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return true + } + + // Create view model + let viewModel = OnboardingViewModel(settings: settings) + + // Create the SwiftUI view + let onboardingView = OnboardingView(viewModel: viewModel) + + // Create the hosting view + let hostingView = NSHostingView(rootView: onboardingView) + hostingView.translatesAutoresizingMaskIntoConstraints = false + + // Create the window + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 620), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.title = NSLocalizedString("onboarding.window.title", comment: "Welcome to ScreenTranslate") + window.contentView = hostingView + window.center() + window.isReleasedWhenClosed = false + window.delegate = self + + // Normal window level - allows menu bar and other system UI to remain accessible + window.level = .normal + + // Prevent resizing + window.isMovableByWindowBackground = false + + // Store reference + self.window = window + + // Show the window + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + return true + } + + /// Closes the onboarding window if open. + func closeOnboarding() { + window?.close() + window = nil + } +} + +// MARK: - NSWindowDelegate + +extension OnboardingWindowController: NSWindowDelegate { + nonisolated func windowWillClose(_ notification: Notification) { + Task { @MainActor in + // Notify completion + completionHandler?() + + // Clear references + window = nil + } + } +} diff --git a/ScreenTranslate/Features/Overlay/TranslationPopoverView.swift b/ScreenTranslate/Features/Overlay/TranslationPopoverView.swift new file mode 100644 index 0000000..2a715f3 --- /dev/null +++ b/ScreenTranslate/Features/Overlay/TranslationPopoverView.swift @@ -0,0 +1,387 @@ +import AppKit +import CoreGraphics + +// MARK: - TranslationPopoverView + +/// Custom NSView for drawing the translation popover content. +/// Displays original and translated text with styling. +final class TranslationPopoverView: NSView { + // MARK: - Properties + + /// Translation results to display + private let translations: [TranslationResult] + + /// Weak reference to parent window for delegate communication + private weak var windowRef: TranslationPopoverWindow? + + /// Background color + private let backgroundColor = NSColor.windowBackgroundColor + + /// Border color + private let borderColor = NSColor.separatorColor + + /// Corner radius + private let cornerRadius: CGFloat = 12 + + /// Original text color (gray) + private let originalTextColor = NSColor.secondaryLabelColor + + /// Translated text color (black) + private let translatedTextColor = NSColor.labelColor + + /// Copy button area (in view coordinates) + private var copyButtonRect: CGRect? + + // MARK: - Initialization + + init( + translations: [TranslationResult], + window: TranslationPopoverWindow + ) { + self.translations = translations + self.windowRef = window + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Layout + + /// Calculates the size needed to fit the content + func sizeThatFits(_ size: NSSize) -> NSSize { + let padding: CGFloat = 16 + let itemSpacing: CGFloat = 12 + let lineWidth: CGFloat = 1 + let copyButtonHeight: CGFloat = 28 + + var totalHeight = padding * 2 // Top and bottom padding + var maxWidth: CGFloat = 0 + + // Calculate sizes for each translation item + for (index, translation) in translations.enumerated() { + // Original text + let originalFont = NSFont.systemFont(ofSize: 13, weight: .regular) + let originalAttrs: [NSAttributedString.Key: Any] = [ + .font: originalFont + ] + let originalSize = (translation.sourceText as NSString).size( + withAttributes: originalAttrs + ) + + // Translated text + let translatedFont = NSFont.systemFont(ofSize: 14, weight: .medium) + let translatedAttrs: [NSAttributedString.Key: Any] = [ + .font: translatedFont + ] + let translatedSize = (translation.translatedText as NSString).size( + withAttributes: translatedAttrs + ) + + // Take the wider of the two texts + let itemWidth = max(originalSize.width, translatedSize.width) + maxWidth = max(maxWidth, itemWidth) + + // Add height for this item + totalHeight += originalSize.height + 4 + translatedSize.height + + // Add spacing between items (but not after last) + if index < translations.count - 1 { + totalHeight += itemSpacing + lineWidth + } + } + + // Add space for copy button + totalHeight += copyButtonHeight + padding + + // Constrain width + let maxAllowedWidth: CGFloat = 500 + let minAllowedWidth: CGFloat = 280 + let calculatedWidth = min(max(maxWidth, minAllowedWidth), maxAllowedWidth) + + return NSSize(width: calculatedWidth + padding * 2, height: totalHeight) + } + + // MARK: - Drawing + + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext else { return } + + // Draw background with shadow + drawBackground(context: context) + + // Draw content + var currentY: CGFloat = bounds.height - 16 + let padding: CGFloat = 16 + let itemSpacing: CGFloat = 12 + + for (index, translation) in translations.enumerated() { + // Draw original text (gray) + currentY = drawOriginalText( + translation.sourceText, + at: CGPoint(x: padding, y: currentY), + context: context + ) + + // Draw translated text (black) + currentY = drawTranslatedText( + translation.translatedText, + at: CGPoint(x: padding, y: currentY - 6), + context: context + ) + + // Draw separator between items + if index < translations.count - 1 { + currentY -= itemSpacing + drawSeparator(at: currentY - 4, context: context) + currentY -= 4 + } + } + + // Draw copy button + currentY -= 8 + copyButtonRect = drawCopyButton(at: CGPoint(x: padding, y: currentY), context: context) + } + + /// Draws the popover background with rounded corners and shadow + private func drawBackground(context: CGContext) { + context.saveGState() + + // Create and apply shadow + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.2) + shadow.shadowOffset = NSSize(width: 0, height: -2) + shadow.shadowBlurRadius = 8 + shadow.set() + + // Draw rounded rectangle background + let path = NSBezierPath( + roundedRect: bounds.insetBy(dx: 2, dy: 2), + xRadius: cornerRadius, + yRadius: cornerRadius + ) + + backgroundColor.setFill() + path.fill() + + // Draw border + borderColor.setStroke() + path.lineWidth = 1 + path.stroke() + + context.restoreGState() + } + + /// Draws original text in gray + private func drawOriginalText( + _ text: String, + at origin: CGPoint, + context: CGContext + ) -> CGFloat { + let font = NSFont.systemFont(ofSize: 13, weight: .regular) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: originalTextColor + ] + + let attributedString = NSAttributedString(string: text, attributes: attributes) + let textSize = attributedString.size() + + let drawPoint = CGPoint( + x: origin.x, + y: origin.y - textSize.height + ) + + attributedString.draw(at: drawPoint) + + return origin.y - textSize.height + } + + /// Draws translated text in black + private func drawTranslatedText( + _ text: String, + at origin: CGPoint, + context: CGContext + ) -> CGFloat { + let font = NSFont.systemFont(ofSize: 14, weight: .medium) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: translatedTextColor + ] + + let attributedString = NSAttributedString(string: text, attributes: attributes) + let textSize = attributedString.size() + + let drawPoint = CGPoint( + x: origin.x, + y: origin.y - textSize.height + ) + + attributedString.draw(at: drawPoint) + + return origin.y - textSize.height + } + + /// Draws a separator line between translation items + private func drawSeparator(at y: CGFloat, context: CGContext) { + context.saveGState() + + let lineRect = CGRect( + x: 16, + y: y, + width: bounds.width - 32, + height: 1 + ) + + let path = NSBezierPath(rect: lineRect) + borderColor.withAlphaComponent(0.5).setStroke() + path.lineWidth = 1 + path.stroke() + + context.restoreGState() + } + + /// Draws the copy button at the specified position + private func drawCopyButton(at origin: CGPoint, context: CGContext) -> CGRect { + let buttonWidth: CGFloat = 80 + let buttonHeight: CGFloat = 28 + + let buttonRect = CGRect( + x: origin.x, + y: origin.y - buttonHeight, + width: buttonWidth, + height: buttonHeight + ) + + context.saveGState() + + // Button background + let buttonPath = NSBezierPath( + roundedRect: buttonRect, + xRadius: 6, + yRadius: 6 + ) + + NSColor.controlAccentColor.setFill() + buttonPath.fill() + + // Button text + let buttonText = "Copy" + let font = NSFont.systemFont(ofSize: 13, weight: .medium) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: NSColor.white + ] + + let textSize = (buttonText as NSString).size(withAttributes: attributes) + let textPoint = CGPoint( + x: buttonRect.midX - textSize.width / 2, + y: buttonRect.midY - textSize.height / 2 + ) + + (buttonText as NSString).draw(at: textPoint, withAttributes: attributes) + + context.restoreGState() + + return buttonRect + } + + // MARK: - Mouse Events + + override func mouseDown(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + + // Check if click is on copy button + if let buttonRect = copyButtonRect, buttonRect.contains(point) { + copyToClipboard() + return + } + + super.mouseDown(with: event) + } + + override func mouseEntered(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + + // Change cursor when hovering over copy button + if let buttonRect = copyButtonRect, buttonRect.contains(point) { + NSCursor.pointingHand.set() + } else { + NSCursor.arrow.set() + } + } + + override func mouseExited(with event: NSEvent) { + NSCursor.arrow.set() + } + + override func mouseMoved(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + + // Change cursor when hovering over copy button + if let buttonRect = copyButtonRect, buttonRect.contains(point) { + NSCursor.pointingHand.set() + } else { + NSCursor.arrow.set() + } + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + + // Remove existing tracking areas + for area in trackingAreas { + removeTrackingArea(area) + } + + // Add new tracking area for mouse tracking + let options: NSTrackingArea.Options = [ + .activeAlways, + .mouseMoved, + .mouseEnteredAndExited, + .inVisibleRect + ] + + let trackingArea = NSTrackingArea( + rect: bounds, + options: options, + owner: self, + userInfo: nil + ) + addTrackingArea(trackingArea) + } + + // MARK: - Copy Functionality + + /// Copies all translated text to clipboard + private func copyToClipboard() { + let combinedTranslation = translations + .map(\.translatedText) + .joined(separator: "\n") + + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(combinedTranslation, forType: .string) + + // Show brief visual feedback + showCopyFeedback() + } + + /// Shows visual feedback when text is copied + private func showCopyFeedback() { + // Brief flash effect + let originalAlpha = alphaValue + alphaValue = 0.7 + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.1 + animator().alphaValue = 1.0 + } + + // Restore + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.alphaValue = originalAlpha + } + } +} diff --git a/ScreenTranslate/Features/Overlay/TranslationPopoverWindow.swift b/ScreenTranslate/Features/Overlay/TranslationPopoverWindow.swift new file mode 100644 index 0000000..2438f8a --- /dev/null +++ b/ScreenTranslate/Features/Overlay/TranslationPopoverWindow.swift @@ -0,0 +1,288 @@ +import AppKit +import CoreGraphics +import SwiftUI + +// MARK: - TranslationPopoverDelegate + +/// Delegate protocol for translation popover events. +@MainActor +protocol TranslationPopoverDelegate: AnyObject { + /// Called when user dismisses the popover. + func translationPopoverDidDismiss() +} + +// MARK: - TranslationPopoverWindow + +/// NSPanel subclass for displaying translation results in a popover below the selection. +/// Shows original text and translated text in a styled floating panel. +final class TranslationPopoverWindow: NSPanel { + // MARK: - Properties + + /// The anchor rectangle for the popover (in screen coordinates) + let anchorRect: CGRect + + /// The screen this popover appears on + let targetScreen: NSScreen + + /// Translation results to display + private let translations: [TranslationResult] + + /// The content view handling drawing and interaction + private var popoverView: TranslationPopoverView? + + /// Delegate for popover events + weak var popoverDelegate: TranslationPopoverDelegate? + + /// Whether the popover is currently positioned + private var isPositioned = false + + // MARK: - Initialization + + /// Creates a new translation popover window. + /// - Parameters: + /// - anchorRect: The rectangle to anchor the popover below (screen coordinates) + /// - screen: The NSScreen containing the anchor + /// - translations: Translation results to display + @MainActor + init( + anchorRect: CGRect, + screen: NSScreen, + translations: [TranslationResult] + ) { + self.anchorRect = anchorRect + self.targetScreen = screen + self.translations = translations + + // Initial frame - will be repositioned + let initialFrame = CGRect(x: 0, y: 0, width: 400, height: 200) + + super.init( + contentRect: initialFrame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + configureWindow() + setupPopoverView() + positionPopover() + } + + // MARK: - Configuration + + @MainActor + private func configureWindow() { + // Window properties for floating popover + level = .floating + isOpaque = false + backgroundColor = .clear + ignoresMouseEvents = false + hasShadow = true + + hidesOnDeactivate = true + + // Behavior + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] + isMovable = false + isMovableByWindowBackground = false + + // Accept mouse events + acceptsMouseMovedEvents = true + } + + @MainActor + private func setupPopoverView() { + let view = TranslationPopoverView( + translations: translations, + window: self + ) + self.contentView = view + self.popoverView = view + } + + /// Positions the popover below the anchor rectangle + @MainActor + private func positionPopover() { + guard let popoverView = popoverView else { return } + + // Calculate the size needed for the content + let contentSize = popoverView.sizeThatFits(NSSize(width: 380, height: 1000)) + + // Calculate position below anchor rect + let anchorBottom = anchorRect.maxY + let anchorCenter = anchorRect.midX + + // Position below anchor with some padding + let padding: CGFloat = 12 + var origin = CGPoint( + x: anchorCenter - contentSize.width / 2, + y: anchorBottom - contentSize.height - padding + ) + + // Keep within screen bounds (horizontal) + if origin.x < 20 { + origin.x = 20 + } else if origin.x + contentSize.width > targetScreen.frame.width - 20 { + origin.x = targetScreen.frame.width - contentSize.width - 20 + } + + // Keep within screen bounds (vertical - flip if needed) + if origin.y < 20 { + // Not enough space below, try above + origin.y = anchorRect.minY + padding + } + + // Ensure still within bounds + if origin.y < 20 { + origin.y = 20 + } else if origin.y + contentSize.height > targetScreen.frame.height - 20 { + origin.y = targetScreen.frame.height - contentSize.height - 20 + } + + let newFrame = CGRect(origin: origin, size: contentSize) + setFrame(newFrame, display: true) + isPositioned = true + } + + // MARK: - Public API + + /// Shows the popover window + @MainActor + func showPopover() { + makeKeyAndOrderFront(nil) + orderFrontRegardless() + + // Add close button after window is positioned + setupCloseButton() + } + + private var closeButton: NSButton? + + private func setupCloseButton() { + guard closeButton == nil else { return } + + let buttonSize: CGFloat = 28 + let margin: CGFloat = 8 + let button = NSButton(frame: NSRect( + x: contentView?.bounds.width ?? 400 - buttonSize - margin, + y: contentView?.bounds.height ?? 200 - buttonSize - margin, + width: buttonSize, + height: buttonSize + )) + button.bezelStyle = NSButton.BezelStyle.circular + button.title = "×" + button.font = NSFont.systemFont(ofSize: 18, weight: .medium) + button.target = self + button.action = #selector(closeWindow) + button.autoresizingMask = NSView.AutoresizingMask([.minXMargin, .minYMargin]) + contentView?.addSubview(button) + closeButton = button + } + + @objc private func closeWindow() { + orderOut(nil) + } + + // MARK: - NSWindow Overrides + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + override var acceptsFirstResponder: Bool { true } + + override func keyDown(with event: NSEvent) { + // Escape key dismisses popover + if event.keyCode == 53 { // Escape + popoverDelegate?.translationPopoverDidDismiss() + return + } + + super.keyDown(with: event) + } + + override func mouseDown(with event: NSEvent) { + // Check if click is outside the popover content + let locationInWindow = event.locationInWindow + guard let contentView = contentView else { + super.mouseDown(with: event) + return + } + + // Convert window coordinates to view coordinates + let locationInView = contentView.convert(locationInWindow, from: nil) + + if !contentView.bounds.contains(locationInView) { + // Click outside - dismiss + popoverDelegate?.translationPopoverDidDismiss() + return + } + + super.mouseDown(with: event) + } +} + +// MARK: - TranslationPopoverController + +/// Controller for managing translation popover lifecycle. +@MainActor +final class TranslationPopoverController { + // MARK: - Properties + + /// Shared instance + static let shared = TranslationPopoverController() + + /// The current popover window + private var popoverWindow: TranslationPopoverWindow? + + /// Delegate for popover events + weak var popoverDelegate: TranslationPopoverDelegate? + + /// Callback for when popover is dismissed + var onDismiss: (() -> Void)? + + // MARK: - Initialization + + private init() {} + + // MARK: - Public API + + /// Presents translation popover with the given results. + /// - Parameters: + /// - anchorRect: The rectangle to anchor below (in screen coordinates) + /// - translations: Array of translation results + func presentPopover( + anchorRect: CGRect, + translations: [TranslationResult] + ) { + // Dismiss any existing popover + dismissPopover() + + guard let screen = NSScreen.main else { return } + + // Create popover window + let popover = TranslationPopoverWindow( + anchorRect: anchorRect, + screen: screen, + translations: translations + ) + popover.popoverDelegate = self + + self.popoverWindow = popover + popover.showPopover() + } + + /// Dismisses the current popover. + func dismissPopover() { + popoverWindow?.close() + popoverWindow = nil + onDismiss?() + } +} + +// MARK: - TranslationPopoverController + TranslationPopoverDelegate + +extension TranslationPopoverController: TranslationPopoverDelegate { + func translationPopoverDidDismiss() { + dismissPopover() + onDismiss?() + } +} diff --git a/ScreenTranslate/Features/Pinned/PinnedWindow.swift b/ScreenTranslate/Features/Pinned/PinnedWindow.swift new file mode 100644 index 0000000..eb5fbca --- /dev/null +++ b/ScreenTranslate/Features/Pinned/PinnedWindow.swift @@ -0,0 +1,216 @@ +import AppKit +import SwiftUI + +/// A window that displays a pinned screenshot with annotations. +/// Can be set to always stay on top of other windows. +final class PinnedWindow: NSPanel { + // MARK: - Properties + + private let screenshot: Screenshot + private let annotations: [Annotation] + private let id: UUID + + /// Callback when the window is closed + var onClose: (() -> Void)? + + /// Flag to prevent reentrant close calls + var isProgrammaticClose = false + + // MARK: - Initialization + + @MainActor + init(screenshot: Screenshot, annotations: [Annotation]) { + self.screenshot = screenshot + self.annotations = annotations + self.id = screenshot.id + + // Calculate window size based on image dimensions + let scaleFactor = screenshot.sourceDisplay.scaleFactor + let imageSize = CGSize( + width: CGFloat(screenshot.image.width) / scaleFactor, + height: CGFloat(screenshot.image.height) / scaleFactor + ) + + // Limit window size to 60% of screen + let windowSize = Self.calculateWindowSize(for: imageSize) + let contentRect = Self.calculateCenteredRect(size: windowSize) + + super.init( + contentRect: contentRect, + styleMask: [.borderless, .nonactivatingPanel, .hudWindow], + backing: .buffered, + defer: false + ) + + configureWindow() + setupContentView() + } + + // MARK: - Configuration + + @MainActor + private func configureWindow() { + // Window behavior - always on top but not intrusive + level = .floating + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + + // Appearance + isOpaque = false + backgroundColor = .clear + hasShadow = true + + // Behavior + isMovableByWindowBackground = true + hidesOnDeactivate = false + isReleasedWhenClosed = false + + // Allow becoming key for keyboard events + _ = canBecomeKey + } + + @MainActor + private func setupContentView() { + let contentView = PinnedWindowContent( + image: screenshot.image, + annotations: annotations, + scaleFactor: screenshot.sourceDisplay.scaleFactor, + onClose: { [weak self] in + self?.close() + } + ) + + let hostingView = NSHostingView(rootView: contentView) + hostingView.autoresizingMask = [.width, .height] + self.contentView = hostingView + } + + // MARK: - Window Sizing + + @MainActor + private static func calculateWindowSize(for imageSize: CGSize) -> NSSize { + guard let screen = NSScreen.main else { + return NSSize(width: min(imageSize.width, 800), height: min(imageSize.height, 600)) + } + + let screenFrame = screen.visibleFrame + + // Leave padding around the window + let maxWidth = screenFrame.width * 0.6 + let maxHeight = screenFrame.height * 0.6 + + // Calculate scale factor to fit within screen + let widthScale = maxWidth / imageSize.width + let heightScale = maxHeight / imageSize.height + let scale = min(widthScale, heightScale, 1.0) // Don't scale up + + return NSSize( + width: max(imageSize.width * scale, 150), + height: max(imageSize.height * scale, 100) + ) + } + + @MainActor + private static func calculateCenteredRect(size: NSSize) -> NSRect { + guard let screen = NSScreen.main else { + return NSRect(origin: .zero, size: size) + } + + let screenFrame = screen.visibleFrame + let x = screenFrame.midX - size.width / 2 + let y = screenFrame.midY - size.height / 2 + + return NSRect(x: x, y: y, width: size.width, height: size.height) + } + + // MARK: - NSPanel Overrides + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } + + // MARK: - Lifecycle + + override func close() { + // Only call onClose for user-initiated closes + if !isProgrammaticClose { + onClose?() + } + super.close() + } + + // MARK: - Public API + + /// Shows the pinned window + @MainActor + func show() { + makeKeyAndOrderFront(nil) + } + + /// Returns the unique identifier for this pinned window + var pinnedId: UUID { id } +} + +// MARK: - Pinned Window Content View + +private struct PinnedWindowContent: View { + let image: CGImage + let annotations: [Annotation] + let scaleFactor: CGFloat + let onClose: () -> Void + + @State private var isHovering = false + + var body: some View { + ZStack(alignment: .topTrailing) { + // Image with annotations + GeometryReader { geometry in + let viewSize = geometry.size + let imageSize = CGSize(width: CGFloat(image.width), height: CGFloat(image.height)) + let scale = min(viewSize.width / imageSize.width, viewSize.height / imageSize.height) + + ZStack { + // Background + Color.black.opacity(0.1) + + // Image + Image(nsImage: NSImage(cgImage: image, size: NSSize( + width: CGFloat(image.width) / scaleFactor, + height: CGFloat(image.height) / scaleFactor + ))) + .resizable() + .aspectRatio(contentMode: .fit) + + // Annotations overlay + AnnotationCanvas( + annotations: annotations, + currentAnnotation: nil, + canvasSize: CGSize(width: CGFloat(image.width), height: CGFloat(image.height)), + scale: scale * scaleFactor, + selectedIndex: nil, + sourceImage: image + ) + .aspectRatio(contentMode: .fit) + } + } + .cornerRadius(8) + + // Close button (visible on hover) + if isHovering { + Button(action: onClose) { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundStyle(.white, .gray) + .shadow(radius: 2) + } + .buttonStyle(.plain) + .padding(8) + .transition(.opacity) + } + } + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.15)) { + isHovering = hovering + } + } + .frame(minWidth: 150, minHeight: 100) + } +} diff --git a/ScreenTranslate/Features/Pinned/PinnedWindowsManager.swift b/ScreenTranslate/Features/Pinned/PinnedWindowsManager.swift new file mode 100644 index 0000000..9e951bd --- /dev/null +++ b/ScreenTranslate/Features/Pinned/PinnedWindowsManager.swift @@ -0,0 +1,125 @@ +import AppKit +import Foundation + +/// Manages all pinned screenshot windows. +/// Provides centralized control over pinned window lifecycle. +@MainActor +final class PinnedWindowsManager { + // MARK: - Singleton + + static let shared = PinnedWindowsManager() + + // MARK: - Properties + + /// All currently pinned windows + private(set) var pinnedWindows: [UUID: PinnedWindow] = [:] + + /// Maximum number of pinned windows allowed + private let maxPinnedWindows = 5 + + // MARK: - Initialization + + private init() {} + + // MARK: - Public API + + /// Pins a screenshot with its annotations. + /// - Parameters: + /// - screenshot: The screenshot to pin + /// - annotations: The annotations to include + /// - Returns: The created pinned window, or nil if limit reached + @discardableResult + func pinScreenshot(_ screenshot: Screenshot, annotations: [Annotation]) -> PinnedWindow? { + // Check if already pinned + if let existingWindow = pinnedWindows[screenshot.id] { + existingWindow.makeKeyAndOrderFront(nil) + return existingWindow + } + + // Check limit + if pinnedWindows.count >= maxPinnedWindows { + // Show warning + showLimitWarning() + return nil + } + + // Create new pinned window + let pinnedWindow = PinnedWindow( + screenshot: screenshot, + annotations: annotations + ) + + pinnedWindow.onClose = { [weak self] in + self?.unpinWindow(screenshot.id) + } + + pinnedWindows[screenshot.id] = pinnedWindow + pinnedWindow.show() + + return pinnedWindow + } + + /// Unpins a screenshot by its ID. + /// - Parameter id: The screenshot ID to unpin + func unpinWindow(_ id: UUID) { + guard let window = pinnedWindows[id] else { return } + + // Mark as programmatic close to prevent reentrancy + window.isProgrammaticClose = true + window.close() + pinnedWindows.removeValue(forKey: id) + } + + /// Unpins all pinned windows. + func unpinAll() { + // Collect all windows and remove from dictionary first + let windows = Array(pinnedWindows.values) + pinnedWindows.removeAll() + + // Mark each as programmatic close and close + for window in windows { + window.isProgrammaticClose = true + window.close() + } + } + + /// Checks if a screenshot is currently pinned. + /// - Parameter id: The screenshot ID to check + /// - Returns: True if pinned, false otherwise + func isPinned(_ id: UUID) -> Bool { + pinnedWindows[id] != nil + } + + /// Returns the number of currently pinned windows. + var pinnedCount: Int { + pinnedWindows.count + } + + /// Brings a pinned window to front. + /// - Parameter id: The screenshot ID to bring to front + func bringToFront(_ id: UUID) { + pinnedWindows[id]?.makeKeyAndOrderFront(nil) + } + + // MARK: - Private Helpers + + private func showLimitWarning() { + let alert = NSAlert() + alert.messageText = NSLocalizedString( + "pinned.limit.title", + value: "Pin Limit Reached", + comment: "Alert title when pin limit is reached" + ) + alert.informativeText = String( + format: NSLocalizedString( + "pinned.limit.message", + value: "You can pin up to %d screenshots at a time. Please unpin some first.", + comment: "Alert message explaining pin limit" + ), + maxPinnedWindows + ) + alert.alertStyle = .warning + alert.addButton(withTitle: NSLocalizedString("OK", comment: "")) + alert.runModal() + } +} diff --git a/ScreenCapture/Features/Preview/AnnotationCanvas.swift b/ScreenTranslate/Features/Preview/AnnotationCanvas.swift similarity index 52% rename from ScreenCapture/Features/Preview/AnnotationCanvas.swift rename to ScreenTranslate/Features/Preview/AnnotationCanvas.swift index af02e3f..216be5b 100644 --- a/ScreenCapture/Features/Preview/AnnotationCanvas.swift +++ b/ScreenTranslate/Features/Preview/AnnotationCanvas.swift @@ -1,9 +1,16 @@ import SwiftUI import AppKit +import CoreImage +import os /// SwiftUI Canvas view for drawing and displaying annotations. /// Renders existing annotations and in-progress drawing. struct AnnotationCanvas: View { + // MARK: - Shared CIContext for performance + + private static let sharedCIContext = CIContext() + private static let logger = Logger.ui + // MARK: - Properties /// The annotations to display @@ -21,6 +28,9 @@ struct AnnotationCanvas: View { /// Index of the selected annotation (nil = none selected) var selectedIndex: Int? + /// The original image for mosaic effect + var sourceImage: CGImage? + // MARK: - Body var body: some View { @@ -52,32 +62,52 @@ struct AnnotationCanvas: View { return "No annotations" } - let rectangleCount = annotations.filter { - if case .rectangle = $0 { return true } - return false - }.count - - let freehandCount = annotations.filter { - if case .freehand = $0 { return true } - return false - }.count - - let textCount = annotations.filter { - if case .text = $0 { return true } - return false - }.count - var parts: [String] = [] - if rectangleCount > 0 { - parts.append("\(rectangleCount) rectangle\(rectangleCount == 1 ? "" : "s")") - } - if freehandCount > 0 { - parts.append("\(freehandCount) drawing\(freehandCount == 1 ? "" : "s")") + + // Count each annotation type + var rectangleCount = 0, freehandCount = 0, arrowCount = 0, textCount = 0 + var ellipseCount = 0, lineCount = 0, mosaicCount = 0, highlightCount = 0, numberLabelCount = 0 + + // Count from existing annotations + for annotation in annotations { + switch annotation { + case .rectangle: rectangleCount += 1 + case .freehand: freehandCount += 1 + case .arrow: arrowCount += 1 + case .text: textCount += 1 + case .ellipse: ellipseCount += 1 + case .line: lineCount += 1 + case .mosaic: mosaicCount += 1 + case .highlight: highlightCount += 1 + case .numberLabel: numberLabelCount += 1 + } } - if textCount > 0 { - parts.append("\(textCount) text\(textCount == 1 ? "" : "s")") + + // Also count currentAnnotation if present + if let current = currentAnnotation { + switch current { + case .rectangle: rectangleCount += 1 + case .freehand: freehandCount += 1 + case .arrow: arrowCount += 1 + case .text: textCount += 1 + case .ellipse: ellipseCount += 1 + case .line: lineCount += 1 + case .mosaic: mosaicCount += 1 + case .highlight: highlightCount += 1 + case .numberLabel: numberLabelCount += 1 + } } + if rectangleCount > 0 { parts.append("\(rectangleCount) rectangle\(rectangleCount == 1 ? "" : "s")") } + if ellipseCount > 0 { parts.append("\(ellipseCount) ellipse\(ellipseCount == 1 ? "" : "s")") } + if lineCount > 0 { parts.append("\(lineCount) line\(lineCount == 1 ? "" : "s")") } + if arrowCount > 0 { parts.append("\(arrowCount) arrow\(arrowCount == 1 ? "" : "s")") } + if freehandCount > 0 { parts.append("\(freehandCount) drawing\(freehandCount == 1 ? "" : "s")") } + if highlightCount > 0 { parts.append("\(highlightCount) highlight\(highlightCount == 1 ? "" : "s")") } + if mosaicCount > 0 { parts.append("\(mosaicCount) mosaic\(mosaicCount == 1 ? "" : "s")") } + if textCount > 0 { parts.append("\(textCount) text\(textCount == 1 ? "" : "s")") } + if numberLabelCount > 0 { parts.append("\(numberLabelCount) number label\(numberLabelCount == 1 ? "" : "s")") } + return "Annotations: \(parts.joined(separator: ", "))" } @@ -92,12 +122,22 @@ struct AnnotationCanvas: View { switch annotation { case .rectangle(let rect): drawRectangle(rect, in: &context, size: size) + case .ellipse(let ellipse): + drawEllipse(ellipse, in: &context, size: size) + case .line(let line): + drawLine(line, in: &context, size: size) case .freehand(let freehand): drawFreehand(freehand, in: &context, size: size) case .arrow(let arrow): drawArrow(arrow, in: &context, size: size) + case .highlight(let highlight): + drawHighlight(highlight, in: &context, size: size) + case .mosaic(let mosaic): + drawMosaic(mosaic, in: &context, size: size) case .text(let text): drawText(text, in: &context, size: size) + case .numberLabel(let label): + drawNumberLabel(label, in: &context, size: size) } } @@ -232,6 +272,186 @@ struct AnnotationCanvas: View { ) } + /// Draws an ellipse annotation + private func drawEllipse( + _ annotation: EllipseAnnotation, + in context: inout GraphicsContext, + size: CGSize + ) { + let scaledRect = scaleRect(annotation.rect) + let path = Path(ellipseIn: scaledRect) + + if annotation.isFilled { + // Filled ellipse - solid color to hide underlying content + context.fill( + path, + with: .color(annotation.style.color.color) + ) + } else { + // Hollow ellipse - outline only + context.stroke( + path, + with: .color(annotation.style.color.color), + lineWidth: annotation.style.lineWidth * scale + ) + } + } + + /// Draws a line annotation + private func drawLine( + _ annotation: LineAnnotation, + in context: inout GraphicsContext, + size: CGSize + ) { + let scaledStart = scalePoint(annotation.startPoint) + let scaledEnd = scalePoint(annotation.endPoint) + + var path = Path() + path.move(to: scaledStart) + path.addLine(to: scaledEnd) + + context.stroke( + path, + with: .color(annotation.style.color.color), + lineWidth: annotation.style.lineWidth * scale + ) + } + + /// Draws a highlight annotation + private func drawHighlight( + _ annotation: HighlightAnnotation, + in context: inout GraphicsContext, + size: CGSize + ) { + let scaledRect = scaleRect(annotation.rect) + let path = Path(scaledRect) + context.fill( + path, + with: .color(annotation.color.color.opacity(annotation.opacity)) + ) + } + + /// Draws a mosaic annotation (pixelation effect) + private func drawMosaic( + _ annotation: MosaicAnnotation, + in context: inout GraphicsContext, + size: CGSize + ) { + let scaledRect = scaleRect(annotation.rect) + // Use blockSize directly, with small minimum to ensure visibility + let blockSize: CGFloat = max(2, CGFloat(annotation.blockSize) * scale) + + // If we have source image, do real pixelation + if let cgImage = sourceImage { + let imageWidth = CGFloat(cgImage.width) + let imageHeight = CGFloat(cgImage.height) + + // Convert scaled rect back to image coordinates + let imageRect = CGRect( + x: scaledRect.origin.x / scale, + y: scaledRect.origin.y / scale, + width: scaledRect.size.width / scale, + height: scaledRect.size.height / scale + ) + + // Create pixelated version by drawing scaled down then scaled up + if let pixelatedCI = createPixelatedImage( + from: cgImage, + rect: imageRect, + blockSize: blockSize / scale, + canvasSize: CGSize(width: imageWidth, height: imageHeight) + ) { + if let outputImage = Self.sharedCIContext.createCGImage(pixelatedCI, from: pixelatedCI.extent) { + let nsImage = NSImage(cgImage: outputImage, size: NSSize(width: imageWidth, height: imageHeight)) + context.draw(Image(nsImage: nsImage), in: scaledRect) + return + } + } + } + + // Fallback: draw colored blocks (improved version) + var x = scaledRect.origin.x + while x < scaledRect.origin.x + scaledRect.size.width { + var y = scaledRect.origin.y + while y < scaledRect.origin.y + scaledRect.size.height { + let blockRect = CGRect( + x: x, + y: y, + width: min(blockSize, scaledRect.origin.x + scaledRect.size.width - x), + height: min(blockSize, scaledRect.origin.y + scaledRect.size.height - y) + ) + let path = Path(blockRect) + // Alternate colors for checkerboard pattern + let isEven = Int(x / blockSize).isMultiple(of: 2) == Int(y / blockSize).isMultiple(of: 2) + context.fill(path, with: .color(isEven ? .gray.opacity(0.6) : .gray.opacity(0.4))) + y += blockSize + } + x += blockSize + } + } + + /// Creates a pixelated CIImage from the source image in the specified rect + private func createPixelatedImage( + from cgImage: CGImage, + rect: CGRect, + blockSize: CGFloat, + canvasSize: CGSize + ) -> CIImage? { + let ciImage = CIImage(cgImage: cgImage) + + // Apply pixelation using CIPixellate filter + guard let pixellateFilter = CIFilter(name: "CIPixellate") else { + Self.logger.warning("CIPixellate filter not available, falling back to gray block") + return nil + } + pixellateFilter.setValue(ciImage, forKey: kCIInputImageKey) + pixellateFilter.setValue(max(1, blockSize), forKey: kCIInputScaleKey) + + guard let outputImage = pixellateFilter.outputImage else { + Self.logger.warning("Pixellation failed, falling back to gray block") + return nil + } + + return outputImage.cropped(to: CGRect( + x: rect.origin.x, + y: canvasSize.height - rect.origin.y - rect.size.height, + width: rect.size.width, + height: rect.size.height + )) + } + + /// Draws a number label annotation + private func drawNumberLabel( + _ annotation: NumberLabelAnnotation, + in context: inout GraphicsContext, + size: CGSize + ) { + let scaledPoint = scalePoint(annotation.position) + let scaledSize = annotation.size * scale + let scaledRadius = scaledSize / 2 + + // Draw circle background + let circleRect = CGRect( + x: scaledPoint.x - scaledRadius, + y: scaledPoint.y - scaledRadius, + width: scaledRadius * 2, + height: scaledRadius * 2 + ) + let circlePath = Path(ellipseIn: circleRect) + context.fill(circlePath, with: .color(annotation.color.color)) + + // Draw number text + let text = Text("\(annotation.number)") + .font(.system(size: scaledRadius * 1.2, weight: .bold)) + .foregroundColor(.white) + + context.draw( + context.resolve(text), + at: scaledPoint, + anchor: .center + ) + } + /// Draws a selection indicator around an annotation private func drawSelectionIndicator( for annotation: Annotation, diff --git a/ScreenTranslate/Features/Preview/CropDimOverlay.swift b/ScreenTranslate/Features/Preview/CropDimOverlay.swift new file mode 100644 index 0000000..dbf861a --- /dev/null +++ b/ScreenTranslate/Features/Preview/CropDimOverlay.swift @@ -0,0 +1,30 @@ +import SwiftUI + +/// A shape that covers everything except a rectangular cutout +struct CropDimOverlay: Shape { + var cropRect: CGRect + + var animatableData: AnimatablePair, AnimatablePair> { + get { + AnimatablePair( + AnimatablePair(cropRect.origin.x, cropRect.origin.y), + AnimatablePair(cropRect.width, cropRect.height) + ) + } + set { + cropRect = CGRect( + x: newValue.first.first, + y: newValue.first.second, + width: newValue.second.first, + height: newValue.second.second + ) + } + } + + func path(in rect: CGRect) -> Path { + var path = Path() + path.addRect(rect) + path.addRect(cropRect) + return path + } +} diff --git a/ScreenTranslate/Features/Preview/PreviewActionButtons.swift b/ScreenTranslate/Features/Preview/PreviewActionButtons.swift new file mode 100644 index 0000000..b6bdb1a --- /dev/null +++ b/ScreenTranslate/Features/Preview/PreviewActionButtons.swift @@ -0,0 +1,170 @@ +import SwiftUI + +struct PreviewActionButtons: View { + @Bindable var viewModel: PreviewViewModel + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + HStack(spacing: 8) { + cropButton + + Divider() + .frame(height: 16) + .accessibilityHidden(true) + + pinButton + + Divider() + .frame(height: 16) + .accessibilityHidden(true) + + undoRedoButtons + + Divider() + .frame(height: 16) + .accessibilityHidden(true) + + saveButton + + Divider() + .frame(height: 16) + .accessibilityHidden(true) + + ocrButton + + Divider() + .frame(height: 16) + .accessibilityHidden(true) + + confirmButton + } + .buttonStyle(.accessoryBar) + .accessibilityElement(children: .contain) + .accessibilityLabel(Text("Screenshot actions")) + } + + private var cropButton: some View { + Button { + viewModel.toggleCropMode() + } label: { + Image(systemName: "crop") + } + .buttonStyle(.accessoryBar) + .background( + viewModel.isCropMode + ? Color.accentColor.opacity(0.2) + : Color.clear + ) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .help(String(localized: "preview.tooltip.crop")) + .accessibilityLabel(Text("preview.crop")) + .accessibilityHint(Text("Press C to toggle")) + } + + private var pinButton: some View { + Button { + viewModel.pinScreenshot() + } label: { + Image(systemName: viewModel.isPinned ? "pin.fill" : "pin") + } + .buttonStyle(.accessoryBar) + .background( + viewModel.isPinned + ? Color.accentColor.opacity(0.2) + : Color.clear + ) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .help(String(localized: "preview.tooltip.pin")) + .accessibilityLabel(Text("preview.pin")) + .accessibilityHint(Text("Press P to pin")) + } + + private var undoRedoButtons: some View { + Group { + Button { + viewModel.undo() + } label: { + Image(systemName: "arrow.uturn.backward") + } + .disabled(!viewModel.canUndo) + .help(String(localized: "preview.tooltip.undo")) + .accessibilityLabel(Text("action.undo")) + .accessibilityHint(Text("Command Z")) + + Button { + viewModel.redo() + } label: { + Image(systemName: "arrow.uturn.forward") + } + .disabled(!viewModel.canRedo) + .help(String(localized: "preview.tooltip.redo")) + .accessibilityLabel(Text("action.redo")) + .accessibilityHint(Text("Command Shift Z")) + } + } + + private var saveButton: some View { + Button { + viewModel.saveScreenshot() + } label: { + if viewModel.isSaving { + loadingIndicator + } else { + Image(systemName: "square.and.arrow.down") + } + } + .disabled(viewModel.isSaving) + .help(String(localized: "preview.tooltip.save")) + .accessibilityLabel(Text(String(localized: viewModel.isSaving ? "preview.accessibility.saving" : "preview.accessibility.save"))) + .accessibilityHint(Text(String(localized: "preview.accessibility.hint.commandS"))) + } + + private var ocrButton: some View { + Button { + viewModel.performOCR() + } label: { + if viewModel.isPerformingOCR { + ProgressView() + .controlSize(.small) + .frame(width: 16, height: 16) + } else { + Image(systemName: "text.viewfinder") + } + } + .disabled(viewModel.isPerformingOCR) + .help(String(localized: "preview.tooltip.ocr")) + } + + /// Confirm button: copies to clipboard and dismisses (only on success) + /// Users who don't want to copy can close the window directly + private var confirmButton: some View { + Button { + if viewModel.copyToClipboard() { + viewModel.dismiss() + } + } label: { + if viewModel.isCopying { + loadingIndicator + } else { + Text(String(localized: "button.confirm")) + .fontWeight(.medium) + } + } + .disabled(viewModel.isCopying) + .help(String(localized: "preview.tooltip.confirm")) + .accessibilityLabel(Text(String(localized: viewModel.isCopying ? "preview.accessibility.copying" : "preview.accessibility.confirm"))) + .accessibilityHint(Text(String(localized: "preview.accessibility.hint.enter"))) + } + + @ViewBuilder + private var loadingIndicator: some View { + if reduceMotion { + Image(systemName: "ellipsis") + .frame(width: 16, height: 16) + } else { + ProgressView() + .controlSize(.small) + .frame(width: 16, height: 16) + } + } +} diff --git a/ScreenTranslate/Features/Preview/PreviewAnnotatedImageView.swift b/ScreenTranslate/Features/Preview/PreviewAnnotatedImageView.swift new file mode 100644 index 0000000..a5aa064 --- /dev/null +++ b/ScreenTranslate/Features/Preview/PreviewAnnotatedImageView.swift @@ -0,0 +1,317 @@ +import SwiftUI +import AppKit + +struct PreviewAnnotatedImageView: View { + @Bindable var viewModel: PreviewViewModel + @Binding var imageDisplaySize: CGSize + @Binding var imageScale: CGFloat + @FocusState.Binding var isTextFieldFocused: Bool + + private var displayScaleFactor: CGFloat { + viewModel.screenshot.sourceDisplay.scaleFactor + } + + private var imageSize: CGSize { + CGSize( + width: CGFloat(viewModel.image.width) / displayScaleFactor, + height: CGFloat(viewModel.image.height) / displayScaleFactor + ) + } + + var body: some View { + ZStack(alignment: .topLeading) { + Image(viewModel.image, scale: displayScaleFactor, label: Text("preview.screenshot")) + .accessibilityLabel(Text( + "Screenshot preview, \(viewModel.dimensionsText), from \(viewModel.displayName)" + )) + + AnnotationCanvas( + annotations: viewModel.annotations, + currentAnnotation: viewModel.currentAnnotation, + canvasSize: imageSize, + scale: 1.0 / displayScaleFactor, + selectedIndex: viewModel.selectedAnnotationIndex, + sourceImage: viewModel.image + ) + .frame(width: imageSize.width, height: imageSize.height) + + if viewModel.isWaitingForTextInput, + let inputPosition = viewModel.textInputPosition { + textInputField(at: inputPosition) + } + + if viewModel.selectedTool != nil { + drawingGestureOverlay + } + + if viewModel.selectedTool == nil && !viewModel.isCropMode { + selectionGestureOverlay + } + + if viewModel.isCropMode { + PreviewCropOverlay(viewModel: viewModel, displaySize: imageSize, scale: 1.0 / displayScaleFactor) + } + } + .overlay(alignment: .topLeading) { + if let tool = viewModel.selectedTool { + activeToolIndicator(tool: tool) + .padding(8) + } else if viewModel.isCropMode { + cropModeIndicator + .padding(8) + } + } + .overlay(alignment: .bottom) { + if viewModel.cropRect != nil && !viewModel.isCropSelecting { + cropActionButtons + .padding(12) + } + } + .frame(width: imageSize.width, height: imageSize.height) + .onAppear { + imageDisplaySize = imageSize + imageScale = 1.0 + } + .contentShape(Rectangle()) + .cursor(cursorForCurrentTool) + } + + private var cursorForCurrentTool: NSCursor { + if viewModel.isCropMode { + return .crosshair + } + + guard let tool = viewModel.selectedTool else { + if viewModel.isDraggingAnnotation { + return .closedHand + } else if viewModel.selectedAnnotationIndex != nil { + return .openHand + } + return .arrow + } + + switch tool { + case .rectangle, .ellipse, .line, .freehand, .arrow, .highlight, .mosaic, .numberLabel: + return .crosshair + case .text: + return .iBeam + } + } + + private var drawingGestureOverlay: some View { + Color.clear + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let point = convertToImageCoordinates(value.location) + if value.translation == .zero { + viewModel.beginDrawing(at: point) + } else { + viewModel.continueDrawing(to: point) + } + } + .onEnded { value in + let point = convertToImageCoordinates(value.location) + viewModel.endDrawing(at: point) + } + ) + } + + private var selectionGestureOverlay: some View { + Color.clear + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let point = convertToImageCoordinates(value.location) + if value.translation == .zero { + if let hitIndex = viewModel.hitTest(at: point) { + viewModel.selectAnnotation(at: hitIndex) + viewModel.beginDraggingAnnotation(at: point) + } else { + viewModel.deselectAnnotation() + } + } else if viewModel.isDraggingAnnotation { + viewModel.continueDraggingAnnotation(to: point) + } + } + .onEnded { _ in + viewModel.endDraggingAnnotation() + } + ) + } + + private func convertToImageCoordinates(_ point: CGPoint) -> CGPoint { + CGPoint(x: point.x * displayScaleFactor, y: point.y * displayScaleFactor) + } + + private func textInputField(at position: CGPoint) -> some View { + let displayPosition = CGPoint( + x: position.x / displayScaleFactor, + y: position.y / displayScaleFactor + ) + return TextField(String(localized: "preview.enter.text"), text: $viewModel.textInputContent) + .textFieldStyle(.plain) + .font(.system(size: 14)) + .foregroundColor(AppSettings.shared.strokeColor.color) + .padding(4) + .background(Color.white.opacity(0.9)) + .cornerRadius(4) + .frame(minWidth: 100, maxWidth: 300) + .position(x: displayPosition.x + 50, y: displayPosition.y + 10) + .focused($isTextFieldFocused) + .onAppear { + isTextFieldFocused = true + } + .onSubmit { + viewModel.commitTextInput() + isTextFieldFocused = false + } + .onExitCommand { + viewModel.cancelCurrentDrawing() + isTextFieldFocused = false + } + } + + private func activeToolIndicator(tool: AnnotationToolType) -> some View { + HStack(spacing: 4) { + Image(systemName: tool.systemImage) + Text(tool.displayName) + .font(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.ultraThinMaterial) + .cornerRadius(6) + .foregroundStyle(.secondary) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text("\(String(localized: "preview.active.tool")): \(tool.displayName)")) + } + + private var cropModeIndicator: some View { + HStack(spacing: 4) { + Image(systemName: "crop") + Text("preview.crop") + .font(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.ultraThinMaterial) + .cornerRadius(6) + .foregroundStyle(.secondary) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text("preview.crop.mode.active")) + } + + private var cropActionButtons: some View { + HStack(spacing: 12) { + Button { + viewModel.cancelCrop() + } label: { + Label(String(localized: "action.cancel"), systemImage: "xmark") + } + .buttonStyle(.bordered) + .keyboardShortcut(.escape, modifiers: []) + + Button { + viewModel.applyCrop() + } label: { + Label(String(localized: "preview.crop.apply"), systemImage: "checkmark") + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return, modifiers: []) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.ultraThinMaterial) + .cornerRadius(10) + } +} + +struct PreviewCropOverlay: View { + @Bindable var viewModel: PreviewViewModel + let displaySize: CGSize + let scale: CGFloat + + var body: some View { + ZStack { + if let cropRect = viewModel.cropRect, cropRect.width > 0, cropRect.height > 0 { + let scaledRect = CGRect( + x: cropRect.origin.x * scale, + y: cropRect.origin.y * scale, + width: cropRect.width * scale, + height: cropRect.height * scale + ) + + CropDimOverlay(cropRect: scaledRect) + .fill(Color.black.opacity(0.5)) + .allowsHitTesting(false) + .transaction { $0.animation = nil } + + Rectangle() + .stroke(Color.white, lineWidth: 2) + .frame(width: scaledRect.width, height: scaledRect.height) + .position(x: scaledRect.midX, y: scaledRect.midY) + .allowsHitTesting(false) + + ForEach(0..<4, id: \.self) { corner in + let position = cornerPosition(for: corner, in: scaledRect) + RoundedRectangle(cornerRadius: 2) + .fill(Color.white) + .frame(width: 10, height: 10) + .position(position) + .allowsHitTesting(false) + } + + cropDimensionsLabel(for: cropRect, scaledRect: scaledRect) + } + + Color.clear + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let point = CGPoint(x: value.location.x / scale, y: value.location.y / scale) + if value.translation == .zero { + viewModel.beginCropSelection(at: point) + } else { + viewModel.continueCropSelection(to: point) + } + } + .onEnded { value in + let point = CGPoint(x: value.location.x / scale, y: value.location.y / scale) + viewModel.endCropSelection(at: point) + } + ) + } + } + + private func cornerPosition(for corner: Int, in rect: CGRect) -> CGPoint { + switch corner { + case 0: return CGPoint(x: rect.minX, y: rect.minY) + case 1: return CGPoint(x: rect.maxX, y: rect.minY) + case 2: return CGPoint(x: rect.minX, y: rect.maxY) + case 3: return CGPoint(x: rect.maxX, y: rect.maxY) + default: return .zero + } + } + + private func cropDimensionsLabel(for cropRect: CGRect, scaledRect: CGRect) -> some View { + let width = Int(cropRect.width) + let height = Int(cropRect.height) + + return Text("\(width) × \(height)") + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.black.opacity(0.75)) + .cornerRadius(4) + .position( + x: scaledRect.midX, + y: max(scaledRect.minY - 20, 15) + ) + .allowsHitTesting(false) + } +} diff --git a/ScreenTranslate/Features/Preview/PreviewContentView.swift b/ScreenTranslate/Features/Preview/PreviewContentView.swift new file mode 100644 index 0000000..8dd30c4 --- /dev/null +++ b/ScreenTranslate/Features/Preview/PreviewContentView.swift @@ -0,0 +1,191 @@ +import SwiftUI +import AppKit + +struct PreviewContentView: View { + @Bindable var viewModel: PreviewViewModel + @State private var imageDisplaySize: CGSize = .zero + @State private var imageScale: CGFloat = 1.0 + @State private var isResultsPanelExpanded: Bool = false + @FocusState private var isTextFieldFocused: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + VStack(spacing: 0) { + ScrollView([.horizontal, .vertical], showsIndicators: true) { + PreviewAnnotatedImageView( + viewModel: viewModel, + imageDisplaySize: $imageDisplaySize, + imageScale: $imageScale, + isTextFieldFocused: $isTextFieldFocused + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .windowBackgroundColor)) + + Divider() + + infoBar + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.bar) + + if viewModel.hasOCRResults { + Divider() + PreviewResultsPanel(viewModel: viewModel, isExpanded: $isResultsPanelExpanded) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.bar) + } + } + .alert( + String(localized: "error.title"), + isPresented: .constant(viewModel.errorMessage != nil), + presenting: viewModel.errorMessage + ) { _ in + Button(String(localized: "button.ok")) { + viewModel.errorMessage = nil + } + } message: { message in + Text(message) + } + .alert( + String(localized: "save.success.title"), + isPresented: .constant(viewModel.saveSuccessMessage != nil), + presenting: viewModel.saveSuccessMessage + ) { _ in + Button(String(localized: "button.ok")) { + viewModel.dismissSuccessMessage() + } + } message: { message in + Text(message) + } + .overlay(alignment: .top) { + if let message = viewModel.copySuccessMessage { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text(message) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.ultraThinMaterial) + .cornerRadius(8) + .shadow(radius: 4) + .padding(.top, 20) + .transition(.move(edge: .top).combined(with: .opacity)) + .onTapGesture { + viewModel.dismissCopySuccessMessage() + } + } + } + .animation(.easeInOut(duration: 0.3), value: viewModel.copySuccessMessage != nil) + } + + private var infoBar: some View { + HStack(spacing: 12) { + HStack(spacing: 8) { + Text(viewModel.dimensionsText) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .help(String(localized: "preview.image.dimensions")) + + Text("•") + .foregroundStyle(.tertiary) + + Text(viewModel.fileSizeText) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .help(String(localized: "preview.estimated.size")) + } + .fixedSize() + + Divider() + .frame(height: 16) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + PreviewToolBar(viewModel: viewModel) + } + .padding(.horizontal, 4) + } + .frame(minWidth: 100) + + Divider() + .frame(height: 16) + + PreviewActionButtons(viewModel: viewModel) + .fixedSize() + } + } +} + +#if DEBUG +#Preview { + let testImage: CGImage = { + let width = 800 + let height = 600 + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + // Fallback: create a 1x1 placeholder image + let placeholder = NSImage(size: CGSize(width: 1, height: 1)) + if let cgImage = placeholder.cgImage(forProposedRect: nil, context: nil, hints: nil) { + return cgImage + } + // Ultimate fallback: create a 1x1 white CGImage + let fallbackColorSpace = CGColorSpaceCreateDeviceRGB() + guard let fallbackContext = CGContext( + data: nil, + width: 1, + height: 1, + bitsPerComponent: 8, + bytesPerRow: 4, + space: fallbackColorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ), let fallbackImage = fallbackContext.makeImage() else { + fatalError("Unable to create fallback image") + } + return fallbackImage + } + + context.setFillColor(NSColor.systemBlue.cgColor) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + + let fallbackImage = NSImage(size: CGSize(width: 1, height: 1)) + let cgFallback: CGImage + if let img = fallbackImage.cgImage(forProposedRect: nil, context: nil, hints: nil) { + cgFallback = img + } else if let contextImage = context.makeImage() { + cgFallback = contextImage + } else { + fatalError("Unable to create fallback CGImage") + } + return context.makeImage() ?? cgFallback + }() + + let display = DisplayInfo( + id: 1, + name: "Built-in Display", + frame: CGRect(x: 0, y: 0, width: 1920, height: 1080), + scaleFactor: 2.0, + isPrimary: true + ) + + let screenshot = Screenshot( + image: testImage, + sourceDisplay: display + ) + + let viewModel = PreviewViewModel(screenshot: screenshot) + + return PreviewContentView(viewModel: viewModel) + .frame(width: 600, height: 400) +} +#endif diff --git a/ScreenTranslate/Features/Preview/PreviewResultsPanel.swift b/ScreenTranslate/Features/Preview/PreviewResultsPanel.swift new file mode 100644 index 0000000..f056ff7 --- /dev/null +++ b/ScreenTranslate/Features/Preview/PreviewResultsPanel.swift @@ -0,0 +1,57 @@ +import SwiftUI +import AppKit + +struct PreviewResultsPanel: View { + @Bindable var viewModel: PreviewViewModel + @Binding var isExpanded: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + } + } label: { + HStack { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .frame(width: 12) + Text("preview.results.panel") + .font(.caption) + .fontWeight(.medium) + Spacer() + } + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + VStack(alignment: .leading, spacing: 12) { + if viewModel.hasOCRResults { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("preview.recognized.text") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(viewModel.combinedOCRText, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + .font(.caption) + } + .buttonStyle(.plain) + .help(String(localized: "preview.copy.text")) + } + Text(viewModel.combinedOCRText) + .font(.body) + .textSelection(.enabled) + } + } + } + .padding(.top, 8) + } + } + } +} diff --git a/ScreenTranslate/Features/Preview/PreviewToolBar.swift b/ScreenTranslate/Features/Preview/PreviewToolBar.swift new file mode 100644 index 0000000..1c065e8 --- /dev/null +++ b/ScreenTranslate/Features/Preview/PreviewToolBar.swift @@ -0,0 +1,424 @@ +import SwiftUI +import AppKit + +struct PreviewToolBar: View { + @Bindable var viewModel: PreviewViewModel + + var body: some View { + HStack(spacing: 4) { + ForEach(AnnotationToolType.allCases) { tool in + toolButton(for: tool) + } + + if viewModel.selectedTool != nil || viewModel.selectedAnnotationIndex != nil { + Divider() + .frame(height: 16) + + PreviewStyleCustomizationBar(viewModel: viewModel) + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel(Text("Annotation tools")) + } + + private func toolButton(for tool: AnnotationToolType) -> some View { + let isSelected = viewModel.selectedTool == tool + return Button { + if isSelected { + viewModel.selectTool(nil) + } else { + viewModel.selectTool(tool) + } + } label: { + Image(systemName: tool.systemImage) + .frame(width: 24, height: 24) + } + .buttonStyle(.accessoryBar) + .background( + isSelected + ? Color.accentColor.opacity(0.2) + : Color.clear + ) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .customTooltip("\(tool.displayName) (\(String(tool.keyboardShortcut).uppercased()))") + .accessibilityLabel(Text(tool.displayName)) + .accessibilityHint(Text("Press \(String(tool.keyboardShortcut).uppercased()) to toggle")) + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + } +} + +struct PreviewStyleCustomizationBar: View { + @Bindable var viewModel: PreviewViewModel + + private var isEditingAnnotation: Bool { + viewModel.selectedAnnotationIndex != nil + } + + private var effectiveToolType: AnnotationToolType? { + isEditingAnnotation ? viewModel.selectedAnnotationType : viewModel.selectedTool + } + + private var presetColors: [Color] { + [.red, .yellow, .green, .blue, .black] + } + + var body: some View { + HStack(spacing: 8) { + if isEditingAnnotation { + Text("preview.edit.label") + .font(.caption) + .foregroundStyle(.secondary) + } + + if effectiveToolType != .mosaic { + colorPicker + + Divider() + .frame(height: 16) + } + + if effectiveToolType == .rectangle || effectiveToolType == .ellipse { + shapeFillToggle + Divider() + .frame(height: 16) + } + + if shouldShowStrokeWidth { + strokeWidthControl + } + + if effectiveToolType == .text { + textSizeControl + } + + if effectiveToolType == .mosaic { + blockSizeControl + } + + if isEditingAnnotation { + Divider() + .frame(height: 16) + + deleteButton + } + } + } + + private var shouldShowStrokeWidth: Bool { + if effectiveToolType == .freehand || effectiveToolType == .arrow || effectiveToolType == .line { + return true + } + + if effectiveToolType == .rectangle { + let isFilled = isEditingAnnotation + ? (viewModel.selectedAnnotationIsFilled ?? false) + : AppSettings.shared.rectangleFilled + return !isFilled + } + + if effectiveToolType == .ellipse { + let isFilled = isEditingAnnotation + ? (viewModel.selectedAnnotationIsFilled ?? false) + : AppSettings.shared.ellipseFilled + return !isFilled + } + + return false + } + + private var colorPicker: some View { + HStack(spacing: 2) { + ForEach(presetColors, id: \.self) { color in + colorButton(for: color) + } + + ColorPicker( + "", + selection: Binding( + get: { + if isEditingAnnotation { + return viewModel.selectedAnnotationColor?.color ?? .red + } + return AppSettings.shared.strokeColor.color + }, + set: { newColor in + if isEditingAnnotation { + viewModel.updateSelectedAnnotationColor(CodableColor(newColor)) + } else { + AppSettings.shared.strokeColor = CodableColor(newColor) + } + } + ), + supportsOpacity: false + ) + .labelsHidden() + .frame(width: 24) + } + } + + private func colorButton(for color: Color) -> some View { + Button { + if isEditingAnnotation { + viewModel.updateSelectedAnnotationColor(CodableColor(color)) + } else { + AppSettings.shared.strokeColor = CodableColor(color) + } + } label: { + Circle() + .fill(color) + .frame(width: 16, height: 16) + .overlay { + let currentColor = isEditingAnnotation + ? (viewModel.selectedAnnotationColor?.color ?? .clear) + : AppSettings.shared.strokeColor.color + if colorsAreEqual(currentColor, color) { + Circle() + .stroke(Color.primary, lineWidth: 2) + } + } + .overlay { + if color == .white || color == .yellow { + Circle() + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + } + } + } + .buttonStyle(.plain) + .help(colorName(for: color)) + } + + private var shapeFillToggle: some View { + let isFilled = isEditingAnnotation + ? (viewModel.selectedAnnotationIsFilled ?? false) + : (effectiveToolType == .ellipse ? AppSettings.shared.ellipseFilled : AppSettings.shared.rectangleFilled) + + return Button { + if isEditingAnnotation { + viewModel.updateSelectedAnnotationFilled(!isFilled) + } else { + if effectiveToolType == .ellipse { + AppSettings.shared.ellipseFilled.toggle() + } else { + AppSettings.shared.rectangleFilled.toggle() + } + } + } label: { + Image(systemName: isFilled ? getFillIconName() : getHollowIconName()) + .frame(width: 24, height: 24) + } + .buttonStyle(.accessoryBar) + .background( + isFilled + ? Color.accentColor.opacity(0.2) + : Color.clear + ) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .help(isFilled ? String(localized: "preview.shape.filled") : String(localized: "preview.shape.hollow")) + } + + private func getFillIconName() -> String { + effectiveToolType == .ellipse ? "circle.fill" : "rectangle.fill" + } + + private func getHollowIconName() -> String { + effectiveToolType == .ellipse ? "circle" : "rectangle" + } + + private var strokeWidthControl: some View { + HStack(spacing: 4) { + Image(systemName: "lineweight") + .font(.caption) + .foregroundStyle(.secondary) + + Slider( + value: Binding( + get: { + if isEditingAnnotation { + return viewModel.selectedAnnotationStrokeWidth ?? 3.0 + } + return AppSettings.shared.strokeWidth + }, + set: { newWidth in + if isEditingAnnotation { + viewModel.updateSelectedAnnotationStrokeWidth(newWidth) + } else { + AppSettings.shared.strokeWidth = newWidth + } + } + ), + in: 1.0...20.0, + step: 0.5 + ) + .frame(width: 80) + .help(String(localized: "settings.stroke.width")) + + let width = isEditingAnnotation + ? Int(viewModel.selectedAnnotationStrokeWidth ?? 3) + : Int(AppSettings.shared.strokeWidth) + Text("\(width)") + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 20) + } + } + + private var textSizeControl: some View { + HStack(spacing: 4) { + Image(systemName: "textformat.size") + .font(.caption) + .foregroundStyle(.secondary) + + Slider( + value: Binding( + get: { + if isEditingAnnotation { + return viewModel.selectedAnnotationFontSize ?? 16.0 + } + return AppSettings.shared.textSize + }, + set: { newSize in + if isEditingAnnotation { + viewModel.updateSelectedAnnotationFontSize(newSize) + } else { + AppSettings.shared.textSize = newSize + } + } + ), + in: 8.0...72.0, + step: 1 + ) + .frame(width: 80) + .help(String(localized: "settings.text.size")) + + let size = isEditingAnnotation + ? Int(viewModel.selectedAnnotationFontSize ?? 16) + : Int(AppSettings.shared.textSize) + Text("\(size)") + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 20) + } + } + + private var blockSizeControl: some View { + HStack(spacing: 4) { + Image(systemName: "square.grid.3x3") + .font(.caption) + .foregroundStyle(.secondary) + + Slider( + value: Binding( + get: { + if isEditingAnnotation { + return Double(viewModel.selectedAnnotationBlockSize ?? 10) + } + return Double(AppSettings.shared.mosaicBlockSize) + }, + set: { newSize in + if isEditingAnnotation { + viewModel.updateSelectedAnnotationBlockSize(Int(newSize)) + } else { + AppSettings.shared.mosaicBlockSize = CGFloat(newSize) + } + } + ), + in: 1.0...32.0, + step: 1 + ) + .frame(width: 80) + .help(String(localized: "settings.mosaic.blockSize")) + + let size = isEditingAnnotation + ? Int(viewModel.selectedAnnotationBlockSize ?? 10) + : Int(AppSettings.shared.mosaicBlockSize) + Text("\(size)") + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 20) + } + } + + private var deleteButton: some View { + Button { + viewModel.deleteSelectedAnnotation() + } label: { + Image(systemName: "trash") + .foregroundStyle(.red) + } + .buttonStyle(.plain) + .help(String(localized: "preview.tooltip.delete")) + } + + private func colorsAreEqual(_ colorA: Color, _ colorB: Color) -> Bool { + let nsColorA = NSColor(colorA).usingColorSpace(.deviceRGB) + let nsColorB = NSColor(colorB).usingColorSpace(.deviceRGB) + guard let convertedA = nsColorA, let convertedB = nsColorB else { return false } + + let tolerance: CGFloat = 0.01 + return abs(convertedA.redComponent - convertedB.redComponent) < tolerance && + abs(convertedA.greenComponent - convertedB.greenComponent) < tolerance && + abs(convertedA.blueComponent - convertedB.blueComponent) < tolerance + } + + private func colorName(for color: Color) -> String { + switch color { + case .red: return String(localized: "color.red") + case .orange: return String(localized: "color.orange") + case .yellow: return String(localized: "color.yellow") + case .green: return String(localized: "color.green") + case .blue: return String(localized: "color.blue") + case .purple: return String(localized: "color.purple") + case .white: return String(localized: "color.white") + case .black: return String(localized: "color.black") + default: return String(localized: "color.custom") + } + } +} + +// MARK: - Custom Tooltip for Floating Windows + +extension View { + /// Custom tooltip that works in floating windows where .help() may not display + func customTooltip(_ text: String) -> some View { + self.modifier(CustomTooltipModifier(text: text)) + } +} + +struct CustomTooltipModifier: ViewModifier { + let text: String + @State private var isHovered = false + @State private var showTooltip = false + + func body(content: Content) -> some View { + content + .onHover { hovering in + isHovered = hovering + if hovering { + // Short delay before showing tooltip + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + if isHovered { + showTooltip = true + } + } + } else { + showTooltip = false + } + } + .overlay(alignment: .top) { + if showTooltip { + Text(text) + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color(nsColor: .windowBackgroundColor)) + .foregroundColor(.primary) + .cornerRadius(4) + .shadow(color: .black.opacity(0.2), radius: 2, x: 0, y: 1) + .offset(y: -28) + .transition(.opacity) + .fixedSize() + .compositingGroup() + } + } + } +} diff --git a/ScreenTranslate/Features/Preview/PreviewViewModel+Crop.swift b/ScreenTranslate/Features/Preview/PreviewViewModel+Crop.swift new file mode 100644 index 0000000..0b36054 --- /dev/null +++ b/ScreenTranslate/Features/Preview/PreviewViewModel+Crop.swift @@ -0,0 +1,82 @@ +import Foundation +import SwiftUI + +extension PreviewViewModel { + func toggleCropMode() { + isCropMode.toggle() + } + + func beginCropSelection(at point: CGPoint) { + guard isCropMode else { return } + cropStartPoint = point + cropRect = CGRect(origin: point, size: .zero) + isCropSelecting = true + } + + func continueCropSelection(to point: CGPoint) { + guard isCropMode, let start = cropStartPoint else { return } + + let minX = min(start.x, point.x) + let minY = min(start.y, point.y) + let width = abs(point.x - start.x) + let height = abs(point.y - start.y) + + cropRect = CGRect(x: minX, y: minY, width: width, height: height) + } + + func endCropSelection(at point: CGPoint) { + guard isCropMode else { return } + continueCropSelection(to: point) + isCropSelecting = false + + if let rect = cropRect, rect.width < 10 || rect.height < 10 { + cropRect = nil + } + } + + func applyCrop() { + guard let rect = cropRect else { return } + + let imageWidth = CGFloat(screenshot.image.width) + let imageHeight = CGFloat(screenshot.image.height) + + let clampedRect = CGRect( + x: max(0, rect.origin.x), + y: max(0, rect.origin.y), + width: min(rect.width, imageWidth - rect.origin.x), + height: min(rect.height, imageHeight - rect.origin.y) + ) + + guard clampedRect.width >= 10, clampedRect.height >= 10 else { + errorMessage = "Crop area is too small" + cropRect = nil + isCropMode = false + return + } + + guard let croppedImage = screenshot.image.cropping(to: clampedRect) else { + errorMessage = "Failed to crop image" + return + } + + pushUndoState() + + screenshot = Screenshot( + image: croppedImage, + captureDate: screenshot.captureDate, + sourceDisplay: screenshot.sourceDisplay + ) + + redoStack.removeAll() + isCropMode = false + cropRect = nil + imageSizeChangeCounter += 1 + } + + func cancelCrop() { + cropRect = nil + isCropMode = false + isCropSelecting = false + cropStartPoint = nil + } +} diff --git a/ScreenTranslate/Features/Preview/PreviewViewModel+Drawing.swift b/ScreenTranslate/Features/Preview/PreviewViewModel+Drawing.swift new file mode 100644 index 0000000..d320a25 --- /dev/null +++ b/ScreenTranslate/Features/Preview/PreviewViewModel+Drawing.swift @@ -0,0 +1,512 @@ +import Foundation +import SwiftUI + +// MARK: - Drawing & Annotation Methods + +extension PreviewViewModel { + // MARK: - Drawing Methods + + /// Begins a drawing gesture at the given point + /// - Parameter point: The point in image coordinates + func beginDrawing(at point: CGPoint) { + guard let selectedTool else { return } + + // Apply current stroke/text styles from settings + let strokeStyle = StrokeStyle( + color: settings.strokeColor, + lineWidth: settings.strokeWidth + ) + let textStyle = TextStyle( + color: settings.strokeColor, + fontSize: settings.textSize, + fontName: ".AppleSystemUIFont" + ) + + switch selectedTool { + case .rectangle: + rectangleTool.strokeStyle = strokeStyle + rectangleTool.isFilled = settings.rectangleFilled + rectangleTool.beginDrawing(at: point) + case .ellipse: + ellipseTool.strokeStyle = strokeStyle + ellipseTool.isFilled = settings.ellipseFilled + ellipseTool.beginDrawing(at: point) + case .line: + lineTool.strokeStyle = strokeStyle + lineTool.beginDrawing(at: point) + case .freehand: + freehandTool.strokeStyle = strokeStyle + freehandTool.beginDrawing(at: point) + case .arrow: + arrowTool.strokeStyle = strokeStyle + arrowTool.beginDrawing(at: point) + case .highlight: + highlightTool.beginDrawing(at: point) + case .mosaic: + mosaicTool.blockSize = Int(settings.mosaicBlockSize) + mosaicTool.beginDrawing(at: point) + case .text: + textTool.textStyle = textStyle + textTool.beginDrawing(at: point) + isWaitingForTextInputInternal = true + textInputPositionInternal = point + case .numberLabel: + numberLabelTool.beginDrawing(at: point) + } + + updateCurrentAnnotation() + } + + /// Continues a drawing gesture to the given point + /// - Parameter point: The point in image coordinates + func continueDrawing(to point: CGPoint) { + guard let selectedTool else { return } + + switch selectedTool { + case .rectangle: + rectangleTool.continueDrawing(to: point) + case .ellipse: + ellipseTool.continueDrawing(to: point) + case .line: + lineTool.continueDrawing(to: point) + case .freehand: + freehandTool.continueDrawing(to: point) + case .arrow: + arrowTool.continueDrawing(to: point) + case .highlight: + highlightTool.continueDrawing(to: point) + case .mosaic: + mosaicTool.continueDrawing(to: point) + case .text: + textTool.continueDrawing(to: point) + case .numberLabel: + numberLabelTool.continueDrawing(to: point) + } + + updateCurrentAnnotation() + } + + /// Ends a drawing gesture at the given point + /// - Parameter point: The point in image coordinates + func endDrawing(at point: CGPoint) { + guard let selectedTool else { return } + + var annotation: Annotation? + + switch selectedTool { + case .rectangle: + annotation = rectangleTool.endDrawing(at: point) + case .ellipse: + annotation = ellipseTool.endDrawing(at: point) + case .line: + annotation = lineTool.endDrawing(at: point) + case .freehand: + annotation = freehandTool.endDrawing(at: point) + case .arrow: + annotation = arrowTool.endDrawing(at: point) + case .highlight: + annotation = highlightTool.endDrawing(at: point) + case .mosaic: + annotation = mosaicTool.endDrawing(at: point) + case .text: + _ = textTool.endDrawing(at: point) + updateCurrentAnnotation() + return + case .numberLabel: + annotation = numberLabelTool.endDrawing(at: point) + } + + currentAnnotationInternal = nil + drawingUpdateCounter += 1 + + if let annotation { + addAnnotation(annotation) + } + } + + /// Cancels the current drawing operation + func cancelCurrentDrawing() { + rectangleTool.cancelDrawing() + ellipseTool.cancelDrawing() + lineTool.cancelDrawing() + freehandTool.cancelDrawing() + arrowTool.cancelDrawing() + highlightTool.cancelDrawing() + mosaicTool.cancelDrawing() + textTool.cancelDrawing() + numberLabelTool.cancelDrawing() + currentAnnotationInternal = nil + isWaitingForTextInputInternal = false + textInputPositionInternal = nil + drawingUpdateCounter += 1 + } + + /// Updates the cached current annotation to trigger view refresh + func updateCurrentAnnotation() { + currentAnnotationInternal = currentTool?.currentAnnotation + drawingUpdateCounter += 1 + } + + /// Commits the current text input and adds the annotation + func commitTextInput() { + guard let position = textInputPositionInternal else { return } + + let trimmedText = textInputContent.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedText.isEmpty else { + cancelCurrentDrawing() + return + } + + let textStyle = TextStyle( + color: settings.strokeColor, + fontSize: settings.textSize, + fontName: ".AppleSystemUIFont" + ) + let annotation = Annotation.text( + TextAnnotation(position: position, content: trimmedText, style: textStyle) + ) + addAnnotation(annotation) + + // Reset observable text input state + textInputContent = "" + isWaitingForTextInputInternal = false + textInputPositionInternal = nil + } + + // MARK: - Annotation Selection & Editing + + /// Tests if a point hits an annotation and returns its index + /// - Parameter point: The point to test in image coordinates + /// - Returns: The index of the hit annotation, or nil if none hit + func hitTest(at point: CGPoint) -> Int? { + // Check in reverse order (top-most first) + for (index, annotation) in annotations.enumerated().reversed() { + let bounds = annotation.bounds + // Add some padding for easier selection + let expandedBounds = bounds.insetBy(dx: -10, dy: -10) + if expandedBounds.contains(point) { + return index + } + } + return nil + } + + /// Selects the annotation at the given index + func selectAnnotation(at index: Int?) { + // Deselect any tool when selecting an annotation + if index != nil && selectedTool != nil { + selectedTool = nil + } + selectedAnnotationIndex = index + } + + /// Deselects any selected annotation + func deselectAnnotation() { + selectedAnnotationIndex = nil + isDraggingAnnotation = false + dragStartPoint = nil + dragOriginalPosition = nil + } + + /// Deletes the currently selected annotation + func deleteSelectedAnnotation() { + guard let index = selectedAnnotationIndex else { return } + pushUndoState() + screenshot = screenshot.removingAnnotation(at: index) + redoStack.removeAll() + selectedAnnotationIndex = nil + } + + /// Begins dragging the selected annotation + func beginDraggingAnnotation(at point: CGPoint) { + guard let index = selectedAnnotationIndex, + index < annotations.count else { return } + + isDraggingAnnotation = true + dragStartPoint = point + + // Store the original position based on annotation type + let annotation = annotations[index] + switch annotation { + case .rectangle(let rect): + dragOriginalPosition = rect.rect.origin + case .ellipse(let ellipse): + dragOriginalPosition = ellipse.rect.origin + case .line(let line): + dragOriginalPosition = line.bounds.origin + case .freehand(let freehand): + dragOriginalPosition = freehand.bounds.origin + case .arrow(let arrow): + dragOriginalPosition = arrow.bounds.origin + case .highlight(let highlight): + dragOriginalPosition = highlight.rect.origin + case .mosaic(let mosaic): + dragOriginalPosition = mosaic.rect.origin + case .text(let text): + dragOriginalPosition = text.position + case .numberLabel(let label): + dragOriginalPosition = label.position + } + } + + /// Continues dragging the selected annotation + func continueDraggingAnnotation(to point: CGPoint) { + guard isDraggingAnnotation, + let index = selectedAnnotationIndex, + let startPoint = dragStartPoint, + let originalPosition = dragOriginalPosition, + index < annotations.count else { return } + + let delta = CGPoint( + x: point.x - startPoint.x, + y: point.y - startPoint.y + ) + + let annotation = annotations[index] + var updatedAnnotation: Annotation? + + switch annotation { + case .rectangle(var rect): + rect.rect.origin = CGPoint( + x: originalPosition.x + delta.x, + y: originalPosition.y + delta.y + ) + updatedAnnotation = .rectangle(rect) + + case .ellipse(var ellipse): + ellipse.rect.origin = CGPoint( + x: originalPosition.x + delta.x, + y: originalPosition.y + delta.y + ) + updatedAnnotation = .ellipse(ellipse) + + case .line(var line): + let bounds = line.bounds + let offsetX = originalPosition.x + delta.x - bounds.origin.x + let offsetY = originalPosition.y + delta.y - bounds.origin.y + line.startPoint = CGPoint(x: line.startPoint.x + offsetX, y: line.startPoint.y + offsetY) + line.endPoint = CGPoint(x: line.endPoint.x + offsetX, y: line.endPoint.y + offsetY) + updatedAnnotation = .line(line) + + case .freehand(var freehand): + let bounds = freehand.bounds + let offsetX = originalPosition.x + delta.x - bounds.origin.x + let offsetY = originalPosition.y + delta.y - bounds.origin.y + freehand.points = freehand.points.map { point in + CGPoint(x: point.x + offsetX, y: point.y + offsetY) + } + updatedAnnotation = .freehand(freehand) + + case .arrow(var arrow): + let bounds = arrow.bounds + let offsetX = originalPosition.x + delta.x - bounds.origin.x + let offsetY = originalPosition.y + delta.y - bounds.origin.y + arrow.startPoint = CGPoint( + x: arrow.startPoint.x + offsetX, + y: arrow.startPoint.y + offsetY + ) + arrow.endPoint = CGPoint( + x: arrow.endPoint.x + offsetX, + y: arrow.endPoint.y + offsetY + ) + updatedAnnotation = .arrow(arrow) + + case .highlight(var highlight): + highlight.rect.origin = CGPoint( + x: originalPosition.x + delta.x, + y: originalPosition.y + delta.y + ) + updatedAnnotation = .highlight(highlight) + + case .mosaic(var mosaic): + mosaic.rect.origin = CGPoint( + x: originalPosition.x + delta.x, + y: originalPosition.y + delta.y + ) + updatedAnnotation = .mosaic(mosaic) + + case .text(var text): + text.position = CGPoint( + x: originalPosition.x + delta.x, + y: originalPosition.y + delta.y + ) + updatedAnnotation = .text(text) + + case .numberLabel(var label): + label.position = CGPoint( + x: originalPosition.x + delta.x, + y: originalPosition.y + delta.y + ) + updatedAnnotation = .numberLabel(label) + } + + if let updated = updatedAnnotation { + // Update without pushing undo (will push on end) + screenshot.annotations[index] = updated + drawingUpdateCounter += 1 + } + } + + /// Ends dragging the selected annotation + func endDraggingAnnotation() { + isDraggingAnnotation = false + dragStartPoint = nil + dragOriginalPosition = nil + } + + /// Updates the color of the selected annotation + func updateSelectedAnnotationColor(_ color: CodableColor) { + guard let index = selectedAnnotationIndex, + index < annotations.count else { return } + + pushUndoState() + let annotation = annotations[index] + var updatedAnnotation: Annotation? + + switch annotation { + case .rectangle(var rect): + rect.style.color = color + updatedAnnotation = .rectangle(rect) + + case .ellipse(var ellipse): + ellipse.style.color = color + updatedAnnotation = .ellipse(ellipse) + + case .line(var line): + line.style.color = color + updatedAnnotation = .line(line) + + case .freehand(var freehand): + freehand.style.color = color + updatedAnnotation = .freehand(freehand) + + case .arrow(var arrow): + arrow.style.color = color + updatedAnnotation = .arrow(arrow) + + case .highlight(var highlight): + highlight.color = color + updatedAnnotation = .highlight(highlight) + + case .mosaic: + return + + case .text(var text): + text.style.color = color + updatedAnnotation = .text(text) + + case .numberLabel(var label): + label.color = color + updatedAnnotation = .numberLabel(label) + } + + if let updated = updatedAnnotation { + screenshot = screenshot.replacingAnnotation(at: index, with: updated) + redoStack.removeAll() + } + } + + /// Updates the stroke width of the selected annotation + func updateSelectedAnnotationStrokeWidth(_ width: CGFloat) { + guard let index = selectedAnnotationIndex, + index < annotations.count else { return } + + pushUndoState() + let annotation = annotations[index] + var updatedAnnotation: Annotation? + + switch annotation { + case .rectangle(var rect): + rect.style.lineWidth = width + updatedAnnotation = .rectangle(rect) + + case .ellipse(var ellipse): + ellipse.style.lineWidth = width + updatedAnnotation = .ellipse(ellipse) + + case .line(var line): + line.style.lineWidth = width + updatedAnnotation = .line(line) + + case .freehand(var freehand): + freehand.style.lineWidth = width + updatedAnnotation = .freehand(freehand) + + case .arrow(var arrow): + arrow.style.lineWidth = width + updatedAnnotation = .arrow(arrow) + + case .highlight, .mosaic, .text, .numberLabel: + return + } + + if let updated = updatedAnnotation { + screenshot = screenshot.replacingAnnotation(at: index, with: updated) + redoStack.removeAll() + } + } + + /// Updates the font size of the selected text annotation + func updateSelectedAnnotationFontSize(_ size: CGFloat) { + guard let index = selectedAnnotationIndex, + index < annotations.count else { return } + + let annotation = annotations[index] + guard case .text(var text) = annotation else { return } + + pushUndoState() + text.style.fontSize = size + screenshot = screenshot.replacingAnnotation(at: index, with: .text(text)) + redoStack.removeAll() + } + + /// Updates the isFilled state of the selected rectangle or ellipse annotation + func updateSelectedAnnotationFilled(_ isFilled: Bool) { + guard let index = selectedAnnotationIndex, + index < annotations.count else { return } + + let annotation = annotations[index] + + // Check if the fill state is actually changing + let shouldUpdate: Bool + if case .rectangle(let rect) = annotation { + shouldUpdate = rect.isFilled != isFilled + } else if case .ellipse(let ellipse) = annotation { + shouldUpdate = ellipse.isFilled != isFilled + } else { + return + } + + guard shouldUpdate else { return } + + pushUndoState() + + if case .rectangle(var rect) = annotation { + rect.isFilled = isFilled + screenshot = screenshot.replacingAnnotation(at: index, with: .rectangle(rect)) + } else if case .ellipse(var ellipse) = annotation { + ellipse.isFilled = isFilled + screenshot = screenshot.replacingAnnotation(at: index, with: .ellipse(ellipse)) + } + + redoStack.removeAll() + } + + /// Updates the block size of the selected mosaic annotation + func updateSelectedAnnotationBlockSize(_ blockSize: Int) { + guard let index = selectedAnnotationIndex, + index < annotations.count else { return } + + let annotation = annotations[index] + guard case .mosaic(var mosaic) = annotation else { return } + + // Enforce minimum block size of 1 to prevent rendering errors + let clampedBlockSize = max(1, blockSize) + guard mosaic.blockSize != clampedBlockSize else { return } + + pushUndoState() + mosaic.blockSize = clampedBlockSize + screenshot = screenshot.replacingAnnotation(at: index, with: .mosaic(mosaic)) + redoStack.removeAll() + } +} diff --git a/ScreenTranslate/Features/Preview/PreviewViewModel+Export.swift b/ScreenTranslate/Features/Preview/PreviewViewModel+Export.swift new file mode 100644 index 0000000..7770bc3 --- /dev/null +++ b/ScreenTranslate/Features/Preview/PreviewViewModel+Export.swift @@ -0,0 +1,96 @@ +import Foundation +import SwiftUI +import AppKit + +extension PreviewViewModel { + @discardableResult + func copyToClipboard() -> Bool { + guard !isCopying else { return false } + isCopying = true + + do { + try clipboardService.copy(image, annotations: annotations) + isCopying = false + return true + } catch { + errorMessage = NSLocalizedString("error.clipboard.write.failed", comment: "Failed to copy to clipboard") + clearError() + isCopying = false + return false + } + } + + func saveScreenshot() { + guard !isSaving else { return } + isSaving = true + + Task { + await performSave() + } + } + + func performSave() async { + defer { isSaving = false } + + let directory = settings.saveLocation + let format = settings.defaultFormat + let quality: Double + switch format { + case .jpeg: + quality = settings.jpegQuality + case .heic: + quality = settings.heicQuality + case .png: + quality = 1.0 + } + + let fileURL = imageExporter.generateFileURL(in: directory, format: format) + + do { + try imageExporter.save( + image, + annotations: annotations, + to: fileURL, + format: format, + quality: quality + ) + + screenshot = screenshot.saved(to: fileURL) + onSave?(fileURL) + hide() + } catch let error as ScreenTranslateError { + handleSaveError(error) + } catch { + errorMessage = NSLocalizedString("error.save.unknown", comment: "An unexpected error occurred while saving") + clearError() + } + } + + func handleSaveError(_ error: ScreenTranslateError) { + switch error { + case .invalidSaveLocation(let url): + errorMessage = String( + format: NSLocalizedString("error.save.location.invalid.detail", comment: ""), + url.path + ) + case .diskFull: + errorMessage = NSLocalizedString("error.disk.full", comment: "Not enough disk space") + case .exportEncodingFailed(let format): + errorMessage = String( + format: NSLocalizedString("error.export.encoding.failed.detail", comment: ""), + format.displayName + ) + default: + errorMessage = error.localizedDescription + } + clearError() + } + + func dismissSuccessMessage() { + saveSuccessMessage = nil + } + + func dismissCopySuccessMessage() { + copySuccessMessage = nil + } +} diff --git a/ScreenTranslate/Features/Preview/PreviewViewModel+OCR.swift b/ScreenTranslate/Features/Preview/PreviewViewModel+OCR.swift new file mode 100644 index 0000000..face706 --- /dev/null +++ b/ScreenTranslate/Features/Preview/PreviewViewModel+OCR.swift @@ -0,0 +1,29 @@ +import Foundation +import SwiftUI +import AppKit + +extension PreviewViewModel { + func performOCR() { + guard !isPerformingOCR else { return } + isPerformingOCR = true + ocrTranslationError = nil + + Task { + await executeOCR() + } + } + + func executeOCR() async { + defer { isPerformingOCR = false } + + do { + let result = try await ocrService.recognize( + image, + languages: [.english, .chineseSimplified] + ) + ocrResult = result + } catch { + ocrTranslationError = "OCR failed: \(error.localizedDescription)" + } + } +} diff --git a/ScreenTranslate/Features/Preview/PreviewViewModel.swift b/ScreenTranslate/Features/Preview/PreviewViewModel.swift new file mode 100644 index 0000000..b7a9c01 --- /dev/null +++ b/ScreenTranslate/Features/Preview/PreviewViewModel.swift @@ -0,0 +1,471 @@ +import Foundation +import SwiftUI +import AppKit +import Observation + +/// ViewModel for the screenshot preview window. +/// Manages screenshot state, annotations, and user actions. +/// Must run on MainActor for UI binding. +@MainActor +@Observable +final class PreviewViewModel { + // MARK: - Properties + + var screenshot: Screenshot + + @ObservationIgnored + private(set) var isVisible: Bool = false + + var selectedTool: AnnotationToolType? { + didSet { + if oldValue != selectedTool { + cancelCurrentDrawing() + } + if selectedTool != nil && isCropMode { + isCropMode = false + cropRect = nil + } + } + } + + var isCropMode: Bool = false { + didSet { + if isCropMode && selectedTool != nil { + selectedTool = nil + } + if !isCropMode { + cropRect = nil + } + } + } + + var cropRect: CGRect? + var isCropSelecting: Bool = false + var cropStartPoint: CGPoint? + var errorMessage: String? + + var isSaving: Bool = false + var isCopying: Bool = false + var copySuccessMessage: String? + + var isSavingWithTranslations: Bool = false + var saveSuccessMessage: String? + + @ObservationIgnored + var onDismiss: (() -> Void)? + + @ObservationIgnored + var onSave: ((URL) -> Void)? + + @ObservationIgnored + let settings = AppSettings.shared + + @ObservationIgnored + let imageExporter = ImageExporter.shared + + @ObservationIgnored + let clipboardService = ClipboardService.shared + + @ObservationIgnored + let ocrService = OCRService.shared + + @ObservationIgnored + let translationEngine = TranslationEngine.shared + + var ocrResult: OCRResult? + var isPerformingOCR: Bool = false + var ocrTranslationError: String? + + // MARK: - Annotation Tools + + @ObservationIgnored + var rectangleTool = RectangleTool() + + @ObservationIgnored + var ellipseTool = EllipseTool() + + @ObservationIgnored + var lineTool = LineTool() + + @ObservationIgnored + var freehandTool = FreehandTool() + + @ObservationIgnored + var arrowTool = ArrowTool() + + @ObservationIgnored + var highlightTool = HighlightTool() + + @ObservationIgnored + var mosaicTool = MosaicTool() + + @ObservationIgnored + var textTool = TextTool() + + @ObservationIgnored + var numberLabelTool = NumberLabelTool() + + var drawingUpdateCounter: Int = 0 + var currentAnnotationInternal: Annotation? + var isWaitingForTextInputInternal: Bool = false + var textInputPositionInternal: CGPoint? + + // MARK: - Annotation Selection & Editing + + var selectedAnnotationIndex: Int? + var isDraggingAnnotation: Bool = false + + @ObservationIgnored + var dragStartPoint: CGPoint? + + @ObservationIgnored + var dragOriginalPosition: CGPoint? + + var isDrawing: Bool { + currentTool?.isActive ?? false + } + + var currentAnnotation: Annotation? { + currentAnnotationInternal + } + + var currentTool: (any AnnotationTool)? { + guard let selectedTool else { return nil } + switch selectedTool { + case .rectangle: return rectangleTool + case .ellipse: return ellipseTool + case .line: return lineTool + case .arrow: return arrowTool + case .freehand: return freehandTool + case .highlight: return highlightTool + case .mosaic: return mosaicTool + case .text: return textTool + case .numberLabel: return numberLabelTool + } + } + + var isWaitingForTextInput: Bool { + isWaitingForTextInputInternal + } + + /// Synchronized text input content that stays in sync with TextTool + var textInputContent: String { + get { + textTool.currentText + } + set { + textTool.updateText(newValue) + } + } + + var textInputPosition: CGPoint? { + textInputPositionInternal + } + + // MARK: - Computed Properties + + var image: CGImage { + screenshot.image + } + + var annotations: [Annotation] { + screenshot.annotations + } + + var dimensionsText: String { + screenshot.formattedDimensions + } + + var fileSizeText: String { + let format = settings.defaultFormat + let pixelCount = Double(screenshot.image.width * screenshot.image.height) + let bytes = Int(pixelCount * format.estimatedBytesPerPixel) + + if bytes < 1024 { + return "\(bytes) B" + } else if bytes < 1024 * 1024 { + return String(format: "%.1f KB", Double(bytes) / 1024.0) + } else { + return String(format: "%.1f MB", Double(bytes) / (1024.0 * 1024.0)) + } + } + + var displayName: String { + screenshot.sourceDisplay.name + } + + var format: ExportFormat { + get { screenshot.format } + set { screenshot = screenshot.with(format: newValue) } + } + + var canUndo: Bool { + !undoStack.isEmpty + } + + var canRedo: Bool { + !redoStack.isEmpty + } + + // MARK: - Undo/Redo + + var undoStack: [Screenshot] = [] + var redoStack: [Screenshot] = [] + + @ObservationIgnored + private let maxUndoLevels = 50 + + var imageSizeChangeCounter: Int = 0 + + // MARK: - Initialization + + init(screenshot: Screenshot) { + self.screenshot = screenshot + } + + // MARK: - Public API + + func show() { + isVisible = true + } + + func hide() { + guard isVisible else { return } + isVisible = false + onDismiss?() + } + + func addAnnotation(_ annotation: Annotation) { + pushUndoState() + screenshot = screenshot.adding(annotation) + redoStack.removeAll() + } + + func removeAnnotation(at index: Int) { + guard index >= 0 && index < annotations.count else { return } + pushUndoState() + screenshot = screenshot.removingAnnotation(at: index) + redoStack.removeAll() + } + + func undo() { + guard let previousState = undoStack.popLast() else { return } + + let currentSize = CGSize(width: screenshot.image.width, height: screenshot.image.height) + let previousSize = CGSize(width: previousState.image.width, height: previousState.image.height) + let imageSizeChanged = currentSize != previousSize + + redoStack.append(screenshot) + screenshot = previousState + + if imageSizeChanged { + imageSizeChangeCounter += 1 + } + } + + func redo() { + guard let nextState = redoStack.popLast() else { return } + + let currentSize = CGSize(width: screenshot.image.width, height: screenshot.image.height) + let nextSize = CGSize(width: nextState.image.width, height: nextState.image.height) + let imageSizeChanged = currentSize != nextSize + + undoStack.append(screenshot) + screenshot = nextState + + if imageSizeChanged { + imageSizeChangeCounter += 1 + } + } + + func selectTool(_ tool: AnnotationToolType?) { + // Reset number label counter when deselecting the tool + if selectedTool == .numberLabel && tool != .numberLabel { + numberLabelTool.resetNumber() + } + selectedTool = tool + } + + func dismiss() { + hide() + } + + func pushUndoState() { + undoStack.append(screenshot) + + if undoStack.count > maxUndoLevels { + undoStack.removeFirst() + } + } + + func clearError() { + Task { + try? await Task.sleep(for: .seconds(3)) + errorMessage = nil + } + } + + // MARK: - Selected Annotation Properties + + var selectedAnnotationType: AnnotationToolType? { + guard let index = selectedAnnotationIndex, + index < annotations.count else { return nil } + + switch annotations[index] { + case .rectangle: return .rectangle + case .ellipse: return .ellipse + case .line: return .line + case .freehand: return .freehand + case .arrow: return .arrow + case .highlight: return .highlight + case .mosaic: return .mosaic + case .text: return .text + case .numberLabel: return .numberLabel + } + } + + var selectedAnnotationColor: CodableColor? { + guard let index = selectedAnnotationIndex, + index < annotations.count else { return nil } + + switch annotations[index] { + case .rectangle(let rect): return rect.style.color + case .ellipse(let ellipse): return ellipse.style.color + case .line(let line): return line.style.color + case .freehand(let freehand): return freehand.style.color + case .arrow(let arrow): return arrow.style.color + case .text(let text): return text.style.color + case .highlight(let highlight): return highlight.color + case .mosaic: return nil + case .numberLabel(let label): return label.color + } + } + + var selectedAnnotationStrokeWidth: CGFloat? { + guard let index = selectedAnnotationIndex, + index < annotations.count else { return nil } + + switch annotations[index] { + case .rectangle(let rect): return rect.style.lineWidth + case .ellipse(let ellipse): return ellipse.style.lineWidth + case .line(let line): return line.style.lineWidth + case .freehand(let freehand): return freehand.style.lineWidth + case .arrow(let arrow): return arrow.style.lineWidth + case .text: return nil + case .mosaic: return nil + case .highlight: return nil + case .numberLabel: return nil + } + } + + var selectedAnnotationFontSize: CGFloat? { + guard let index = selectedAnnotationIndex, + index < annotations.count else { return nil } + + if case .text(let text) = annotations[index] { + return text.style.fontSize + } + return nil + } + + var selectedAnnotationIsFilled: Bool? { + guard let index = selectedAnnotationIndex, + index < annotations.count else { return nil } + + if case .rectangle(let rect) = annotations[index] { + return rect.isFilled + } + if case .ellipse(let ellipse) = annotations[index] { + return ellipse.isFilled + } + return nil + } + + var selectedAnnotationBlockSize: Int? { + guard let index = selectedAnnotationIndex, + index < annotations.count else { return nil } + + if case .mosaic(let mosaic) = annotations[index] { + return mosaic.blockSize + } + return nil + } + + var hasOCRResults: Bool { + ocrResult?.hasResults ?? false + } + + var combinedOCRText: String { + ocrResult?.fullText ?? "" + } + + // MARK: - Pin Functionality + + /// Pins the current screenshot with all annotations + func pinScreenshot() { + PinnedWindowsManager.shared.pinScreenshot(screenshot, annotations: annotations) + } + + /// Checks if the current screenshot is pinned + var isPinned: Bool { + PinnedWindowsManager.shared.isPinned(screenshot.id) + } +} + +// MARK: - Annotation Tool Type + +enum AnnotationToolType: String, CaseIterable, Identifiable, Sendable { + case rectangle + case ellipse + case line + case arrow + case freehand + case highlight + case mosaic + case text + case numberLabel + + var id: String { rawValue } + + var displayName: String { + switch self { + case .rectangle: return String(localized: "tool.rectangle") + case .ellipse: return String(localized: "tool.ellipse") + case .line: return String(localized: "tool.line") + case .arrow: return String(localized: "tool.arrow") + case .freehand: return String(localized: "tool.freehand") + case .highlight: return String(localized: "tool.highlight") + case .mosaic: return String(localized: "tool.mosaic") + case .text: return String(localized: "tool.text") + case .numberLabel: return String(localized: "tool.numberLabel") + } + } + + var keyboardShortcut: Character { + switch self { + case .rectangle: return "r" + case .ellipse: return "o" + case .line: return "l" + case .arrow: return "a" + case .freehand: return "d" + case .highlight: return "h" + case .mosaic: return "m" + case .text: return "t" + case .numberLabel: return "n" + } + } + + var systemImage: String { + switch self { + case .rectangle: return "rectangle" + case .ellipse: return "circle" + case .line: return "line.diagonal" + case .arrow: return "arrow.up.right" + case .freehand: return "pencil.line" + case .highlight: return "highlighter" + case .mosaic: return "checkerboard.rectangle" + case .text: return "textbox" + case .numberLabel: return "number.circle" + } + } +} diff --git a/ScreenCapture/Features/Preview/PreviewWindow.swift b/ScreenTranslate/Features/Preview/PreviewWindow.swift similarity index 61% rename from ScreenCapture/Features/Preview/PreviewWindow.swift rename to ScreenTranslate/Features/Preview/PreviewWindow.swift index 337c5ff..7c2dc74 100644 --- a/ScreenCapture/Features/Preview/PreviewWindow.swift +++ b/ScreenTranslate/Features/Preview/PreviewWindow.swift @@ -30,8 +30,12 @@ final class PreviewWindow: NSPanel { viewModel.onDismiss = onDismiss viewModel.onSave = onSave - // Calculate initial window size based on image dimensions - let imageSize = CGSize(width: screenshot.image.width, height: screenshot.image.height) + // Calculate initial window size based on image dimensions in points + let scaleFactor = screenshot.sourceDisplay.scaleFactor + let imageSize = CGSize( + width: CGFloat(screenshot.image.width) / scaleFactor, + height: CGFloat(screenshot.image.height) / scaleFactor + ) let windowSize = Self.calculateWindowSize(for: imageSize) let contentRect = Self.calculateCenteredRect(size: windowSize) @@ -64,7 +68,7 @@ final class PreviewWindow: NSPanel { titleVisibility = .visible // Size constraints - minimum width accommodates toolbar UI - minSize = NSSize(width: 700, height: 400) + minSize = NSSize(width: 300, height: 200) maxSize = NSSize(width: 4000, height: 3000) // Collection behavior for proper window management @@ -111,9 +115,10 @@ final class PreviewWindow: NSPanel { /// Resizes the window to fit the current image @MainActor func resizeToFitImage() { + let scaleFactor = viewModel.screenshot.sourceDisplay.scaleFactor let imageSize = CGSize( - width: viewModel.screenshot.image.width, - height: viewModel.screenshot.image.height + width: CGFloat(viewModel.screenshot.image.width) / scaleFactor, + height: CGFloat(viewModel.screenshot.image.height) / scaleFactor ) let newSize = Self.calculateWindowSize(for: imageSize) @@ -157,8 +162,14 @@ final class PreviewWindow: NSPanel { let windowWidth = imageSize.width * scale let windowHeight = imageSize.height * scale + 60 // Extra height for info bar - // Minimum size must accommodate toolbar UI elements - return NSSize(width: max(windowWidth, 700), height: max(windowHeight, 400)) + // Dynamic minimum size: toolbar needs some space but don't force stretch small images too much + let effectiveMinWidth = min(300, imageSize.width) + let effectiveMinHeight = min(200, imageSize.height + 60) + + return NSSize( + width: max(windowWidth, effectiveMinWidth), + height: max(windowHeight, effectiveMinHeight) + ) } /// Calculates a centered rect for the window. @@ -181,149 +192,143 @@ final class PreviewWindow: NSPanel { /// Handle key events for shortcuts override func keyDown(with event: NSEvent) { - // Check for Escape key to dismiss or deselect - if event.keyCode == 53 { // Escape - Task { @MainActor in - if viewModel.selectedAnnotationIndex != nil { - // First deselect annotation - viewModel.deselectAnnotation() - } else if viewModel.selectedTool != nil { - // Then deselect tool - viewModel.selectTool(nil) - } else { - // Finally dismiss - viewModel.dismiss() - } - } - return - } + guard !handleSpecialKeys(event) else { return } + super.keyDown(with: event) + } - // Check for Delete/Backspace to delete selected annotation - if event.keyCode == 51 || event.keyCode == 117 { // Backspace or Delete - Task { @MainActor in - if viewModel.selectedAnnotationIndex != nil { - viewModel.deleteSelectedAnnotation() - } + private func handleSpecialKeys(_ event: NSEvent) -> Bool { + if handleEscape(event) { return true } + if handleDelete(event) { return true } + if handleReturn(event) { return true } + if handleCommandShortcuts(event) { return true } + if handleToolShortcuts(event) { return true } + return false + } + + private func handleEscape(_ event: NSEvent) -> Bool { + guard event.keyCode == 53 else { return false } + Task { @MainActor in + if viewModel.selectedAnnotationIndex != nil { + viewModel.deselectAnnotation() + } else if viewModel.selectedTool != nil { + viewModel.selectTool(nil) + } else { + viewModel.dismiss() } - return } + return true + } - // Check for Enter/Return key - apply crop if in crop mode, otherwise save - if event.keyCode == 36 || event.keyCode == 76 { // Return or Enter (numpad) - Task { @MainActor in - if viewModel.isCropMode && viewModel.cropRect != nil { - viewModel.applyCrop() - } else { - viewModel.saveScreenshot() - } + private func handleDelete(_ event: NSEvent) -> Bool { + guard event.keyCode == 51 || event.keyCode == 117 else { return false } + Task { @MainActor in + if viewModel.selectedAnnotationIndex != nil { + viewModel.deleteSelectedAnnotation() } - return } + return true + } - // Check for Cmd+S to save - if event.modifierFlags.contains(.command) && event.charactersIgnoringModifiers == "s" { - Task { @MainActor in - viewModel.saveScreenshot() + private func handleReturn(_ event: NSEvent) -> Bool { + guard event.keyCode == 36 || event.keyCode == 76 else { return false } + Task { @MainActor in + if viewModel.isCropMode && viewModel.cropRect != nil { + viewModel.applyCrop() + } else { + // Match the Confirm button behavior: copy to clipboard and dismiss + viewModel.copyToClipboard() + viewModel.dismiss() } - return } + return true + } - // Check for Cmd+C to copy and dismiss - if event.modifierFlags.contains(.command) && event.charactersIgnoringModifiers == "c" { + private func handleCommandShortcuts(_ event: NSEvent) -> Bool { + guard event.modifierFlags.contains(.command) else { return false } + let char = event.charactersIgnoringModifiers?.lowercased() + + switch char { + case "s": + Task { @MainActor in viewModel.saveScreenshot() } + return true + case "c": Task { @MainActor in viewModel.copyToClipboard() viewModel.dismiss() } - return - } - - // Check for Cmd+Z to undo - if event.modifierFlags.contains(.command) && event.charactersIgnoringModifiers == "z" { + return true + case "z": if event.modifierFlags.contains(.shift) { - Task { @MainActor in - viewModel.redo() - } + Task { @MainActor in viewModel.redo() } } else { - Task { @MainActor in - viewModel.undo() - } + Task { @MainActor in viewModel.undo() } } - return + return true + default: + return false } + } - // Check for tool shortcuts (R, D, A, T) and Escape to deselect - if let char = event.charactersIgnoringModifiers?.lowercased().first { - switch char { - case "r": - Task { @MainActor in - if viewModel.selectedTool == .rectangle { - viewModel.selectTool(nil) - } else { - viewModel.selectTool(.rectangle) - } - } - return - case "d": - Task { @MainActor in - if viewModel.selectedTool == .freehand { - viewModel.selectTool(nil) - } else { - viewModel.selectTool(.freehand) - } - } - return - case "a": - Task { @MainActor in - if viewModel.selectedTool == .arrow { - viewModel.selectTool(nil) - } else { - viewModel.selectTool(.arrow) - } - } - return - case "t": - Task { @MainActor in - if viewModel.selectedTool == .text { - viewModel.selectTool(nil) - } else { - viewModel.selectTool(.text) - } - } - return - case "1", "2", "3", "4": - // Number keys to quickly select tools (1=Rectangle, 2=Freehand, 3=Arrow, 4=Text) - let toolIndex = Int(String(char))! - 1 + private func handleToolShortcuts(_ event: NSEvent) -> Bool { + guard let char = event.charactersIgnoringModifiers?.lowercased().first else { return false } + + if char == "c" && !event.modifierFlags.contains(.command) { + Task { @MainActor in viewModel.toggleCropMode() } + return true + } + + switch char { + case "r": + Task { @MainActor in toggleTool(.rectangle) } + return true + case "o": + Task { @MainActor in toggleTool(.ellipse) } + return true + case "l": + Task { @MainActor in toggleTool(.line) } + return true + case "d": + Task { @MainActor in toggleTool(.freehand) } + return true + case "a": + Task { @MainActor in toggleTool(.arrow) } + return true + case "h": + Task { @MainActor in toggleTool(.highlight) } + return true + case "m": + Task { @MainActor in toggleTool(.mosaic) } + return true + case "t": + Task { @MainActor in toggleTool(.text) } + return true + case "n": + Task { @MainActor in toggleTool(.numberLabel) } + return true + case "p": + Task { @MainActor in viewModel.pinScreenshot() } + return true + case "1", "2", "3", "4", "5", "6", "7", "8", "9": + if let digit = Int(String(char)) { + let toolIndex = digit - 1 let tools = AnnotationToolType.allCases if toolIndex < tools.count { - Task { @MainActor in - let tool = tools[toolIndex] - if viewModel.selectedTool == tool { - viewModel.selectTool(nil) - } else { - viewModel.selectTool(tool) - } - } - } - return - case "c": - // C key to toggle crop mode (when not combined with Cmd) - if !event.modifierFlags.contains(.command) { - Task { @MainActor in - viewModel.toggleCropMode() - } - return + Task { @MainActor in toggleTool(tools[toolIndex]) } } - default: - break } + return true + default: + return false } - - super.keyDown(with: event) } - override func performKeyEquivalent(with event: NSEvent) -> Bool { - // Allow standard keyboard equivalents for tab navigation - return super.performKeyEquivalent(with: event) + @MainActor + private func toggleTool(_ tool: AnnotationToolType) { + if viewModel.selectedTool == tool { + viewModel.selectTool(nil) + } else { + viewModel.selectTool(tool) + } } override var canBecomeKey: Bool { @@ -339,7 +344,10 @@ final class PreviewWindow: NSPanel { /// Shows the preview window @MainActor func showPreview() { - viewModel.show() + // Delay state modification to avoid "Modifying state during view update" warning + DispatchQueue.main.async { [weak self] in + self?.viewModel.show() + } makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } @@ -347,7 +355,10 @@ final class PreviewWindow: NSPanel { /// Closes the preview window @MainActor func closePreview() { - viewModel.hide() + // Delay state modification to avoid "Modifying state during view update" warning + DispatchQueue.main.async { [weak self] in + self?.viewModel.hide() + } close() } } diff --git a/ScreenTranslate/Features/Settings/AdvancedSettingsTab.swift b/ScreenTranslate/Features/Settings/AdvancedSettingsTab.swift new file mode 100644 index 0000000..5697c4c --- /dev/null +++ b/ScreenTranslate/Features/Settings/AdvancedSettingsTab.swift @@ -0,0 +1,163 @@ +import AppKit +import SwiftUI + +struct AdvancedSettingsContent: View { + @Bindable var viewModel: SettingsViewModel + + var body: some View { + VStack(spacing: 20) { + StrokeColorPicker(viewModel: viewModel) + Divider().opacity(0.1) + StrokeWidthSlider(viewModel: viewModel) + Divider().opacity(0.1) + TextSizeSlider(viewModel: viewModel) + } + .macos26LiquidGlass() + + Button(role: .destructive) { + viewModel.resetAllToDefaults() + } label: { + Text(localized("settings.reset.all")) + .font(.system(.callout, design: .rounded, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(.red.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: DesignSystem.Radii.control)) + } + .buttonStyle(.plain) + .foregroundStyle(.red) + } +} + +struct StrokeColorPicker: View { + @Bindable var viewModel: SettingsViewModel + + var body: some View { + HStack { + Text(localized("settings.stroke.color")) + + Spacer() + + HStack(spacing: 4) { + ForEach(SettingsViewModel.presetColors, id: \.self) { color in + Button { + viewModel.strokeColor = color + } label: { + Circle() + .fill(color) + .frame(width: 20, height: 20) + .overlay { + if colorsAreEqual(viewModel.strokeColor, color) { + Circle() + .stroke(Color.primary, lineWidth: 2) + } + } + .overlay { + if color == .white || color == .yellow { + Circle() + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel(Text(colorName(for: color))) + } + } + + ColorPicker("", selection: $viewModel.strokeColor, supportsOpacity: false) + .labelsHidden() + .frame(width: 30) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(localized("settings.stroke.color"))) + } + + private func colorsAreEqual(_ firstColor: Color, _ secondColor: Color) -> Bool { + let nsA = NSColor(firstColor).usingColorSpace(.deviceRGB) + let nsB = NSColor(secondColor).usingColorSpace(.deviceRGB) + guard let colorA = nsA, let colorB = nsB else { return false } + + let tolerance: CGFloat = 0.01 + return abs(colorA.redComponent - colorB.redComponent) < tolerance + && abs(colorA.greenComponent - colorB.greenComponent) < tolerance + && abs(colorA.blueComponent - colorB.blueComponent) < tolerance + } + + private func colorName(for color: Color) -> String { + switch color { + case .red: return localized("color.red") + case .orange: return localized("color.orange") + case .yellow: return localized("color.yellow") + case .green: return localized("color.green") + case .blue: return localized("color.blue") + case .purple: return localized("color.purple") + case .pink: return localized("color.pink") + case .white: return localized("color.white") + case .black: return localized("color.black") + default: return localized("color.custom") + } + } +} + +struct StrokeWidthSlider: View { + @Bindable var viewModel: SettingsViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(localized("settings.stroke.width")) + Spacer() + Text("\(viewModel.strokeWidth, specifier: "%.1f") pt") + .foregroundStyle(.secondary) + .monospacedDigit() + } + + HStack(spacing: 12) { + Slider( + value: $viewModel.strokeWidth, + in: SettingsViewModel.strokeWidthRange, + step: 0.5 + ) { + Text(localized("settings.stroke.width")) + } + .accessibilityValue(Text("\(viewModel.strokeWidth, specifier: "%.1f") points")) + + RoundedRectangle(cornerRadius: viewModel.strokeWidth / 2) + .fill(viewModel.strokeColor) + .frame(width: 40, height: viewModel.strokeWidth) + } + } + } +} + +struct TextSizeSlider: View { + @Bindable var viewModel: SettingsViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(localized("settings.text.size")) + Spacer() + Text("\(Int(viewModel.textSize)) pt") + .foregroundStyle(.secondary) + .monospacedDigit() + } + + HStack(spacing: 12) { + Slider( + value: $viewModel.textSize, + in: SettingsViewModel.textSizeRange, + step: 1 + ) { + Text(localized("settings.text.size")) + } + .accessibilityValue(Text("\(Int(viewModel.textSize)) points")) + + Text("Aa") + .font(.system(size: min(viewModel.textSize, 24))) + .foregroundStyle(viewModel.strokeColor) + .frame(width: 40) + } + } + } +} diff --git a/ScreenTranslate/Features/Settings/CompatibleEngineConfigSheet.swift b/ScreenTranslate/Features/Settings/CompatibleEngineConfigSheet.swift new file mode 100644 index 0000000..b57f0e5 --- /dev/null +++ b/ScreenTranslate/Features/Settings/CompatibleEngineConfigSheet.swift @@ -0,0 +1,262 @@ +// +// CompatibleEngineConfigSheet.swift +// ScreenTranslate +// +// Configuration sheet for OpenAI-compatible translation engines +// + +import SwiftUI + +struct CompatibleEngineConfigSheet: View { + let config: CompatibleTranslationProvider.CompatibleConfig + let index: Int + let isNew: Bool + let onSave: (CompatibleTranslationProvider.CompatibleConfig) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var showAPIKey = false + @State private var apiKey: String = "" + @State private var displayName: String = "" + @State private var baseURL: String = "" + @State private var modelName: String = "" + @State private var hasAPIKey: Bool = true + @State private var isTesting = false + @State private var testResult: String? + @State private var testSuccess = false + + var body: some View { + VStack(spacing: 20) { + // Header + HStack { + Image(systemName: "gearshape.2") + .font(.title2) + .foregroundStyle(.tint) + VStack(alignment: .leading) { + Text(isNew ? localized("engine.compatible.new") : displayName) + .font(.headline) + Text(localized("engine.compatible.description")) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + + Divider() + + // Configuration Form + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Display Name + VStack(alignment: .leading, spacing: 8) { + Text(localized("engine.compatible.displayName")) + .font(.subheadline) + .foregroundStyle(.secondary) + + TextField(localized("engine.compatible.displayName.placeholder"), text: $displayName) + .textFieldStyle(.roundedBorder) + } + + // Base URL + VStack(alignment: .leading, spacing: 8) { + Text(localized("engine.config.baseURL")) + .font(.subheadline) + .foregroundStyle(.secondary) + + TextField("http://localhost:8000/v1", text: $baseURL) + .textFieldStyle(.roundedBorder) + } + + // Model Name + VStack(alignment: .leading, spacing: 8) { + Text(localized("engine.config.model")) + .font(.subheadline) + .foregroundStyle(.secondary) + + TextField("gpt-4o-mini", text: $modelName) + .textFieldStyle(.roundedBorder) + } + + // API Key Toggle + Toggle(isOn: $hasAPIKey) { + Text(localized("engine.compatible.requireApiKey")) + } + .toggleStyle(.switch) + + // API Key (if required) + if hasAPIKey { + VStack(alignment: .leading, spacing: 8) { + Text(localized("engine.config.apiKey")) + .font(.subheadline) + .foregroundStyle(.secondary) + + HStack { + if showAPIKey { + TextField(localized("engine.config.apiKey.placeholder"), text: $apiKey) + .textFieldStyle(.roundedBorder) + } else { + SecureField(localized("engine.config.apiKey.placeholder"), text: $apiKey) + .textFieldStyle(.roundedBorder) + } + Button { + showAPIKey.toggle() + } label: { + Image(systemName: showAPIKey ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + } + } + } + } + .padding() + } + + Divider() + + // Footer + HStack { + // Test Button + Button { + Task { await testConnection() } + } label: { + HStack(spacing: 6) { + if isTesting { + ProgressView() + .controlSize(.small) + } + Image(systemName: "bolt.fill") + Text(localized("engine.config.test")) + } + } + .buttonStyle(.bordered) + .controlSize(.regular) + .disabled(isTesting || !canTest) + + if let result = testResult { + HStack(spacing: 4) { + Image(systemName: testSuccess ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(testSuccess ? Color.green : Color.red) + Text(result) + .font(.caption) + .foregroundStyle(testSuccess ? .secondary : Color.red) + .lineLimit(2) + } + } + + Spacer() + + Button(localized("button.cancel")) { + dismiss() + } + .buttonStyle(.bordered) + + Button(localized("button.save")) { + saveConfig() + dismiss() + } + .buttonStyle(.borderedProminent) + .disabled(displayName.isEmpty || baseURL.isEmpty || modelName.isEmpty) + } + } + .padding() + .frame(width: 500, height: 480) + .onAppear { + loadConfig() + } + } + + // MARK: - Computed Properties + + private var canTest: Bool { + !displayName.isEmpty && !baseURL.isEmpty && !modelName.isEmpty && (!hasAPIKey || !apiKey.isEmpty) + } + + // MARK: - Actions + + private func loadConfig() { + displayName = config.displayName + baseURL = config.baseURL + modelName = config.modelName + hasAPIKey = config.hasAPIKey + + // Load credentials from keychain + Task { + let compositeId = config.keychainId + if let credentials = try? await KeychainService.shared.getCredentials(forCompatibleId: compositeId) { + await MainActor.run { + apiKey = credentials.apiKey + } + } + } + } + + private func saveConfig() { + // For new engines, generate a new ID; for existing, keep the original + let configId = isNew ? UUID() : config.id + + let savedConfig = CompatibleTranslationProvider.CompatibleConfig( + id: configId, + displayName: displayName, + baseURL: baseURL, + modelName: modelName, + hasAPIKey: hasAPIKey + ) + + onSave(savedConfig) + + // Save credentials to keychain + Task { + let compositeId = savedConfig.keychainId + if hasAPIKey && !apiKey.isEmpty { + try? await KeychainService.shared.saveCredentials(apiKey: apiKey, forCompatibleId: compositeId) + } else { + try? await KeychainService.shared.deleteCredentials(forCompatibleId: compositeId) + } + } + } + + private func testConnection() async { + isTesting = true + testResult = nil + + do { + // Save credentials temporarily for testing + if hasAPIKey { + let compositeId = config.keychainId + try await KeychainService.shared.saveCredentials(apiKey: apiKey, forCompatibleId: compositeId) + } + + // Create a temporary config for testing + let tempConfig = CompatibleTranslationProvider.CompatibleConfig( + id: config.id, + displayName: displayName, + baseURL: baseURL, + modelName: modelName, + hasAPIKey: hasAPIKey + ) + + // Test by creating a provider and calling checkConnection + let engineConfig = TranslationEngineConfig.default(for: .custom) + let provider = try await CompatibleTranslationProvider( + config: engineConfig, + compatibleConfig: tempConfig, + keychain: KeychainService.shared + ) + + let success = await provider.checkConnection() + + await MainActor.run { + testSuccess = success + testResult = success + ? localized("engine.config.test.success") + : localized("engine.config.test.failed") + isTesting = false + } + } catch { + await MainActor.run { + testSuccess = false + testResult = error.localizedDescription + isTesting = false + } + } + } +} diff --git a/ScreenTranslate/Features/Settings/EngineConfigSheet.swift b/ScreenTranslate/Features/Settings/EngineConfigSheet.swift new file mode 100644 index 0000000..8278581 --- /dev/null +++ b/ScreenTranslate/Features/Settings/EngineConfigSheet.swift @@ -0,0 +1,388 @@ +// +// EngineConfigSheet.swift +// ScreenTranslate +// +// Configuration sheet for individual translation engines +// + +import SwiftUI +import os + +struct EngineConfigSheet: View { + let engine: TranslationEngineType + @Binding var config: TranslationEngineConfig + + @Environment(\.dismiss) private var dismiss + @State private var showAPIKey = false + @State private var apiKey: String = "" + @State private var appID: String = "" + @State private var secretKey: String = "" + @State private var baseURL: String = "" + @State private var modelName: String = "" + @State private var isTesting = false + @State private var testResult: String? + @State private var testSuccess = false + private let logger = Logger.settings + + var body: some View { + VStack(spacing: 20) { + // Header + HStack { + Image(systemName: engineIcon) + .font(.title2) + .foregroundStyle(.tint) + VStack(alignment: .leading) { + Text(engine.localizedName) + .font(.headline) + Text(engine.engineDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + + Divider() + + // Configuration Form + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Enable Toggle + Toggle(isOn: $config.isEnabled) { + Text(localized("engine.config.enabled")) + } + .toggleStyle(.switch) + + // API Key (if required) + if engine.requiresAPIKey { + if engine == .baidu { + // Baidu requires AppID and Secret Key + baiduCredentialsSection + } else { + apiKeySection + } + } + + // MTranServer URL configuration + if engine == .mtranServer { + mtranServerURLSection + } + + // Base URL (if applicable) + if engine.defaultBaseURL != nil || engine == .custom { + baseURLSection + } + + // Model Name (for LLM engines) + if engine.defaultModelName != nil || engine == .custom { + modelNameSection + } + } + .padding() + } + + Divider() + + // Footer + HStack { + // Test Button + Button { + Task { await testConnection() } + } label: { + HStack(spacing: 6) { + if isTesting { + ProgressView() + .controlSize(.small) + } + Image(systemName: "bolt.fill") + Text(localized("engine.config.test")) + } + } + .buttonStyle(.bordered) + .controlSize(.regular) + .disabled(isTesting || !canTest) + + if let result = testResult { + HStack(spacing: 4) { + Image(systemName: testSuccess ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(testSuccess ? Color.green : Color.red) + Text(result) + .font(.caption) + .foregroundStyle(testSuccess ? .secondary : Color.red) + .lineLimit(2) + } + } + + Spacer() + + Button(localized("button.cancel")) { + dismiss() + } + .buttonStyle(.bordered) + + Button(localized("button.save")) { + saveConfig() + dismiss() + } + .buttonStyle(.borderedProminent) + .disabled(!config.isEnabled && !hasValidConfig) + } + } + .padding() + .frame(width: 500, height: 450) + .onAppear { + loadConfig() + } + } + + // MARK: - View Components + + @ViewBuilder + private var apiKeySection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(localized("engine.config.apiKey")) + .font(.subheadline) + .foregroundStyle(.secondary) + + Spacer() + + // Get API Key button + if let url = engine.apiKeyURL { + Button { + NSWorkspace.shared.open(url) + } label: { + HStack(spacing: 4) { + Image(systemName: "link") + Text(localized("engine.config.getApiKey")) + } + .font(.caption) + } + .buttonStyle(.plain) + .foregroundStyle(.tint) + } + } + + HStack { + if showAPIKey { + TextField(localized("engine.config.apiKey.placeholder"), text: $apiKey) + .textFieldStyle(.roundedBorder) + } else { + SecureField(localized("engine.config.apiKey.placeholder"), text: $apiKey) + .textFieldStyle(.roundedBorder) + } + Button { + showAPIKey.toggle() + } label: { + Image(systemName: showAPIKey ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + } + } + } + + @ViewBuilder + private var baiduCredentialsSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(localized("engine.config.baidu.credentials")) + .font(.subheadline) + .foregroundStyle(.secondary) + + Spacer() + + // Get API Key button + if let url = engine.apiKeyURL { + Button { + NSWorkspace.shared.open(url) + } label: { + HStack(spacing: 4) { + Image(systemName: "link") + Text(localized("engine.config.getApiKey")) + } + .font(.caption) + } + .buttonStyle(.plain) + .foregroundStyle(.tint) + } + } + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text(localized("engine.config.baidu.appID")) + .gridColumnAlignment(.trailing) + TextField("App ID", text: $appID) + .textFieldStyle(.roundedBorder) + } + + GridRow { + Text(localized("engine.config.baidu.secretKey")) + .gridColumnAlignment(.trailing) + SecureField("Secret Key", text: $secretKey) + .textFieldStyle(.roundedBorder) + } + } + } + } + + @ViewBuilder + private var mtranServerURLSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text(localized("engine.config.mtran.url")) + .font(.subheadline) + .foregroundStyle(.secondary) + + TextField("http://localhost:8989", text: $baseURL) + .textFieldStyle(.roundedBorder) + } + } + + @ViewBuilder + private var baseURLSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text(localized("engine.config.baseURL")) + .font(.subheadline) + .foregroundStyle(.secondary) + + TextField(engine.defaultBaseURL ?? "", text: $baseURL) + .textFieldStyle(.roundedBorder) + } + } + + @ViewBuilder + private var modelNameSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text(localized("engine.config.model")) + .font(.subheadline) + .foregroundStyle(.secondary) + + TextField(engine.defaultModelName ?? "", text: $modelName) + .textFieldStyle(.roundedBorder) + } + } + + // MARK: - Computed Properties + + private var engineIcon: String { + switch engine { + case .apple: return "apple.logo" + case .mtranServer: return "server.rack" + case .openai: return "brain.head.profile" + case .claude: return "bubble.left.and.bubble.right" + case .gemini: return "sparkles" + case .ollama: return "cpu" + case .google: return "globe" + case .deepl: return "character.bubble" + case .baidu: return "network" + case .custom: return "gearshape.2" + } + } + + private var canTest: Bool { + if !config.isEnabled { return false } + if engine.requiresAPIKey { + if engine == .baidu { + return !appID.isEmpty && !secretKey.isEmpty + } + return !apiKey.isEmpty + } + return true + } + + private var hasValidConfig: Bool { + if engine.requiresAPIKey { + if engine == .baidu { + return !appID.isEmpty && !secretKey.isEmpty + } + return !apiKey.isEmpty + } + return true + } + + // MARK: - Actions + + private func loadConfig() { + baseURL = config.options?.baseURL ?? engine.defaultBaseURL ?? "" + modelName = config.options?.modelName ?? engine.defaultModelName ?? "" + + // Load credentials from keychain + Task { + if let credentials = try? await KeychainService.shared.getCredentials(for: engine) { + apiKey = credentials.apiKey + appID = credentials.appID ?? "" + secretKey = credentials.additional?["secretKey"] ?? "" + } + } + } + + private func saveConfig() { + // Update options + var options = config.options ?? EngineOptions.default(for: engine) ?? EngineOptions() + if !baseURL.isEmpty { + options.baseURL = baseURL + } + if !modelName.isEmpty { + options.modelName = modelName + } + config.options = options + + // Save credentials to keychain + Task { + do { + if engine == .baidu { + try await KeychainService.shared.saveCredentials( + apiKey: secretKey, + for: engine, + additionalData: ["appID": appID, "secretKey": secretKey] + ) + } else if engine.requiresAPIKey && !apiKey.isEmpty { + try await KeychainService.shared.saveCredentials( + apiKey: apiKey, + for: engine + ) + } + } catch { + logger.error("Failed to save credentials: \(error.localizedDescription, privacy: .private(mask: .hash))") + } + } + } + + private func testConnection() async { + isTesting = true + testResult = nil + + do { + // Save credentials temporarily for testing + if engine.requiresAPIKey { + if engine == .baidu { + try await KeychainService.shared.saveCredentials( + apiKey: secretKey, + for: engine, + additionalData: ["appID": appID, "secretKey": secretKey] + ) + } else { + try await KeychainService.shared.saveCredentials( + apiKey: apiKey, + for: engine + ) + } + } + + // Test connection + let success = await TranslationService.shared.testConnection(for: engine) + + await MainActor.run { + testSuccess = success + testResult = success + ? localized("engine.config.test.success") + : localized("engine.config.test.failed") + isTesting = false + } + } catch { + await MainActor.run { + testSuccess = false + testResult = error.localizedDescription + isTesting = false + } + } + } +} diff --git a/ScreenTranslate/Features/Settings/EngineSettingsTab.swift b/ScreenTranslate/Features/Settings/EngineSettingsTab.swift new file mode 100644 index 0000000..fd20324 --- /dev/null +++ b/ScreenTranslate/Features/Settings/EngineSettingsTab.swift @@ -0,0 +1,313 @@ +import SwiftUI + +struct EngineSettingsContent: View { + @Bindable var viewModel: SettingsViewModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // VLM Configuration (for image analysis) + VLMConfigurationSection(viewModel: viewModel) + + // Multi-Engine Translation Configuration + MultiEngineSettingsSection(viewModel: viewModel) + } + .padding() + } + } +} + +struct VLMConfigurationSection: View { + @Bindable var viewModel: SettingsViewModel + @State private var showAPIKey = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(localized("settings.vlm.title")) + .font(.headline) + + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { + GridRow { + Text(localized("settings.vlm.provider")) + .foregroundStyle(.secondary) + .gridColumnAlignment(.trailing) + Picker("", selection: $viewModel.vlmProvider) { + ForEach(VLMProviderType.allCases) { provider in + Text(provider.localizedName).tag(provider) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 400) + } + } + + // PaddleOCR specific section + if viewModel.vlmProvider == .paddleocr { + PaddleOCRStatusSection(viewModel: viewModel) + } else { + // Standard VLM configuration for API-based providers + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { + if viewModel.vlmProvider == .glmOCR { + GridRow { + Text(localized("settings.glmocr.mode")) + .foregroundStyle(.secondary) + .gridColumnAlignment(.trailing) + Picker("", selection: $viewModel.glmOCRMode) { + ForEach(GLMOCRMode.allCases, id: \.self) { mode in + Text(mode.localizedName).tag(mode) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 300) + } + } + + GridRow { + Text(localized("settings.vlm.apiKey")) + .foregroundStyle(.secondary) + .gridColumnAlignment(.trailing) + HStack { + if showAPIKey { + TextField("", text: $viewModel.vlmAPIKey) + .textFieldStyle(.roundedBorder) + } else { + SecureField("", text: $viewModel.vlmAPIKey) + .textFieldStyle(.roundedBorder) + } + Button { + showAPIKey.toggle() + } label: { + Image(systemName: showAPIKey ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + } + .frame(maxWidth: 300) + } + + if !viewModel.currentVLMRequiresAPIKey { + GridRow { + Color.clear.gridCellUnsizedAxes([.horizontal, .vertical]) + Text(viewModel.vlmProvider == .glmOCR && viewModel.glmOCRMode == .local + ? localized("settings.glmocr.local.apiKey.optional") + : localized("settings.vlm.apiKey.optional")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + GridRow { + Text(localized("settings.vlm.baseURL")) + .foregroundStyle(.secondary) + .gridColumnAlignment(.trailing) + TextField("", text: $viewModel.vlmBaseURL) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 300) + } + + GridRow { + Text(localized("settings.vlm.model")) + .foregroundStyle(.secondary) + .gridColumnAlignment(.trailing) + TextField("", text: $viewModel.vlmModelName) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 300) + } + } + + Text(viewModel.currentVLMProviderDescription) + .font(.caption) + .foregroundStyle(.secondary) + + // Test API Connection Button + HStack { + Button { + viewModel.testVLMAPI() + } label: { + HStack(spacing: 6) { + if viewModel.isTestingVLM { + ProgressView() + .controlSize(.small) + } + Image(systemName: "bolt.fill") + Text(localized("settings.vlm.test.button")) + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(viewModel.isTestingVLM) + + Spacer() + + if let result = viewModel.vlmTestResult { + HStack(spacing: 4) { + Image(systemName: viewModel.vlmTestSuccess ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(viewModel.vlmTestSuccess ? Color.green : Color.red) + Text(result) + .font(.caption) + .foregroundStyle(viewModel.vlmTestSuccess ? .secondary : Color.red) + .lineLimit(2) + } + } + } + .padding(.top, 8) + } + } + .padding() + .background(Color(.controlBackgroundColor)) + .cornerRadius(8) + } +} + +// MARK: - PaddleOCR Status Section + +struct PaddleOCRStatusSection: View { + @Bindable var viewModel: SettingsViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Status + HStack { + Image(systemName: viewModel.isPaddleOCRInstalled ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(viewModel.isPaddleOCRInstalled ? .green : .orange) + + if viewModel.isPaddleOCRInstalled { + Text(localized("settings.paddleocr.ready")) + .foregroundStyle(.secondary) + if let version = viewModel.paddleOCRVersion, !version.isEmpty { + Text("(\(version))") + .font(.caption) + .foregroundStyle(.tertiary) + } + } else { + Text(localized("settings.paddleocr.not.installed.message")) + .foregroundStyle(.secondary) + } + } + + // Mode selection + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { + GridRow { + Text(localized("settings.paddleocr.mode")) + .foregroundStyle(.secondary) + .gridColumnAlignment(.trailing) + Picker("", selection: $viewModel.paddleOCRMode) { + ForEach(PaddleOCRMode.allCases, id: \.self) { mode in + VStack(alignment: .leading) { + Text(mode.localizedName) + }.tag(mode) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 300) + } + + // Mode description + GridRow { + Color.clear.gridCellUnsizedAxes([.horizontal, .vertical]) + Text(viewModel.paddleOCRMode.description) + .font(.caption) + .foregroundStyle(.tertiary) + } + + // Cloud API toggle + GridRow { + Text(localized("settings.paddleocr.useCloud")) + .foregroundStyle(.secondary) + .gridColumnAlignment(.trailing) + Toggle("", isOn: $viewModel.paddleOCRUseCloud) + .toggleStyle(.checkbox) + } + + // Cloud API settings (only show when useCloud is true) + if viewModel.paddleOCRUseCloud { + GridRow { + Text(localized("settings.paddleocr.cloudBaseURL")) + .foregroundStyle(.secondary) + .gridColumnAlignment(.trailing) + TextField("", text: $viewModel.paddleOCRCloudBaseURL) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 300) + } + + GridRow { + Text(localized("settings.paddleocr.cloudAPIKey")) + .foregroundStyle(.secondary) + .gridColumnAlignment(.trailing) + SecureField("", text: $viewModel.paddleOCRCloudAPIKey) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 300) + } + + GridRow { + Text(localized("settings.paddleocr.cloudModelId")) + .foregroundStyle(.secondary) + .gridColumnAlignment(.trailing) + TextField("", text: $viewModel.paddleOCRCloudModelId) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 300) + } + } + + // Local model directory for vllm backend (only show when mode is precise and not using cloud) + if viewModel.paddleOCRMode == .precise && !viewModel.paddleOCRUseCloud { + Divider() + .gridCellUnsizedAxes(.horizontal) + + GridRow { + Text(localized("settings.paddleocr.localVLModelDir")) + .foregroundStyle(.secondary) + .gridColumnAlignment(.trailing) + VStack(alignment: .leading, spacing: 4) { + TextField("", text: $viewModel.paddleOCRLocalVLModelDir) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 300) + Text(localized("settings.paddleocr.localVLModelDir.hint")) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + } + + // Description + Text(localized("settings.paddleocr.description")) + .font(.caption) + .foregroundStyle(.tertiary) + + // Install instructions or button + if !viewModel.isPaddleOCRInstalled { + VStack(alignment: .leading, spacing: 8) { + if viewModel.isInstallingPaddleOCR { + HStack { + ProgressView() + .controlSize(.small) + Text(localized("settings.paddleocr.installing")) + .foregroundStyle(.secondary) + } + } else { + HStack(spacing: 12) { + Button(localized("settings.paddleocr.install.button")) { + viewModel.installPaddleOCR() + } + .buttonStyle(.bordered) + .controlSize(.small) + + Button(localized("settings.paddleocr.copy.command.button")) { + viewModel.copyPaddleOCRInstallCommand() + } + .buttonStyle(.borderless) + .controlSize(.small) + } + + if let error = viewModel.paddleOCRInstallError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + } + } + } + .padding(.top, 8) + } +} diff --git a/ScreenTranslate/Features/Settings/GeneralSettingsTab.swift b/ScreenTranslate/Features/Settings/GeneralSettingsTab.swift new file mode 100644 index 0000000..76396c9 --- /dev/null +++ b/ScreenTranslate/Features/Settings/GeneralSettingsTab.swift @@ -0,0 +1,210 @@ +import AppKit +import SwiftUI + +struct GeneralSettingsContent: View { + @Bindable var viewModel: SettingsViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Label(localized("settings.section.permissions"), systemImage: "lock.shield") + .font(.headline) + PermissionRow(viewModel: viewModel) + } + .macos26LiquidGlass() + + VStack(alignment: .leading, spacing: 20) { + Label(localized("settings.save.location"), systemImage: "folder") + .font(.headline) + SaveLocationPicker(viewModel: viewModel) + Divider().opacity(0.1) + AppLanguagePicker() + } + .macos26LiquidGlass() + } +} + +// MARK: - Permission Row + +struct PermissionRow: View { + @Bindable var viewModel: SettingsViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + PermissionItem( + icon: "record.circle", + title: localized("settings.permission.screen.recording"), + hint: localized("settings.permission.screen.recording.hint"), + isGranted: viewModel.hasScreenRecordingPermission, + isChecking: viewModel.isCheckingPermissions, + onGrant: { viewModel.requestScreenRecordingPermission() } + ) + + Divider() + + PermissionItem( + icon: "figure.walk.circle", + title: localized("settings.permission.accessibility"), + hint: localized("settings.permission.accessibility.hint"), + isGranted: viewModel.hasAccessibilityPermission, + isChecking: viewModel.isCheckingPermissions, + onGrant: { viewModel.requestAccessibilityPermission() } + ) + + Divider() + + PermissionItem( + icon: "folder", + title: localized("settings.save.location"), + hint: localized("settings.save.location.message"), + isGranted: viewModel.hasFolderAccessPermission, + isChecking: viewModel.isCheckingPermissions, + onGrant: { viewModel.requestFolderAccess() } + ) + + HStack { + Spacer() + Button { + viewModel.checkPermissions() + } label: { + Label(localized("action.reset"), systemImage: "arrow.clockwise") + } + .buttonStyle(.borderless) + } + } + .onAppear { + viewModel.checkPermissions() + } + } +} + +struct PermissionItem: View { + let icon: String + let title: String + let hint: String + let isGranted: Bool + let isChecking: Bool + let onGrant: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + HStack(spacing: 8) { + Image(systemName: icon) + .foregroundStyle(.secondary) + .frame(width: 20) + Text(title) + } + + Spacer() + + if isChecking { + ProgressView() + .controlSize(.small) + } else { + HStack(spacing: 8) { + if isGranted { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text(localized("settings.permission.granted")) + .foregroundStyle(.secondary) + } else { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + + Button { + onGrant() + } label: { + Text(localized("settings.permission.grant")) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + } + } + + if !isGranted && !isChecking { + Text(hint) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel( + Text(""" + \(title), \ + \(isGranted ? localized("settings.permission.granted") : localized("settings.permission.required")) + """) + ) + } +} + +// MARK: - Save Location Picker + +struct SaveLocationPicker: View { + @Bindable var viewModel: SettingsViewModel + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.saveLocationPath) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer() + + Button { + viewModel.selectSaveLocation() + } label: { + Text(localized("settings.save.location.choose")) + } + + Button { + viewModel.revealSaveLocation() + } label: { + Image(systemName: "folder") + } + .help(localized("settings.save.location.reveal")) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text("\(localized("settings.save.location")): \(viewModel.saveLocationPath)")) + } +} + +// MARK: - App Language Picker + +struct AppLanguagePicker: View { + @State private var selectedLanguage: AppLanguage = .system + @State private var isInitialized = false + + var body: some View { + HStack { + Text(localized("settings.language")) + + Spacer() + + Picker("", selection: $selectedLanguage) { + ForEach(AppLanguage.allCases) { language in + Text(language.displayName) + .tag(language) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(minWidth: 120) + .onChange(of: selectedLanguage) { _, newValue in + guard isInitialized else { return } + Task { @MainActor in + LanguageManager.shared.currentLanguage = newValue + } + } + } + .onAppear { + selectedLanguage = LanguageManager.shared.currentLanguage + isInitialized = true + } + } +} diff --git a/ScreenTranslate/Features/Settings/LanguageSettingsTab.swift b/ScreenTranslate/Features/Settings/LanguageSettingsTab.swift new file mode 100644 index 0000000..6a2946c --- /dev/null +++ b/ScreenTranslate/Features/Settings/LanguageSettingsTab.swift @@ -0,0 +1,204 @@ +import SwiftUI + +struct LanguageSettingsContent: View { + @Bindable var viewModel: SettingsViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 24) { + // Global Translation Languages + VStack(alignment: .leading, spacing: 8) { + Text(localized("settings.section.languages")) + .font(.headline) + HStack(spacing: 16) { + VStack(alignment: .leading) { + Text(localized("translation.language.source")) + .font(.caption).foregroundStyle(.secondary) + SourceLanguagePicker(viewModel: viewModel) + } + Image(systemName: "arrow.right.circle.fill").font(.title2).foregroundStyle( + .secondary.opacity(0.5)) + VStack(alignment: .leading) { + Text(localized("translation.language.target")) + .font(.caption).foregroundStyle(.secondary) + TargetLanguagePicker(viewModel: viewModel) + } + } + } + + Divider().opacity(0.3) + + // Translate and Insert Languages + VStack(alignment: .leading, spacing: 8) { + Text(localized("settings.translateAndInsert.language.section")) + .font(.headline) + HStack(spacing: 16) { + VStack(alignment: .leading) { + Text(localized("settings.translateAndInsert.language.source")) + .font(.caption).foregroundStyle(.secondary) + TranslateAndInsertSourceLanguagePicker(viewModel: viewModel) + } + Image(systemName: "arrow.right.circle.fill").font(.title2).foregroundStyle( + .secondary.opacity(0.5)) + VStack(alignment: .leading) { + Text(localized("settings.translateAndInsert.language.target")) + .font(.caption).foregroundStyle(.secondary) + TranslateAndInsertTargetLanguagePicker(viewModel: viewModel) + } + } + } + } + .macos26LiquidGlass() + } +} + +struct SourceLanguagePicker: View { + @Bindable var viewModel: SettingsViewModel + + var body: some View { + Picker(localized("translation.language.source"), selection: $viewModel.translationSourceLanguage) { + ForEach(viewModel.availableSourceLanguages, id: \.rawValue) { language in + Text(language.localizedName) + .tag(language) + } + } + .pickerStyle(.menu) + .help(localized("translation.language.source.hint")) + } +} + +struct TargetLanguagePicker: View { + @Bindable var viewModel: SettingsViewModel + + var body: some View { + HStack { + Text(localized("translation.language.target")) + + Spacer() + + Menu { + Button { + viewModel.translationTargetLanguage = nil + } label: { + HStack { + Text(localized("translation.language.follow.system")) + if viewModel.translationTargetLanguage == nil { + Image(systemName: "checkmark") + } + } + } + + Divider() + + ForEach(viewModel.availableTargetLanguages, id: \.rawValue) { language in + Button { + viewModel.translationTargetLanguage = language + } label: { + HStack { + Text(language.localizedName) + if viewModel.translationTargetLanguage == language { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 4) { + Text(targetLanguageDisplay) + .foregroundStyle(.secondary) + Image(systemName: "chevron.down") + .font(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .menuStyle(.borderlessButton) + .fixedSize() + } + .help(localized("translation.language.target.hint")) + } + + private var targetLanguageDisplay: String { + if let targetLanguage = viewModel.translationTargetLanguage { + return targetLanguage.localizedName + } + return localized("translation.language.follow.system") + } +} + +// MARK: - Translate and Insert Language Pickers + +struct TranslateAndInsertSourceLanguagePicker: View { + @Bindable var viewModel: SettingsViewModel + + var body: some View { + Picker(localized("settings.translateAndInsert.language.source"), selection: $viewModel.translateAndInsertSourceLanguage) { + ForEach(viewModel.availableSourceLanguages, id: \.rawValue) { language in + Text(language.localizedName) + .tag(language) + } + } + .pickerStyle(.menu) + } +} + +struct TranslateAndInsertTargetLanguagePicker: View { + @Bindable var viewModel: SettingsViewModel + + var body: some View { + HStack { + Text(localized("settings.translateAndInsert.language.target")) + + Spacer() + + Menu { + Button { + viewModel.translateAndInsertTargetLanguage = nil + } label: { + HStack { + Text(localized("translation.language.follow.system")) + if viewModel.translateAndInsertTargetLanguage == nil { + Image(systemName: "checkmark") + } + } + } + + Divider() + + ForEach(viewModel.availableTargetLanguages, id: \.rawValue) { language in + Button { + viewModel.translateAndInsertTargetLanguage = language + } label: { + HStack { + Text(language.localizedName) + if viewModel.translateAndInsertTargetLanguage == language { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 4) { + Text(targetLanguageDisplay) + .foregroundStyle(.secondary) + Image(systemName: "chevron.down") + .font(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .menuStyle(.borderlessButton) + .fixedSize() + } + } + + private var targetLanguageDisplay: String { + if let targetLanguage = viewModel.translateAndInsertTargetLanguage { + return targetLanguage.localizedName + } + return localized("translation.language.follow.system") + } +} diff --git a/ScreenTranslate/Features/Settings/MultiEngineSettingsSection.swift b/ScreenTranslate/Features/Settings/MultiEngineSettingsSection.swift new file mode 100644 index 0000000..3101e9e --- /dev/null +++ b/ScreenTranslate/Features/Settings/MultiEngineSettingsSection.swift @@ -0,0 +1,926 @@ +// +// MultiEngineSettingsSection.swift +// ScreenTranslate +// +// Multi-engine configuration section for settings +// + +import SwiftUI +import os.log + +struct MultiEngineSettingsSection: View { + @Bindable var viewModel: SettingsViewModel + @State private var selectedEngine: TranslationEngineType? + @State private var showingConfigSheet = false + @State private var editingConfig: TranslationEngineConfig? + @State private var compatibleSheetState: CompatibleSheetState? + + // Sheet state for compatible engine configuration + struct CompatibleSheetState: Identifiable { + let id = UUID() + let config: CompatibleTranslationProvider.CompatibleConfig + let index: Int + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Selection Mode (horizontal layout) + selectionModeSection + + // Mode-specific configuration + modeSpecificSection + + Divider() + + // Available Engines + enginesSection + } + .padding() + .background(Color(.controlBackgroundColor)) + .cornerRadius(8) + } + + // MARK: - Selection Mode Section (Horizontal) + + @ViewBuilder + private var selectionModeSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text(localized("engine.config.title")) + .font(.headline) + + // Horizontal button group + HStack(spacing: 4) { + ForEach(EngineSelectionMode.allCases) { mode in + Button { + viewModel.settings.engineSelectionMode = mode + } label: { + HStack(spacing: 4) { + Image(systemName: mode.iconName) + .font(.caption) + Text(mode.localizedName) + .font(.subheadline) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(viewModel.settings.engineSelectionMode == mode ? Color.accentColor : Color.clear) + .foregroundStyle(viewModel.settings.engineSelectionMode == mode ? .white : .primary) + .cornerRadius(6) + } + .buttonStyle(.plain) + .help(mode.modeDescription) + } + } + .padding(4) + .background(Color(.controlBackgroundColor).opacity(0.5)) + .cornerRadius(8) + } + } + + // MARK: - Mode Specific Section + + @ViewBuilder + private var modeSpecificSection: some View { + switch viewModel.settings.engineSelectionMode { + case .primaryWithFallback: + primaryFallbackSection + case .parallel: + parallelEnginesSection + case .quickSwitch: + quickSwitchSection + case .sceneBinding: + sceneBindingSection + } + } + + // MARK: - Primary/Fallback Section + + @ViewBuilder + private var primaryFallbackSection: some View { + let enabledEngines = viewModel.settings.engineConfigs.values.filter { $0.isEnabled } + let compatibleConfigs = viewModel.settings.compatibleProviderConfigs + + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 24) { + // Primary Engine + VStack(alignment: .leading, spacing: 4) { + Text(localized("engine.config.primary")) + .font(.caption) + .foregroundStyle(.secondary) + Picker("", selection: Binding( + get: { + if let first = viewModel.settings.parallelEngines.first { + // Check if it's a compatible engine + if first == .custom, let customConfig = viewModel.settings.engineConfigs[.custom], + let customNameStr = customConfig.customName, + let jsonData = customNameStr.data(using: .utf8), + let compatibleConfig = try? JSONDecoder().decode(CompatibleTranslationProvider.CompatibleConfig.self, from: jsonData) { + return "compatible:\(compatibleConfig.id)" + } + return first.rawValue + } + return TranslationEngineType.apple.rawValue + }, + set: { (newValue: String) in + let engine: TranslationEngineType + if newValue.hasPrefix("compatible:") { + // Set compatible config as custom engine + let configId = String(newValue.dropFirst("compatible:".count)) + if let config = compatibleConfigs.first(where: { $0.id.uuidString == configId }) { + setCompatibleAsEngine(config: config) + } + return + } else { + engine = TranslationEngineType(rawValue: newValue) ?? .apple + } + if viewModel.settings.parallelEngines.isEmpty { + viewModel.settings.parallelEngines = [engine] + } else { + viewModel.settings.parallelEngines[0] = engine + } + viewModel.settings.saveParallelEngines() + } + )) { + // Standard engines + ForEach(enabledEngines.filter { $0.id != .custom }, id: \.id) { config in + Text(config.id.localizedName).tag(config.id.rawValue) + } + // Compatible engines + ForEach(compatibleConfigs) { config in + Text(config.displayName).tag("compatible:\(config.id)") + } + } + .pickerStyle(.menu) + .frame(width: 140) + } + + // Fallback Engine + VStack(alignment: .leading, spacing: 4) { + Text(localized("engine.config.fallback")) + .font(.caption) + .foregroundStyle(.secondary) + Picker("", selection: Binding( + get: { + if viewModel.settings.parallelEngines.count > 1 { + let second = viewModel.settings.parallelEngines[1] + if second == .custom, let customConfig = viewModel.settings.engineConfigs[.custom], + let customNameStr = customConfig.customName, + let jsonData = customNameStr.data(using: .utf8), + let compatibleConfig = try? JSONDecoder().decode(CompatibleTranslationProvider.CompatibleConfig.self, from: jsonData) { + return "compatible:\(compatibleConfig.id)" + } + return second.rawValue + } + return (enabledEngines.first?.id ?? .apple).rawValue + }, + set: { (newValue: String) in + let engine: TranslationEngineType + if newValue.hasPrefix("compatible:") { + let configId = String(newValue.dropFirst("compatible:".count)) + if let config = compatibleConfigs.first(where: { $0.id.uuidString == configId }) { + setCompatibleAsEngine(config: config) + } + return + } else { + engine = TranslationEngineType(rawValue: newValue) ?? .apple + } + if viewModel.settings.parallelEngines.count > 1 { + viewModel.settings.parallelEngines[1] = engine + } else { + viewModel.settings.parallelEngines.append(engine) + } + viewModel.settings.saveParallelEngines() + } + )) { + // Standard engines + ForEach(enabledEngines.filter { $0.id != .custom }, id: \.id) { config in + Text(config.id.localizedName).tag(config.id.rawValue) + } + // Compatible engines + ForEach(compatibleConfigs) { config in + Text(config.displayName).tag("compatible:\(config.id)") + } + } + .pickerStyle(.menu) + .frame(width: 140) + } + } + } + } + + // MARK: - Quick Switch Section + + @ViewBuilder + private var quickSwitchSection: some View { + let enabledEngines = viewModel.settings.engineConfigs.values.filter { $0.isEnabled } + let compatibleConfigs = viewModel.settings.compatibleProviderConfigs + + VStack(alignment: .leading, spacing: 8) { + Text(localized("engine.config.switch.order")) + .font(.caption) + .foregroundStyle(.secondary) + + // Engine order list + ForEach(Array(viewModel.settings.parallelEngines.enumerated()), id: \.offset) { index, engine in + HStack(spacing: 8) { + // Order number + Text("\(index + 1)") + .font(.caption) + .foregroundStyle(.white) + .frame(width: 18, height: 18) + .background(Color.accentColor) + .cornerRadius(9) + + // Engine name + Text(engine.localizedName) + .font(.subheadline) + + Spacer() + + // Replace button + Menu { + ForEach(enabledEngines.filter { $0.id != .custom }, id: \.id) { config in + Button(config.id.localizedName) { + viewModel.settings.parallelEngines[index] = config.id + viewModel.settings.saveParallelEngines() + } + } + if !compatibleConfigs.isEmpty { + Divider() + ForEach(compatibleConfigs) { config in + Button(config.displayName) { + setCompatibleAsEngineAtIndex(config: config, index: index) + } + } + } + } label: { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.caption) + .foregroundStyle(.secondary) + } + .menuStyle(.borderlessButton) + .help(localized("engine.config.replace")) + + // Remove button (if more than 1) + if viewModel.settings.parallelEngines.count > 1 { + Button { + viewModel.settings.parallelEngines.remove(at: index) + viewModel.settings.saveParallelEngines() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.caption) + .foregroundStyle(.red) + } + .buttonStyle(.plain) + .help(localized("engine.config.remove")) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color(.controlBackgroundColor)) + .cornerRadius(6) + } + + // Add engine button if less than enabled engines + if viewModel.settings.parallelEngines.count < enabledEngines.count + compatibleConfigs.count { + Menu { + ForEach(enabledEngines.filter { $0.id != .custom }, id: \.id) { config in + if !viewModel.settings.parallelEngines.contains(config.id) { + Button { + viewModel.settings.parallelEngines.append(config.id) + viewModel.settings.saveParallelEngines() + } label: { + HStack { + Image(systemName: engineIcon(config.id)) + Text(config.id.localizedName) + } + } + } + } + if !compatibleConfigs.isEmpty { + Divider() + ForEach(compatibleConfigs) { config in + Button { + appendCompatibleAsEngine(config: config) + } label: { + HStack { + Image(systemName: "gearshape.2") + Text(config.displayName) + } + } + } + } + } label: { + HStack(spacing: 4) { + Image(systemName: "plus.circle") + Text(localized("engine.config.add")) + } + .font(.caption) + .foregroundStyle(Color.accentColor) + } + .menuStyle(.borderlessButton) + } + } + } + + // MARK: - Engines Section + + @ViewBuilder + private var enginesSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(localized("engine.available.title")) + .font(.headline) + Spacer() + } + + // Group engines by category + ForEach(EngineCategory.allCases, id: \.self) { category in + if category == .compatible { + // Special handling for compatible engines - dynamic cards + compatibleEnginesSection + } else { + let enginesInCategory = TranslationEngineType.allCases.filter { $0.category == category } + if !enginesInCategory.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text(category.localizedName) + .font(.subheadline) + .foregroundStyle(.secondary) + + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 8) { + ForEach(enginesInCategory, id: \.self) { engine in + engineCard(engine) + } + } + } + } + } + } + } + .sheet(item: $editingConfig) { config in + let engine = config.id + EngineConfigSheet( + engine: engine, + config: Binding( + get: { viewModel.settings.engineConfigs[engine] ?? .default(for: engine) }, + set: { newValue in + // Use full property assignment instead of subscript + // to guarantee @Observable persistence + Logger.settings.info("Engine config updated: \(engine.rawValue), isEnabled=\(newValue.isEnabled)") + var configs = viewModel.settings.engineConfigs + configs[engine] = newValue + viewModel.settings.engineConfigs = configs + viewModel.settings.saveEngineConfigs() + } + ) + ) + } + .sheet(item: $compatibleSheetState) { state in + CompatibleEngineConfigSheet( + config: state.config, + index: state.index, + isNew: state.index >= viewModel.settings.compatibleProviderConfigs.count, + onSave: { savedConfig in + if state.index >= viewModel.settings.compatibleProviderConfigs.count { + viewModel.settings.compatibleProviderConfigs.append(savedConfig) + } else { + viewModel.settings.compatibleProviderConfigs[state.index] = savedConfig + } + viewModel.settings.saveCompatibleConfigs() + } + ) + } + } + + // MARK: - Compatible Engines Section + + @ViewBuilder + private var compatibleEnginesSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text(EngineCategory.compatible.localizedName) + .font(.subheadline) + .foregroundStyle(.secondary) + + // Dynamic compatible engine cards + ForEach(Array(viewModel.settings.compatibleProviderConfigs.enumerated()), id: \.element.id) { index, config in + compatibleEngineCard(config: config, index: index) + } + + // Add button (max 5 engines) + if viewModel.settings.compatibleProviderConfigs.count < 5 { + addCompatibleEngineButton + } else { + Text(localized("engine.compatible.max.reached")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private func compatibleEngineCard(config: CompatibleTranslationProvider.CompatibleConfig, index: Int) -> some View { + Button { + compatibleSheetState = CompatibleSheetState(config: config, index: index) + } label: { + HStack(spacing: 8) { + Image(systemName: "gearshape.2") + .font(.body) + .foregroundStyle(Color.accentColor) + + VStack(alignment: .leading, spacing: 2) { + Text(config.displayName) + .font(.subheadline) + .lineLimit(1) + + HStack(spacing: 4) { + Circle() + .fill(Color.green) + .frame(width: 6, height: 6) + Text(localized("engine.status.configured")) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + Spacer() + + // Use as engine button + Button { + setCompatibleAsEngine(config: config) + } label: { + Image(systemName: "checkmark.circle") + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + .help(localized("engine.compatible.useAsEngine")) + + // Delete button + Button { + deleteCompatibleEngine(at: index) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.red) + } + .buttonStyle(.plain) + .help(localized("engine.compatible.delete")) + } + .padding(8) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.accentColor, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private var addCompatibleEngineButton: some View { + Button { + compatibleSheetState = CompatibleSheetState( + config: CompatibleTranslationProvider.CompatibleConfig.default, + index: viewModel.settings.compatibleProviderConfigs.count + ) + } label: { + HStack(spacing: 6) { + Image(systemName: "plus.circle") + .font(.body) + Text(localized("engine.compatible.add")) + .font(.subheadline) + } + .foregroundStyle(Color.accentColor) + .padding(8) + .frame(maxWidth: .infinity) + .background(Color(.controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(style: SwiftUI.StrokeStyle(lineWidth: 1, dash: [4])) + .foregroundStyle(Color.gray.opacity(0.3)) + ) + } + .buttonStyle(.plain) + } + + private func deleteCompatibleEngine(at index: Int) { + Task { + do { + let config = viewModel.settings.compatibleProviderConfigs[index] + try await KeychainService.shared.deleteCredentials(forCompatibleId: config.keychainId) + + // Clear cached provider for this engine + await TranslationEngineRegistry.shared.removeCompatibleProvider(for: config.keychainId) + + await MainActor.run { + viewModel.settings.compatibleProviderConfigs.remove(at: index) + viewModel.settings.saveCompatibleConfigs() + } + } catch { + // Log error but don't remove config if credential migration fails + Logger.settings.error("Failed to delete compatible engine: \(error.localizedDescription)") + // Optionally show alert to user + } + } + } + + private func setCompatibleAsPrimaryEngine(at index: Int) { + guard index < viewModel.settings.compatibleProviderConfigs.count else { return } + let config = viewModel.settings.compatibleProviderConfigs[index] + setCompatibleAsEngine(config: config) + } + + private func setCompatibleAsEngineAtIndex(config: CompatibleTranslationProvider.CompatibleConfig, index: Int) { + Task { + do { + let provider = try await TranslationEngineRegistry.shared.createCompatibleProvider( + compatibleConfig: config + ) + + let isAvailable = await provider.isAvailable + guard isAvailable else { + Logger.settings.warning("Compatible engine \(config.displayName) is not available") + return + } + + await MainActor.run { + var customConfig = viewModel.settings.engineConfigs[.custom] ?? .default(for: .custom) + if let jsonData = try? JSONEncoder().encode(config), + let jsonString = String(data: jsonData, encoding: .utf8) { + customConfig.customName = jsonString + customConfig.isEnabled = true + viewModel.settings.engineConfigs[.custom] = customConfig + viewModel.settings.saveEngineConfigs() + viewModel.settings.parallelEngines[index] = .custom + viewModel.settings.saveParallelEngines() + } + } + } catch { + Logger.settings.error("Failed to set compatible engine: \(error.localizedDescription)") + } + } + } + + private func appendCompatibleAsEngine(config: CompatibleTranslationProvider.CompatibleConfig) { + Task { + do { + let provider = try await TranslationEngineRegistry.shared.createCompatibleProvider( + compatibleConfig: config + ) + + let isAvailable = await provider.isAvailable + guard isAvailable else { + Logger.settings.warning("Compatible engine \(config.displayName) is not available") + return + } + + await MainActor.run { + var customConfig = viewModel.settings.engineConfigs[.custom] ?? .default(for: .custom) + if let jsonData = try? JSONEncoder().encode(config), + let jsonString = String(data: jsonData, encoding: .utf8) { + customConfig.customName = jsonString + customConfig.isEnabled = true + viewModel.settings.engineConfigs[.custom] = customConfig + viewModel.settings.saveEngineConfigs() + viewModel.settings.parallelEngines.append(.custom) + viewModel.settings.saveParallelEngines() + } + } + } catch { + Logger.settings.error("Failed to append compatible engine: \(error.localizedDescription)") + } + } + } + + private func setCompatibleAsEngine(config: CompatibleTranslationProvider.CompatibleConfig) { + Task { + do { + // Create provider to verify configuration + let provider = try await TranslationEngineRegistry.shared.createCompatibleProvider( + compatibleConfig: config + ) + + // Verify availability + let isAvailable = await provider.isAvailable + guard isAvailable else { + Logger.settings.warning("Compatible engine \(config.displayName) is not available") + return + } + + // Update custom engine config with this compatible config + await MainActor.run { + var customConfig = viewModel.settings.engineConfigs[.custom] ?? .default(for: .custom) + if let jsonData = try? JSONEncoder().encode(config), + let jsonString = String(data: jsonData, encoding: .utf8) { + customConfig.customName = jsonString + customConfig.isEnabled = true + viewModel.settings.engineConfigs[.custom] = customConfig + viewModel.settings.saveEngineConfigs() + + // Set as primary engine + if viewModel.settings.parallelEngines.isEmpty { + viewModel.settings.parallelEngines = [.custom] + } else { + viewModel.settings.parallelEngines[0] = .custom + } + viewModel.settings.saveParallelEngines() + } + } + } catch { + Logger.settings.error("Failed to set compatible engine: \(error.localizedDescription)") + } + } + } + + @ViewBuilder + private func engineCard(_ engine: TranslationEngineType) -> some View { + let config = viewModel.settings.engineConfigs[engine] ?? .default(for: engine) + let _ = Logger.settings.info("engineCard \(engine.rawValue): isEnabled=\(config.isEnabled), fromDefault=\(viewModel.settings.engineConfigs[engine] == nil)") + // Built-in engines (apple, mtranServer) and Ollama don't need API keys + // For others, we check if they require API key (simplified check - in real use would check keychain) + let isConfigured = !engine.requiresAPIKey || config.isEnabled + + Button { + editingConfig = config + } label: { + HStack(spacing: 8) { + Image(systemName: engineIcon(engine)) + .font(.body) + .foregroundStyle(config.isEnabled ? Color.accentColor : Color.secondary) + + VStack(alignment: .leading, spacing: 2) { + Text(engine.localizedName) + .font(.subheadline) + .lineLimit(1) + + // Show status for all engines + HStack(spacing: 4) { + Circle() + .fill(isConfigured ? Color.green : Color.orange) + .frame(width: 6, height: 6) + Text(isConfigured ? localized("engine.status.configured") : localized("engine.status.unconfigured")) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + Spacer() + + if config.isEnabled { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } + .padding(8) + .background(config.isEnabled ? Color.accentColor.opacity(0.1) : Color.clear) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(config.isEnabled ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + // MARK: - Parallel Engines Section (Select which engines to run) + + @ViewBuilder + private var parallelEnginesSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text(localized("engine.config.parallel.select")) + .font(.caption) + .foregroundStyle(.secondary) + + let enabledEngines = viewModel.settings.engineConfigs.values.filter { $0.isEnabled } + let compatibleConfigs = viewModel.settings.compatibleProviderConfigs + + FlowLayout(spacing: 8) { + ForEach(enabledEngines.filter { $0.id != .custom }, id: \.id) { config in + HStack(spacing: 4) { + Image(systemName: engineIcon(config.id)) + .font(.caption) + Text(config.id.localizedName) + .font(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(viewModel.settings.parallelEngines.contains(config.id) ? Color.accentColor.opacity(0.2) : Color.clear) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(viewModel.settings.parallelEngines.contains(config.id) ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: 1) + ) + .onTapGesture { + if viewModel.settings.parallelEngines.contains(config.id) { + viewModel.settings.parallelEngines.removeAll { $0 == config.id } + } else { + viewModel.settings.parallelEngines.append(config.id) + } + viewModel.settings.saveParallelEngines() + } + } + + // Compatible engines + ForEach(compatibleConfigs) { config in + HStack(spacing: 4) { + Image(systemName: "gearshape.2") + .font(.caption) + Text(config.displayName) + .font(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(isCompatibleEngineSelected(config) ? Color.accentColor.opacity(0.2) : Color.clear) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(isCompatibleEngineSelected(config) ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: 1) + ) + .onTapGesture { + toggleCompatibleEngineInParallel(config: config) + } + } + } + } + } + + private func isCompatibleEngineSelected(_ config: CompatibleTranslationProvider.CompatibleConfig) -> Bool { + guard viewModel.settings.parallelEngines.contains(.custom), + let customConfig = viewModel.settings.engineConfigs[.custom], + let customNameStr = customConfig.customName, + let jsonData = customNameStr.data(using: .utf8), + let selectedConfig = try? JSONDecoder().decode(CompatibleTranslationProvider.CompatibleConfig.self, from: jsonData) else { + return false + } + return selectedConfig.id == config.id + } + + private func toggleCompatibleEngineInParallel(config: CompatibleTranslationProvider.CompatibleConfig) { + if isCompatibleEngineSelected(config) { + viewModel.settings.parallelEngines.removeAll { $0 == .custom } + viewModel.settings.saveParallelEngines() + } else { + // Set this compatible config as custom and add to parallel engines + var customConfig = viewModel.settings.engineConfigs[.custom] ?? .default(for: .custom) + if let jsonData = try? JSONEncoder().encode(config), + let jsonString = String(data: jsonData, encoding: .utf8) { + customConfig.customName = jsonString + customConfig.isEnabled = true + viewModel.settings.engineConfigs[.custom] = customConfig + viewModel.settings.saveEngineConfigs() + if !viewModel.settings.parallelEngines.contains(.custom) { + viewModel.settings.parallelEngines.append(.custom) + viewModel.settings.saveParallelEngines() + } + } + } + } + + // MARK: - Scene Binding Section + + @ViewBuilder + private var sceneBindingSection: some View { + VStack(alignment: .leading, spacing: 8) { + let enabledEngines = viewModel.settings.engineConfigs.values.filter { $0.isEnabled } + let compatibleConfigs = viewModel.settings.compatibleProviderConfigs + + ForEach(TranslationScene.allCases) { scene in + HStack { + Image(systemName: scene.iconName) + .frame(width: 20) + Text(scene.localizedName) + .frame(width: 100, alignment: .leading) + + Spacer() + + // Primary engine picker + Picker("", selection: Binding( + get: { + let engine = viewModel.settings.sceneBindings[scene]?.primaryEngine ?? .apple + if engine == .custom, let customConfig = viewModel.settings.engineConfigs[.custom], + let customNameStr = customConfig.customName, + let jsonData = customNameStr.data(using: .utf8), + let compatibleConfig = try? JSONDecoder().decode(CompatibleTranslationProvider.CompatibleConfig.self, from: jsonData) { + return "compatible:\(compatibleConfig.id.uuidString)" + } + return engine.rawValue + }, + set: { (newValue: String) in + var binding = viewModel.settings.sceneBindings[scene] ?? .default(for: scene) + if newValue.hasPrefix("compatible:") { + let configId = String(newValue.dropFirst("compatible:".count)) + if let config = compatibleConfigs.first(where: { $0.id.uuidString == configId }) { + // Set compatible config as custom engine + var customConfig = viewModel.settings.engineConfigs[.custom] ?? .default(for: .custom) + if let jsonData = try? JSONEncoder().encode(config), + let jsonString = String(data: jsonData, encoding: .utf8) { + customConfig.customName = jsonString + customConfig.isEnabled = true + viewModel.settings.engineConfigs[.custom] = customConfig + viewModel.settings.saveEngineConfigs() + binding.primaryEngine = .custom + } + } + } else { + binding.primaryEngine = TranslationEngineType(rawValue: newValue) ?? .apple + } + viewModel.settings.sceneBindings[scene] = binding + viewModel.settings.saveSceneBindings() + } + )) { + ForEach(enabledEngines.filter { $0.id != .custom }, id: \.id) { config in + Text(config.id.localizedName).tag(config.id.rawValue) + } + ForEach(compatibleConfigs) { config in + Text(config.displayName).tag("compatible:\(config.id.uuidString)") + } + } + .pickerStyle(.menu) + .frame(width: 130) + } + } + } + } + + // MARK: - Helpers + + private func engineIcon(_ engine: TranslationEngineType) -> String { + switch engine { + case .apple: return "apple.logo" + case .mtranServer: return "server.rack" + case .openai: return "brain.head.profile" + case .claude: return "bubble.left.and.bubble.right" + case .gemini: return "sparkles" + case .ollama: return "cpu" + case .google: return "globe" + case .deepl: return "character.bubble" + case .baidu: return "network" + case .custom: return "gearshape.2" + } + } +} + +// MARK: - Flow Layout + +struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let rows = computeRows(proposal: proposal, subviews: subviews) + let height = rows.reduce(0) { $0 + $1.height + spacing } - spacing + return CGSize(width: proposal.width ?? 0, height: height > 0 ? height : 0) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let rows = computeRows(proposal: proposal, subviews: subviews) + var y = bounds.minY + for row in rows { + var x = bounds.minX + for item in row.items { + subviews[item.index].place(at: CGPoint(x: x, y: y), proposal: .unspecified) + x += item.size.width + spacing + } + y += row.height + spacing + } + } + + private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [Row] { + var rows: [Row] = [] + var currentRow = Row() + var currentX: CGFloat = 0 + let maxWidth = proposal.width ?? .infinity + + for (index, subview) in subviews.enumerated() { + let size = subview.sizeThatFits(.unspecified) + + if currentX + size.width > maxWidth, !currentRow.items.isEmpty { + rows.append(currentRow) + currentRow = Row() + currentX = 0 + } + + currentRow.items.append(RowItem(index: index, size: size)) + currentRow.height = max(currentRow.height, size.height) + currentX += size.width + spacing + } + + if !currentRow.items.isEmpty { + rows.append(currentRow) + } + + return rows + } + + struct Row { + var items: [RowItem] = [] + var height: CGFloat = 0 + } + + struct RowItem { + let index: Int + let size: CGSize + } +} diff --git a/ScreenTranslate/Features/Settings/PromptSettingsView.swift b/ScreenTranslate/Features/Settings/PromptSettingsView.swift new file mode 100644 index 0000000..f3f7583 --- /dev/null +++ b/ScreenTranslate/Features/Settings/PromptSettingsView.swift @@ -0,0 +1,371 @@ +// +// PromptSettingsView.swift +// ScreenTranslate +// +// Prompt configuration view for translation engines +// + +import SwiftUI + +// MARK: - Prompt Settings Content (for sidebar tab) + +struct PromptSettingsContent: View { + @Bindable var viewModel: SettingsViewModel + + var body: some View { + ScrollView { + PromptSettingsView(viewModel: viewModel) + .padding() + } + } +} + +// MARK: - Prompt Settings View + +struct PromptSettingsView: View { + @Bindable var viewModel: SettingsViewModel + @State private var editingTarget: PromptEditTarget? + @State private var editingPrompt: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 24) { + // Engine Prompts Section + enginePromptsSection + + Divider() + + // Scene Prompts Section + scenePromptsSection + + // Default Prompt Preview + defaultPromptSection + } + .sheet(item: $editingTarget) { target in + PromptEditorSheet( + target: target, + prompt: $editingPrompt, + onSave: { + savePrompt(for: target, prompt: editingPrompt) + } + ) + } + } + + // MARK: - Engine Prompts Section + + @ViewBuilder + private var enginePromptsSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text(localized("prompt.engine.title")) + .font(.headline) + + Text(localized("prompt.engine.description")) + .font(.caption) + .foregroundStyle(.secondary) + + // Built-in LLM Engines that support custom prompts + let llmEngines: [TranslationEngineType] = [.openai, .claude, .gemini, .ollama] + + ForEach(llmEngines, id: \.self) { engine in + HStack { + Image(systemName: engineIcon(engine)) + .frame(width: 24) + + Text(engine.localizedName) + + Spacer() + + // Show if custom prompt is set + if let prompt = viewModel.settings.promptConfig.enginePrompts[engine], !prompt.isEmpty { + Image(systemName: "pencil.circle.fill") + .foregroundStyle(.tint) + } + + Button(localized("prompt.button.edit")) { + editingTarget = .engine(engine) + editingPrompt = viewModel.settings.promptConfig.enginePrompts[engine] ?? "" + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.vertical, 4) + } + + // Compatible Engines (only show configured ones) + if !viewModel.settings.compatibleProviderConfigs.isEmpty { + Divider() + + Text(localized("prompt.compatible.title")) + .font(.subheadline) + .foregroundStyle(.secondary) + + ForEach(viewModel.settings.compatibleProviderConfigs) { config in + HStack { + Image(systemName: "gearshape.2") + .frame(width: 24) + + Text(config.displayName) + + Spacer() + + // Show if custom prompt is set + if let prompt = viewModel.settings.promptConfig.compatibleEnginePrompts[config.id.uuidString], !prompt.isEmpty { + Image(systemName: "pencil.circle.fill") + .foregroundStyle(.tint) + } + + Button(localized("prompt.button.edit")) { + editingTarget = .compatibleEngine(config.id.uuidString, config.displayName) + editingPrompt = viewModel.settings.promptConfig.compatibleEnginePrompts[config.id.uuidString] ?? "" + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.vertical, 4) + } + } + } + .padding() + .background(Color(.controlBackgroundColor)) + .cornerRadius(8) + } + + // MARK: - Scene Prompts Section + + @ViewBuilder + private var scenePromptsSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text(localized("prompt.scene.title")) + .font(.headline) + + Text(localized("prompt.scene.description")) + .font(.caption) + .foregroundStyle(.secondary) + + ForEach(TranslationScene.allCases) { scene in + HStack { + Image(systemName: scene.iconName) + .frame(width: 24) + + Text(scene.localizedName) + + Spacer() + + // Show if custom prompt is set + if let prompt = viewModel.settings.promptConfig.scenePrompts[scene], !prompt.isEmpty { + Image(systemName: "pencil.circle.fill") + .foregroundStyle(.tint) + } + + Button(localized("prompt.button.edit")) { + editingTarget = .scene(scene) + editingPrompt = viewModel.settings.promptConfig.scenePrompts[scene] ?? "" + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.vertical, 4) + } + } + .padding() + .background(Color(.controlBackgroundColor)) + .cornerRadius(8) + } + + // MARK: - Default Prompt Section + + @ViewBuilder + private var defaultPromptSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text(localized("prompt.default.title")) + .font(.headline) + + Text(localized("prompt.default.description")) + .font(.caption) + .foregroundStyle(.secondary) + + ScrollView { + Text(TranslationPromptConfig.defaultPrompt) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.textBackgroundColor)) + .cornerRadius(8) + } + .frame(height: 150) + } + .padding() + .background(Color(.controlBackgroundColor)) + .cornerRadius(8) + } + + // MARK: - Actions + + private func savePrompt(for target: PromptEditTarget, prompt: String) { + var config = viewModel.settings.promptConfig + + switch target { + case .engine(let engine): + if prompt.isEmpty { + config.enginePrompts.removeValue(forKey: engine) + } else { + config.enginePrompts[engine] = prompt + } + case .compatibleEngine(let promptID, _): + if prompt.isEmpty { + config.compatibleEnginePrompts.removeValue(forKey: promptID) + } else { + config.compatibleEnginePrompts[promptID] = prompt + } + case .scene(let scene): + if prompt.isEmpty { + config.scenePrompts.removeValue(forKey: scene) + } else { + config.scenePrompts[scene] = prompt + } + } + + viewModel.settings.promptConfig = config + } + + // MARK: - Helpers + + private func engineIcon(_ engine: TranslationEngineType) -> String { + switch engine { + case .apple: return "apple.logo" + case .mtranServer: return "server.rack" + case .openai: return "brain.head.profile" + case .claude: return "bubble.left.and.bubble.right" + case .gemini: return "sparkles" + case .ollama: return "cpu" + case .google: return "globe" + case .deepl: return "character.bubble" + case .baidu: return "network" + case .custom: return "gearshape.2" + } + } +} + +// MARK: - Prompt Edit Target + +enum PromptEditTarget: Identifiable { + case engine(TranslationEngineType) + case compatibleEngine(String, String) + case scene(TranslationScene) + + var id: String { + switch self { + case .engine(let engine): + return "engine-\(engine.rawValue)" + case .compatibleEngine(let promptID, _): + return "compatible-\(promptID)" + case .scene(let scene): + return "scene-\(scene.rawValue)" + } + } + + var title: String { + switch self { + case .engine(let engine): + return engine.localizedName + case .compatibleEngine(_, let displayName): + return displayName + case .scene(let scene): + return scene.localizedName + } + } +} + +// MARK: - Prompt Editor Sheet + +struct PromptEditorSheet: View { + let target: PromptEditTarget + @Binding var prompt: String + let onSave: () -> Void + + @Environment(\.dismiss) private var dismiss + @State private var localPrompt: String = "" + + var body: some View { + VStack(spacing: 16) { + // Header + HStack { + Text(localized("prompt.editor.title")) + .font(.headline) + Spacer() + Text(target.title) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + // Editor + TextEditor(text: $localPrompt) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 200) + .padding(4) + .background(Color(.textBackgroundColor)) + .cornerRadius(8) + + // Variable Hints + VStack(alignment: .leading, spacing: 8) { + Text(localized("prompt.editor.variables")) + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + ForEach(TranslationPromptConfig.templateVariables) { variable in + Button { + copyVariable(variable.name) + } label: { + HStack(spacing: 2) { + Text(variable.name) + .font(.system(.caption, design: .monospaced)) + Image(systemName: "doc.on.doc") + .font(.system(size: 8)) + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .help(variable.description) + } + } + } + + Divider() + + // Footer + HStack { + Button(localized("prompt.button.reset")) { + localPrompt = "" + } + .buttonStyle(.bordered) + + Spacer() + + Button(localized("button.cancel")) { + dismiss() + } + .buttonStyle(.bordered) + + Button(localized("button.save")) { + prompt = localPrompt + onSave() + dismiss() + } + .buttonStyle(.borderedProminent) + } + } + .padding() + .frame(width: 600, height: 400) + .onAppear { + localPrompt = prompt + } + } + + private func copyVariable(_ variable: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(variable, forType: .string) + } +} diff --git a/ScreenTranslate/Features/Settings/SettingsTab.swift b/ScreenTranslate/Features/Settings/SettingsTab.swift new file mode 100644 index 0000000..1c3ca73 --- /dev/null +++ b/ScreenTranslate/Features/Settings/SettingsTab.swift @@ -0,0 +1,40 @@ +import SwiftUI + +enum SettingsTab: String, CaseIterable, Identifiable, Sendable { + case general, engines, prompts, languages, shortcuts, advanced + var id: String { self.rawValue } + + @MainActor + var displayName: String { + switch self { + case .general: return localized("settings.section.general") + case .engines: return localized("settings.section.engines") + case .prompts: return localized("settings.section.prompts") + case .languages: return localized("settings.section.languages") + case .shortcuts: return localized("settings.section.shortcuts") + case .advanced: return localized("settings.section.annotations") + } + } + + var icon: String { + switch self { + case .general: return "gearshape" + case .engines: return "engine.combustion" + case .prompts: return "text.bubble" + case .languages: return "globe" + case .shortcuts: return "keyboard" + case .advanced: return "pencil.tip.crop.circle" + } + } + + var color: Color { + switch self { + case .general: return .blue + case .engines: return .orange + case .prompts: return .pink + case .languages: return .cyan + case .shortcuts: return .purple + case .advanced: return .green + } + } +} diff --git a/ScreenTranslate/Features/Settings/SettingsView.swift b/ScreenTranslate/Features/Settings/SettingsView.swift new file mode 100644 index 0000000..b416938 --- /dev/null +++ b/ScreenTranslate/Features/Settings/SettingsView.swift @@ -0,0 +1,97 @@ +import AppKit +import SwiftUI + +struct SettingsView: View { + @Bindable var viewModel: SettingsViewModel + @State private var refreshID = UUID() + @State private var selectedTab: SettingsTab = .general + + var body: some View { + NavigationSplitView { + List(SettingsTab.allCases, selection: $selectedTab) { tab in + NavigationLink(value: tab) { + Label { + Text(tab.displayName) + } icon: { + Image(systemName: tab.icon) + .foregroundStyle(tab.color) + } + } + .padding(.vertical, 4) + } + .listStyle(.sidebar) + .navigationSplitViewColumnWidth(min: 180, ideal: 200) + } detail: { + VStack(spacing: 0) { + HStack { + Text(selectedTab.displayName) + .font(.system(size: 24, weight: .bold, design: .rounded)) + Spacer() + } + .padding(.horizontal, 30) + .padding(.top, 44) + .padding(.bottom, 20) + + ScrollView { + VStack(spacing: 24) { + switch selectedTab { + case .general: + GeneralSettingsContent(viewModel: viewModel) + case .engines: + EngineSettingsContent(viewModel: viewModel) + case .prompts: + PromptSettingsContent(viewModel: viewModel) + case .languages: + LanguageSettingsContent(viewModel: viewModel) + case .shortcuts: + ShortcutSettingsContent(viewModel: viewModel) + case .advanced: + AdvancedSettingsContent(viewModel: viewModel) + } + } + .padding(.horizontal, 24) + .padding(.bottom, 40) + } + } + .background(Color(.windowBackgroundColor)) + } + .frame(width: 800, height: 600) + .background(Color(.windowBackgroundColor)) + .id(refreshID) + .onReceive( + NotificationCenter.default.publisher(for: LanguageManager.languageDidChangeNotification) + ) { _ in + refreshID = UUID() + } + .alert(localized("error.title"), isPresented: $viewModel.showErrorAlert) { + Button(localized("button.ok")) { + viewModel.errorMessage = nil + } + } message: { + if let message = viewModel.errorMessage { + Text(message) + } + } + } +} + +extension View { + @ViewBuilder + func `if`( + _ condition: Bool, + transform: (Self) -> Content + ) -> some View { + if condition { + transform(self) + } else { + self + } + } +} + +#if DEBUG + #Preview { + SettingsView(viewModel: SettingsViewModel()) + .frame(width: 500, height: 600) + } +#endif diff --git a/ScreenTranslate/Features/Settings/SettingsViewModel.swift b/ScreenTranslate/Features/Settings/SettingsViewModel.swift new file mode 100644 index 0000000..280f35b --- /dev/null +++ b/ScreenTranslate/Features/Settings/SettingsViewModel.swift @@ -0,0 +1,1261 @@ +import Foundation +import SwiftUI +import AppKit +import Carbon.HIToolbox +@preconcurrency import ScreenCaptureKit + +// MARK: - Shortcut Recording Type + +/// Represents which shortcut is currently being recorded +enum ShortcutRecordingType: Equatable { + case fullScreen + case selection + case translationMode + case textSelectionTranslation + case translateAndInsert +} + +/// ViewModel for the Settings view. +/// Manages user preferences and provides bindings for the settings UI. +@MainActor +@Observable +final class SettingsViewModel { + // MARK: - Properties + + /// Reference to shared app settings + let settings: AppSettings + + /// Reference to app delegate for hotkey re-registration + private weak var appDelegate: AppDelegate? + + /// The type of shortcut currently being recorded (nil if not recording) + var recordingType: ShortcutRecordingType? + + /// Temporary storage for shortcut recording + var recordedShortcut: KeyboardShortcut? + + // MARK: - Backward Compatibility Properties for UI + + /// Whether full screen shortcut is being recorded (for UI binding) + var isRecordingFullScreenShortcut: Bool { + recordingType == .fullScreen + } + + /// Whether selection shortcut is being recorded (for UI binding) + var isRecordingSelectionShortcut: Bool { + recordingType == .selection + } + + /// Whether translation mode shortcut is being recorded (for UI binding) + var isRecordingTranslationModeShortcut: Bool { + recordingType == .translationMode + } + + /// Whether text selection translation shortcut is being recorded (for UI binding) + var isRecordingTextSelectionTranslationShortcut: Bool { + recordingType == .textSelectionTranslation + } + + /// Whether translate and insert shortcut is being recorded (for UI binding) + var isRecordingTranslateAndInsertShortcut: Bool { + recordingType == .translateAndInsert + } + + /// Error message to display + var errorMessage: String? + + /// Whether to show error alert + var showErrorAlert = false + + /// Screen recording permission status + var hasScreenRecordingPermission: Bool = false + + /// Accessibility permission status + var hasAccessibilityPermission: Bool = false + + /// Folder access permission status + var hasFolderAccessPermission: Bool = false + + /// Whether permission check is in progress + var isCheckingPermissions: Bool = false + + /// Task for permission checking (stored for cancellation) + private var permissionCheckTask: Task? + + /// Type of permission being requested + enum PermissionType { + case screenRecording + case accessibility + } + + /// Whether PaddleOCR is installed + var isPaddleOCRInstalled: Bool = false + + /// Whether PaddleOCR installation is in progress + var isInstallingPaddleOCR: Bool = false + + /// PaddleOCR installation error message + var paddleOCRInstallError: String? + + /// PaddleOCR version if installed + var paddleOCRVersion: String? + + // MARK: - PaddleOCR Settings + + /// PaddleOCR mode: fast or precise + var paddleOCRMode: PaddleOCRMode { + get { settings.paddleOCRMode } + set { settings.paddleOCRMode = newValue; settings.save(newValue.rawValue, forKey: AppSettings.Keys.paddleOCRMode) } + } + + /// Whether to use cloud API + var paddleOCRUseCloud: Bool { + get { settings.paddleOCRUseCloud } + set { settings.paddleOCRUseCloud = newValue; settings.save(newValue, forKey: AppSettings.Keys.paddleOCRUseCloud) } + } + + /// Cloud API base URL + var paddleOCRCloudBaseURL: String { + get { settings.paddleOCRCloudBaseURL } + set { settings.paddleOCRCloudBaseURL = newValue } + } + + /// Cloud API key + var paddleOCRCloudAPIKey: String { + get { settings.paddleOCRCloudAPIKey } + set { settings.paddleOCRCloudAPIKey = newValue } + } + + /// Cloud API model ID + var paddleOCRCloudModelId: String { + get { settings.paddleOCRCloudModelId } + set { settings.paddleOCRCloudModelId = newValue } + } + + /// Local VL model directory (for vllm backend) + var paddleOCRLocalVLModelDir: String { + get { settings.paddleOCRLocalVLModelDir } + set { settings.paddleOCRLocalVLModelDir = newValue } + } + + // MARK: - VLM Test State + + /// Whether VLM API test is in progress + var isTestingVLM = false + + /// VLM API test result message + var vlmTestResult: String? + + /// Whether VLM test was successful + var vlmTestSuccess: Bool = false + + // MARK: - MTranServer Test State + + /// Whether MTranServer test is in progress + var isTestingMTranServer = false + + /// MTranServer test result message + var mtranTestResult: String? + + /// Whether MTranServer test was successful + var mtranTestSuccess: Bool = false + + // MARK: - Computed Properties (Bindings to AppSettings) + + /// Save location URL + var saveLocation: URL { + get { settings.saveLocation } + set { settings.saveLocation = newValue } + } + + /// Save location display path + var saveLocationPath: String { + saveLocation.path.replacingOccurrences(of: NSHomeDirectory(), with: "~") + } + + /// Default export format + var defaultFormat: ExportFormat { + get { settings.defaultFormat } + set { settings.defaultFormat = newValue; settings.save(newValue.rawValue, forKey: AppSettings.Keys.defaultFormat) } + } + + /// JPEG quality (0.0-1.0) + var jpegQuality: Double { + get { settings.jpegQuality } + set { settings.jpegQuality = newValue; settings.save(newValue, forKey: AppSettings.Keys.jpegQuality) } + } + + /// JPEG quality as percentage (0-100) + var jpegQualityPercentage: Double { + get { jpegQuality * 100 } + set { jpegQuality = newValue / 100 } + } + + /// HEIC quality (0.0-1.0) + var heicQuality: Double { + get { settings.heicQuality } + set { settings.heicQuality = newValue; settings.save(newValue, forKey: AppSettings.Keys.heicQuality) } + } + + /// HEIC quality as percentage (0-100) + var heicQualityPercentage: Double { + get { heicQuality * 100 } + set { heicQuality = newValue / 100 } + } + + /// Full screen capture shortcut + var fullScreenShortcut: KeyboardShortcut { + get { settings.fullScreenShortcut } + set { + settings.fullScreenShortcut = newValue + appDelegate?.updateHotkeys() + settings.saveShortcut(newValue, forKey: AppSettings.Keys.fullScreenShortcut) + } + } + + /// Selection capture shortcut + var selectionShortcut: KeyboardShortcut { + get { settings.selectionShortcut } + set { + settings.selectionShortcut = newValue + appDelegate?.updateHotkeys() + settings.saveShortcut(newValue, forKey: AppSettings.Keys.selectionShortcut) + } + } + + /// Translation mode shortcut + var translationModeShortcut: KeyboardShortcut { + get { settings.translationModeShortcut } + set { + settings.translationModeShortcut = newValue + appDelegate?.updateHotkeys() + settings.saveShortcut(newValue, forKey: AppSettings.Keys.translationModeShortcut) + } + } + + /// Text selection translation shortcut + var textSelectionTranslationShortcut: KeyboardShortcut { + get { settings.textSelectionTranslationShortcut } + set { + settings.textSelectionTranslationShortcut = newValue + appDelegate?.updateHotkeys() + settings.saveShortcut(newValue, forKey: AppSettings.Keys.textSelectionTranslationShortcut) + } + } + + /// Translate and insert shortcut + var translateAndInsertShortcut: KeyboardShortcut { + get { settings.translateAndInsertShortcut } + set { + settings.translateAndInsertShortcut = newValue + appDelegate?.updateHotkeys() + settings.saveShortcut(newValue, forKey: AppSettings.Keys.translateAndInsertShortcut) + } + } + + /// Annotation stroke color + var strokeColor: Color { + get { settings.strokeColor.color } + set { settings.strokeColor = CodableColor(newValue); settings.saveColor(CodableColor(newValue), forKey: AppSettings.Keys.strokeColor) } + } + + /// Annotation stroke width + var strokeWidth: CGFloat { + get { settings.strokeWidth } + set { settings.strokeWidth = newValue; settings.save(newValue, forKey: AppSettings.Keys.strokeWidth) } + } + + /// Text annotation font size + var textSize: CGFloat { + get { settings.textSize } + set { settings.textSize = newValue; settings.save(newValue, forKey: AppSettings.Keys.textSize) } + } + + /// OCR engine type + var ocrEngine: OCREngineType { + get { settings.ocrEngine } + set { settings.ocrEngine = newValue; settings.save(newValue.rawValue, forKey: AppSettings.Keys.ocrEngine) } + } + + /// Translation engine type + var translationEngine: TranslationEngineType { + get { settings.translationEngine } + set { settings.translationEngine = newValue; settings.save(newValue.rawValue, forKey: AppSettings.Keys.translationEngine) } + } + + /// Translation display mode + var translationMode: TranslationMode { + get { settings.translationMode } + set { settings.translationMode = newValue; settings.save(newValue.rawValue, forKey: AppSettings.Keys.translationMode) } + } + + /// Translation source language + var translationSourceLanguage: TranslationLanguage { + get { settings.translationSourceLanguage } + set { settings.translationSourceLanguage = newValue; settings.save(newValue.rawValue, forKey: AppSettings.Keys.translationSourceLanguage) } + } + + /// Translation target language + var translationTargetLanguage: TranslationLanguage? { + get { settings.translationTargetLanguage } + set { + settings.translationTargetLanguage = newValue + if let value = newValue { + settings.save(value.rawValue, forKey: AppSettings.Keys.translationTargetLanguage) + } else { + UserDefaults.standard.removeObject(forKey: AppSettings.Keys.translationTargetLanguage) + } + } + } + + /// Whether to automatically detect source language + var translationAutoDetect: Bool { + get { settings.translationAutoDetect } + set { settings.translationAutoDetect = newValue; settings.save(newValue, forKey: AppSettings.Keys.translationAutoDetect) } + } + + /// Available languages for the current translation engine + var availableSourceLanguages: [TranslationLanguage] { + TranslationLanguage.allCases + } + + /// Available target languages for the current translation engine + var availableTargetLanguages: [TranslationLanguage] { + TranslationLanguage.allCases.filter { $0 != .auto } + } + + // MARK: - Translate and Insert Language Configuration + + /// Source language for translate and insert + var translateAndInsertSourceLanguage: TranslationLanguage { + get { settings.translateAndInsertSourceLanguage } + set { settings.translateAndInsertSourceLanguage = newValue; settings.save(newValue.rawValue, forKey: AppSettings.Keys.translateAndInsertSourceLanguage) } + } + + /// Target language for translate and insert (nil = follow system) + var translateAndInsertTargetLanguage: TranslationLanguage? { + get { settings.translateAndInsertTargetLanguage } + set { + settings.translateAndInsertTargetLanguage = newValue + if let value = newValue { + settings.save(value.rawValue, forKey: AppSettings.Keys.translateAndInsertTargetLanguage) + } else { + UserDefaults.standard.removeObject(forKey: AppSettings.Keys.translateAndInsertTargetLanguage) + } + } + } + + // MARK: - VLM Configuration + + var vlmProvider: VLMProviderType { + get { settings.vlmProvider } + set { + settings.vlmProvider = newValue + vlmBaseURL = newValue == .glmOCR + ? (settings.storedGLMOCRBaseURL(for: settings.glmOCRMode) + ?? newValue.defaultBaseURL(glmOCRMode: settings.glmOCRMode)) + : newValue.defaultBaseURL(glmOCRMode: settings.glmOCRMode) + + vlmModelName = newValue == .glmOCR + ? (settings.storedGLMOCRModelName(for: settings.glmOCRMode) + ?? newValue.defaultModelName(glmOCRMode: settings.glmOCRMode)) + : newValue.defaultModelName(glmOCRMode: settings.glmOCRMode) + vlmTestResult = nil + vlmTestSuccess = false + } + } + + var vlmAPIKey: String { + get { settings.vlmAPIKey } + set { settings.vlmAPIKey = newValue; settings.save(newValue, forKey: AppSettings.Keys.vlmAPIKey) } + } + + var vlmBaseURL: String { + get { settings.vlmBaseURL } + set { settings.vlmBaseURL = newValue; settings.save(newValue, forKey: AppSettings.Keys.vlmBaseURL) } + } + + var vlmModelName: String { + get { settings.vlmModelName } + set { settings.vlmModelName = newValue; settings.save(newValue, forKey: AppSettings.Keys.vlmModelName) } + } + + var glmOCRMode: GLMOCRMode { + get { settings.glmOCRMode } + set { + settings.glmOCRMode = newValue + settings.save(newValue.rawValue, forKey: AppSettings.Keys.glmOCRMode) + + guard settings.vlmProvider == .glmOCR else { + return + } + + vlmBaseURL = settings.storedGLMOCRBaseURL(for: newValue) ?? newValue.defaultBaseURL + vlmModelName = settings.storedGLMOCRModelName(for: newValue) ?? newValue.defaultModelName + vlmTestResult = nil + vlmTestSuccess = false + } + } + + var currentVLMRequiresAPIKey: Bool { + settings.vlmProvider.requiresAPIKey(glmOCRMode: settings.glmOCRMode) + } + + var currentVLMProviderDescription: String { + settings.vlmProvider.providerDescription(glmOCRMode: settings.glmOCRMode) + } + + // MARK: - Translation Workflow Configuration + + var preferredTranslationEngine: PreferredTranslationEngine { + get { settings.preferredTranslationEngine } + set { settings.preferredTranslationEngine = newValue; settings.save(newValue.rawValue, forKey: AppSettings.Keys.preferredTranslationEngine) } + } + + var mtranServerURL: String { + get { settings.mtranServerURL } + set { + settings.mtranServerURL = newValue + settings.save(newValue, forKey: AppSettings.Keys.mtranServerURL) + // Clear test result when URL changes + mtranTestResult = nil + mtranTestSuccess = false + } + } + + var translationFallbackEnabled: Bool { + get { settings.translationFallbackEnabled } + set { settings.translationFallbackEnabled = newValue; settings.save(newValue, forKey: AppSettings.Keys.translationFallbackEnabled) } + } + + // MARK: - Validation Ranges + + /// Valid range for stroke width + static let strokeWidthRange: ClosedRange = 1.0...20.0 + + /// Valid range for text size + static let textSizeRange: ClosedRange = 8.0...72.0 + + /// Valid range for JPEG quality + static let jpegQualityRange: ClosedRange = 0.1...1.0 + + /// Valid range for HEIC quality + static let heicQualityRange: ClosedRange = 0.1...1.0 + + // MARK: - Initialization + + init(settings: AppSettings = .shared, appDelegate: AppDelegate? = nil) { + self.settings = settings + self.appDelegate = appDelegate + refreshPaddleOCRStatus() + } + + // MARK: - Permission Checking + + /// Checks all required permissions and updates status + func checkPermissions() { + isCheckingPermissions = true + + // Check accessibility permission using AXIsProcessTrusted() without any prompt + let accessibilityGranted = AXIsProcessTrusted() + hasAccessibilityPermission = accessibilityGranted + + // Check folder access permission by testing if we can write to the save location + hasFolderAccessPermission = checkFolderAccess(to: saveLocation) + + // Check screen recording permission using ScreenCaptureKit + // Cancel any existing task to avoid race conditions + permissionCheckTask?.cancel() + + permissionCheckTask = Task { + let granted = await checkScreenRecordingPermission() + self.hasScreenRecordingPermission = granted + self.isCheckingPermissions = false + permissionCheckTask = nil + } + } + + /// Checks screen recording permission using ScreenCaptureKit for reliable detection + private func checkScreenRecordingPermission() async -> Bool { + // First do a quick check with CGPreflightScreenCaptureAccess + if !CGPreflightScreenCaptureAccess() { + return false + } + + // Verify by actually trying to get shareable content + // This ensures permission is truly granted (not just cached) + do { + _ = try await SCShareableContent.current + return true + } catch { + return false + } + } + + /// Checks if we have write access to the specified folder + private func checkFolderAccess(to url: URL) -> Bool { + let fileManager = FileManager.default + + // Check if directory exists and is writable + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue else { + return false + } + + return fileManager.isWritableFile(atPath: url.path) + } + + /// Requests screen recording permission + func requestScreenRecordingPermission() { + // First check if already granted + if CGPreflightScreenCaptureAccess() { + hasScreenRecordingPermission = true + return + } + + // Request permission - CGRequestScreenCaptureAccess() returns true if granted + let granted = CGRequestScreenCaptureAccess() + if granted { + hasScreenRecordingPermission = true + return + } + + // Trigger ScreenCaptureKit API to register app in permission list + Task { + do { + // This will trigger the system to register the app in Screen Recording permissions + _ = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + } catch { + // Expected when permission not granted - app is now registered + } + + // Open System Settings after triggering the API + await MainActor.run { + openScreenRecordingSettings() + } + } + + // Start polling for permission status + startPermissionCheck(for: .screenRecording) + } + + /// Opens System Settings for screen recording permission + func openScreenRecordingSettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { + NSWorkspace.shared.open(url) + } + } + + /// Requests accessibility permission - triggers system dialog only + func requestAccessibilityPermission() { + // Check current status first + if AXIsProcessTrusted() { + hasAccessibilityPermission = true + return + } + + // Request accessibility - triggers system dialog (will guide user to settings if needed) + let options: CFDictionary = ["AXTrustedCheckOptionPrompt": true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + // Start checking for permission + startPermissionCheck(for: .accessibility) + } + + /// Opens System Settings for accessibility permission + func openAccessibilitySettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + NSWorkspace.shared.open(url) + } + } + + /// Starts checking for permission status periodically + private func startPermissionCheck(for type: PermissionType) { + // Cancel any existing permission check task + permissionCheckTask?.cancel() + + permissionCheckTask = Task { + for _ in 0..<60 { // Check for up to 30 seconds + do { + try await Task.sleep(for: .milliseconds(500)) + } catch { + // Task was cancelled + return + } + + switch type { + case .screenRecording: + // Use the same reliable check method + let granted = await checkScreenRecordingPermission() + if granted { + hasScreenRecordingPermission = true + permissionCheckTask = nil + return + } + + case .accessibility: + let granted = AXIsProcessTrusted() + if granted { + hasAccessibilityPermission = granted + permissionCheckTask = nil + return + } + } + } + } + } + + /// Requests folder access by showing a folder picker + func requestFolderAccess() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.canCreateDirectories = true + panel.prompt = "Grant Access" + panel.message = "Select the folder where you want to save screenshots" + panel.directoryURL = saveLocation + + if panel.runModal() == .OK, let url = panel.url { + // Save the security-scoped bookmark for persistent access + do { + let bookmarkData = try url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + UserDefaults.standard.set(bookmarkData, forKey: "SaveLocationBookmark") + saveLocation = url + } catch { + // If bookmark fails, just save the URL + saveLocation = url + } + } + + // Recheck permissions + checkPermissions() + } + + // MARK: - Actions + + /// Shows folder selection panel to choose save location + func selectSaveLocation() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.canCreateDirectories = true + panel.prompt = "Select" + panel.message = "Choose the default location for saving screenshots" + panel.directoryURL = saveLocation + + if panel.runModal() == .OK, let url = panel.url { + // Save the security-scoped bookmark for persistent access + do { + let bookmarkData = try url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + UserDefaults.standard.set(bookmarkData, forKey: "SaveLocationBookmark") + } catch { + // Ignore bookmark errors + } + saveLocation = url + checkPermissions() + } + } + + /// Reveals the save location in Finder + func revealSaveLocation() { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: saveLocation.path) + } + + /// Starts recording a keyboard shortcut for the specified type + /// - Parameter type: The type of shortcut to record + func startRecording(_ type: ShortcutRecordingType) { + recordingType = type + recordedShortcut = nil + } + + /// Starts recording a keyboard shortcut for full screen capture + func startRecordingFullScreenShortcut() { + startRecording(.fullScreen) + } + + /// Starts recording a keyboard shortcut for selection capture + func startRecordingSelectionShortcut() { + startRecording(.selection) + } + + /// Starts recording a keyboard shortcut for translation mode + func startRecordingTranslationModeShortcut() { + startRecording(.translationMode) + } + + /// Starts recording a keyboard shortcut for text selection translation + func startRecordingTextSelectionTranslationShortcut() { + startRecording(.textSelectionTranslation) + } + + /// Starts recording a keyboard shortcut for translate and insert + func startRecordingTranslateAndInsertShortcut() { + startRecording(.translateAndInsert) + } + + /// Cancels shortcut recording + func cancelRecording() { + recordingType = nil + recordedShortcut = nil + } + + /// Handles a key event during shortcut recording + /// - Parameter event: The key event + /// - Returns: Whether the event was handled + func handleKeyEvent(_ event: NSEvent) -> Bool { + guard recordingType != nil else { + return false + } + + // Escape cancels recording + if event.keyCode == UInt16(kVK_Escape) { + cancelRecording() + return true + } + + // Create shortcut from event + let shortcut = KeyboardShortcut( + keyCode: UInt32(event.keyCode), + modifierFlags: event.modifierFlags.intersection([.command, .shift, .option, .control]) + ) + + // Validate shortcut + guard shortcut.isValid else { + showError("Shortcuts must include Command, Control, or Option") + return true + } + + // Check for conflicts with other shortcuts + let currentShortcut = getCurrentRecordingShortcut() + if hasShortcutConflict(shortcut, excluding: currentShortcut) { + showError("This shortcut is already in use") + return true + } + + // Apply the shortcut based on recording type + switch recordingType { + case .fullScreen: + fullScreenShortcut = shortcut + case .selection: + selectionShortcut = shortcut + case .translationMode: + translationModeShortcut = shortcut + case .textSelectionTranslation: + textSelectionTranslationShortcut = shortcut + case .translateAndInsert: + translateAndInsertShortcut = shortcut + case .none: + break + } + + // End recording + cancelRecording() + return true + } + + /// Resets a shortcut to its default + func resetFullScreenShortcut() { + fullScreenShortcut = .fullScreenDefault + } + + /// Resets selection shortcut to default + func resetSelectionShortcut() { + selectionShortcut = .selectionDefault + } + + /// Resets translation mode shortcut to default + func resetTranslationModeShortcut() { + translationModeShortcut = .translationModeDefault + } + + /// Resets text selection translation shortcut to default + func resetTextSelectionTranslationShortcut() { + textSelectionTranslationShortcut = .textSelectionTranslationDefault + } + + /// Resets translate and insert shortcut to default + func resetTranslateAndInsertShortcut() { + translateAndInsertShortcut = .translateAndInsertDefault + } + + /// Resets all settings to defaults + func resetAllToDefaults() { + settings.resetToDefaults() + appDelegate?.updateHotkeys() + } + + // MARK: - Shortcut Conflict Detection + + /// Gets the current shortcut being recorded (if any) + /// - Returns: The current shortcut value, or nil if not recording + private func getCurrentRecordingShortcut() -> KeyboardShortcut? { + switch recordingType { + case .fullScreen: return fullScreenShortcut + case .selection: return selectionShortcut + case .translationMode: return translationModeShortcut + case .textSelectionTranslation: return textSelectionTranslationShortcut + case .translateAndInsert: return translateAndInsertShortcut + case .none: return nil + } + } + + /// Checks if a shortcut conflicts with existing shortcuts + /// - Parameters: + /// - shortcut: The shortcut to check + /// - excluding: A shortcut to exclude from the check (the one being edited) + /// - Returns: true if there's a conflict + private func hasShortcutConflict(_ shortcut: KeyboardShortcut, excluding: KeyboardShortcut?) -> Bool { + let allShortcuts = [ + fullScreenShortcut, selectionShortcut, translationModeShortcut, + textSelectionTranslationShortcut, translateAndInsertShortcut + ].filter { $0 != excluding } + return allShortcuts.contains(shortcut) + } + + // MARK: - Private Helpers + + /// Shows an error message + private func showError(_ message: String) { + errorMessage = message + showErrorAlert = true + } + + // MARK: - PaddleOCR Management + + func refreshPaddleOCRStatus() { + PaddleOCRChecker.resetCache() + PaddleOCRChecker.checkAvailabilityAsync() + + Task { + for _ in 0..<20 { + try? await Task.sleep(for: .milliseconds(250)) + if PaddleOCRChecker.checkCompleted { + break + } + } + await MainActor.run { + isPaddleOCRInstalled = PaddleOCRChecker.isAvailable + paddleOCRVersion = PaddleOCRChecker.version + paddleOCRInstallError = nil + } + } + } + + func installPaddleOCR() { + isInstallingPaddleOCR = true + paddleOCRInstallError = nil + + Task.detached(priority: .userInitiated) { + let result = await self.runPipInstall() + await MainActor.run { + self.isInstallingPaddleOCR = false + if let error = result { + self.paddleOCRInstallError = error + } else { + self.refreshPaddleOCRStatus() + } + } + } + } + + private func runPipInstall() async -> String? { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/env") + task.arguments = ["pip3", "install", "paddleocr", "paddlepaddle"] + + let stderrPipe = Pipe() + task.standardError = stderrPipe + task.standardOutput = Pipe() + + do { + try task.run() + task.waitUntilExit() + + if task.terminationStatus != 0 { + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderr = String(data: stderrData, encoding: .utf8) ?? "Unknown error" + return stderr.isEmpty ? "Installation failed with exit code \(task.terminationStatus)" : stderr + } + return nil + } catch { + return error.localizedDescription + } + } + + func copyPaddleOCRInstallCommand() { + let command = "pip3 install paddleocr paddlepaddle" + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(command, forType: .string) + } + + // MARK: - VLM API Test + + /// Tests the VLM API connectivity with current configuration + func testVLMAPI() { + isTestingVLM = true + vlmTestResult = nil + vlmTestSuccess = false + + Task { + do { + // Validate configuration + let effectiveBaseURL = vlmBaseURL.isEmpty ? vlmProvider.defaultBaseURL(glmOCRMode: glmOCRMode) : vlmBaseURL + let effectiveModel = vlmModelName.isEmpty ? vlmProvider.defaultModelName(glmOCRMode: glmOCRMode) : vlmModelName + + guard let baseURL = URL(string: effectiveBaseURL) else { + throw ScreenCoderEngineError.invalidConfiguration("Invalid base URL: \(effectiveBaseURL)") + } + + if currentVLMRequiresAPIKey && vlmAPIKey.isEmpty { + throw ScreenCoderEngineError.invalidConfiguration("API key is required for \(vlmProvider.localizedName)") + } + + // Test API connectivity + let testResult = try await performVLMConnectivityTest( + provider: vlmProvider, + baseURL: baseURL, + apiKey: vlmAPIKey, + modelName: effectiveModel + ) + + await MainActor.run { + vlmTestSuccess = testResult.success + vlmTestResult = testResult.message + } + + } catch let error as ScreenCoderEngineError { + await MainActor.run { + vlmTestSuccess = false + vlmTestResult = error.localizedDescription + } + } catch let error as VLMProviderError { + await MainActor.run { + vlmTestSuccess = false + vlmTestResult = error.errorDescription ?? error.localizedDescription + } + } catch { + await MainActor.run { + vlmTestSuccess = false + vlmTestResult = "Connection failed: \(error.localizedDescription)" + } + } + + await MainActor.run { + isTestingVLM = false + } + } + } + + /// Performs actual connectivity test for different VLM providers + private func performVLMConnectivityTest( + provider: VLMProviderType, + baseURL: URL, + apiKey: String, + modelName: String + ) async throws -> (success: Bool, message: String) { + switch provider { + case .openai: + return try await testOpenAIConnection(baseURL: baseURL, apiKey: apiKey, modelName: modelName) + case .claude: + return try await testClaudeConnection(baseURL: baseURL, apiKey: apiKey, modelName: modelName) + case .glmOCR: + return try await testGLMOCRConnection(baseURL: baseURL, apiKey: apiKey, modelName: modelName, mode: glmOCRMode) + case .ollama: + return try await testOllamaConnection(baseURL: baseURL, modelName: modelName) + case .paddleocr: + return try await testPaddleOCRConnection() + } + } + + /// Tests PaddleOCR availability - checks cloud mode first, then local + private func testPaddleOCRConnection() async throws -> (success: Bool, message: String) { + let settings = AppSettings.shared + + // If cloud mode is enabled, test cloud connectivity first + if settings.paddleOCRUseCloud { + let cloudBaseURL = settings.paddleOCRCloudBaseURL.trimmingCharacters(in: .whitespaces) + guard !cloudBaseURL.isEmpty, + let url = URL(string: cloudBaseURL) else { + throw VLMProviderError.invalidConfiguration("PaddleOCR cloud base URL is not configured") + } + + // Test cloud API connectivity with a simple request + var request = URLRequest(url: url) + request.timeoutInterval = 10 + + // Add API key if configured + let apiKey = settings.paddleOCRCloudAPIKey.trimmingCharacters(in: .whitespaces) + if !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + + do { + let (_, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw VLMProviderError.invalidResponse("Invalid HTTP response from PaddleOCR cloud") + } + switch httpResponse.statusCode { + case 200, 404: // 404 is acceptable - means server is reachable + return (true, "PaddleOCR cloud is reachable") + case 401, 403: + throw VLMProviderError.authenticationFailed + default: + throw VLMProviderError.invalidResponse("HTTP \(httpResponse.statusCode)") + } + } catch let error as VLMProviderError { + throw error + } catch { + throw VLMProviderError.invalidConfiguration("PaddleOCR cloud is not reachable: \(error.localizedDescription)") + } + } + + // Local mode - check if PaddleOCR is installed + let isAvailable = await PaddleOCREngine.shared.isAvailable + if isAvailable { + return (true, "PaddleOCR is ready") + } else { + throw VLMProviderError.invalidConfiguration("PaddleOCR is not installed. Install it using: pip3 install paddleocr paddlepaddle") + } + } + + /// Tests OpenAI API connection by fetching available models + private func testOpenAIConnection(baseURL: URL, apiKey: String, modelName: String) async throws -> (success: Bool, message: String) { + var request = URLRequest(url: baseURL.appendingPathComponent("models")) + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 10 + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw VLMProviderError.invalidResponse("Invalid HTTP response") + } + + switch httpResponse.statusCode { + case 200: + return (true, String(format: NSLocalizedString("settings.vlm.test.success", comment: ""), modelName)) + case 401: + throw VLMProviderError.authenticationFailed + case 429: + throw VLMProviderError.rateLimited(retryAfter: nil, message: "Rate limited. Please try again later.") + default: + throw VLMProviderError.invalidResponse("HTTP \(httpResponse.statusCode)") + } + } + + /// Tests Claude API connection + private func testClaudeConnection(baseURL: URL, apiKey: String, modelName: String) async throws -> (success: Bool, message: String) { + var request = URLRequest(url: baseURL.appendingPathComponent("models")) + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + request.timeoutInterval = 10 + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw VLMProviderError.invalidResponse("Invalid HTTP response") + } + + switch httpResponse.statusCode { + case 200: + return (true, String(format: NSLocalizedString("settings.vlm.test.success", comment: ""), modelName)) + case 401: + throw VLMProviderError.authenticationFailed + default: + throw VLMProviderError.invalidResponse("HTTP \(httpResponse.statusCode)") + } + } + + /// Tests GLM-OCR connectivity with a tiny inline PNG payload. + private func testGLMOCRConnection( + baseURL: URL, + apiKey: String, + modelName: String, + mode: GLMOCRMode + ) async throws -> (success: Bool, message: String) { + let request: URLRequest + switch mode { + case .cloud: + request = try GLMOCRVLMProvider.makeLayoutParsingRequest( + baseURL: baseURL, + apiKey: apiKey, + modelName: modelName, + fileDataURI: GLMOCRVLMProvider.connectionTestImageDataURI, + timeout: 10 + ) + case .local: + request = try GLMOCRVLMProvider.makeLocalChatRequest( + baseURL: baseURL, + apiKey: apiKey, + modelName: modelName, + fileDataURI: GLMOCRVLMProvider.connectionTestImageDataURI, + timeout: 10 + ) + } + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw VLMProviderError.invalidResponse("Invalid HTTP response") + } + + switch httpResponse.statusCode { + case 200: + switch mode { + case .cloud: + _ = try GLMOCRVLMProvider.parseResponse(data, fallbackImageSize: .zero) + case .local: + _ = try GLMOCRVLMProvider.parseLocalResponse(data, fallbackImageSize: .zero) + } + return (true, String(format: NSLocalizedString("settings.vlm.test.success", comment: ""), modelName)) + case 401, 403: + throw VLMProviderError.authenticationFailed + case 429: + throw VLMProviderError.rateLimited(retryAfter: nil, message: "Rate limited. Please try again later.") + default: + throw VLMProviderError.invalidResponse("HTTP \(httpResponse.statusCode)") + } + } + + /// Tests Ollama connection by checking if server is running + private func testOllamaConnection(baseURL: URL, modelName: String) async throws -> (success: Bool, message: String) { + var request = URLRequest(url: baseURL.appendingPathComponent("api/tags")) + request.timeoutInterval = 5 + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw VLMProviderError.networkError("Ollama server not responding") + } + + // Check if the configured model is available + struct OllamaTagsResponse: Codable { + struct Model: Codable { + let name: String + } + let models: [Model] + } + + let tagsResponse = try JSONDecoder().decode(OllamaTagsResponse.self, from: data) + let availableModels = tagsResponse.models.map { $0.name } + + if availableModels.contains(where: { $0.hasPrefix(modelName) }) { + return (true, String(format: NSLocalizedString("settings.vlm.test.ollama.success", comment: ""), modelName)) + } else { + let modelsList = availableModels.isEmpty ? NSLocalizedString("none", comment: "") : availableModels.joined(separator: ", ") + return (true, String(format: NSLocalizedString("settings.vlm.test.ollama.available", comment: ""), modelsList)) + } + } + + // MARK: - MTranServer Connection Test + + /// Tests MTranServer connection with current configuration + func testMTranServerConnection() { + isTestingMTranServer = true + mtranTestResult = nil + mtranTestSuccess = false + + Task { + do { + // Parse URL and update settings temporarily for test + guard let (host, port) = parseMTranServerURL(mtranServerURL), !host.isEmpty else { + throw MTranServerError.invalidURL + } + + // Save current settings + let originalHost = settings.mtranServerHost + let originalPort = settings.mtranServerPort + + // Update settings for test + settings.mtranServerHost = host + settings.mtranServerPort = port + + // Reset cache to use new settings + MTranServerChecker.resetCache() + + // Check availability + let isAvailable = MTranServerChecker.isAvailable + + // Restore original settings if test is just for checking + settings.mtranServerHost = originalHost + settings.mtranServerPort = originalPort + + await MainActor.run { + mtranTestSuccess = isAvailable + if isAvailable { + mtranTestResult = NSLocalizedString("settings.translation.mtran.test.success", comment: "") + } else { + mtranTestResult = String( + format: NSLocalizedString("settings.translation.mtran.test.failed", comment: ""), + "Server not responding" + ) + } + } + } catch { + await MainActor.run { + mtranTestSuccess = false + mtranTestResult = String( + format: NSLocalizedString("settings.translation.mtran.test.failed", comment: ""), + error.localizedDescription + ) + } + } + + await MainActor.run { + isTestingMTranServer = false + } + } + } + + /// Parses MTranServer URL to extract host and port + private func parseMTranServerURL(_ url: String) -> (host: String, port: Int)? { + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + // Remove protocol if present + var hostPart = trimmed + if hostPart.hasPrefix("http://") { + hostPart = String(hostPart.dropFirst(7)) + } else if hostPart.hasPrefix("https://") { + hostPart = String(hostPart.dropFirst(8)) + } + + // Split by colon for port + if let colonIndex = hostPart.firstIndex(of: ":") { + let host = String(hostPart[.. General } /// Closes the settings window if open. @@ -99,7 +120,7 @@ final class SettingsWindowController: NSObject { // Try to handle the event for shortcut recording if viewModel.handleKeyEvent(event) { - return nil // Consume the event + return nil // Consume the event } return event diff --git a/ScreenTranslate/Features/Settings/ShortcutSettingsTab.swift b/ScreenTranslate/Features/Settings/ShortcutSettingsTab.swift new file mode 100644 index 0000000..3f085fc --- /dev/null +++ b/ScreenTranslate/Features/Settings/ShortcutSettingsTab.swift @@ -0,0 +1,97 @@ +import SwiftUI + +struct ShortcutSettingsContent: View { + @Bindable var viewModel: SettingsViewModel + + var body: some View { + VStack(spacing: 16) { + ShortcutRecorder( + label: localized("settings.shortcut.fullscreen"), + shortcut: viewModel.fullScreenShortcut, + isRecording: viewModel.isRecordingFullScreenShortcut, + onRecord: { viewModel.startRecordingFullScreenShortcut() }, + onReset: { viewModel.resetFullScreenShortcut() } + ) + Divider().opacity(0.1) + ShortcutRecorder( + label: localized("settings.shortcut.selection"), + shortcut: viewModel.selectionShortcut, + isRecording: viewModel.isRecordingSelectionShortcut, + onRecord: { viewModel.startRecordingSelectionShortcut() }, + onReset: { viewModel.resetSelectionShortcut() } + ) + Divider().opacity(0.1) + ShortcutRecorder( + label: localized("settings.shortcut.translation.mode"), + shortcut: viewModel.translationModeShortcut, + isRecording: viewModel.isRecordingTranslationModeShortcut, + onRecord: { viewModel.startRecordingTranslationModeShortcut() }, + onReset: { viewModel.resetTranslationModeShortcut() } + ) + Divider().opacity(0.1) + ShortcutRecorder( + label: localized("settings.shortcut.text.selection.translation"), + shortcut: viewModel.textSelectionTranslationShortcut, + isRecording: viewModel.isRecordingTextSelectionTranslationShortcut, + onRecord: { viewModel.startRecordingTextSelectionTranslationShortcut() }, + onReset: { viewModel.resetTextSelectionTranslationShortcut() } + ) + Divider().opacity(0.1) + ShortcutRecorder( + label: localized("settings.shortcut.translate.and.insert"), + shortcut: viewModel.translateAndInsertShortcut, + isRecording: viewModel.isRecordingTranslateAndInsertShortcut, + onRecord: { viewModel.startRecordingTranslateAndInsertShortcut() }, + onReset: { viewModel.resetTranslateAndInsertShortcut() } + ) + } + .macos26LiquidGlass() + } +} + +struct ShortcutRecorder: View { + let label: String + let shortcut: KeyboardShortcut + let isRecording: Bool + let onRecord: () -> Void + let onReset: () -> Void + + var body: some View { + HStack { + Text(label) + + Spacer() + + if isRecording { + Text(localized("settings.shortcut.recording")) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.accentColor.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } else { + Button { + onRecord() + } label: { + Text(shortcut.displayString) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + } + + Button { + onReset() + } label: { + Image(systemName: "arrow.counterclockwise") + } + .buttonStyle(.borderless) + .help(localized("settings.shortcut.reset")) + .disabled(isRecording) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text("\(label): \(shortcut.displayString)")) + } +} diff --git a/ScreenTranslate/Features/TextTranslation/TextTranslationFlow.swift b/ScreenTranslate/Features/TextTranslation/TextTranslationFlow.swift new file mode 100644 index 0000000..717496f --- /dev/null +++ b/ScreenTranslate/Features/TextTranslation/TextTranslationFlow.swift @@ -0,0 +1,414 @@ +// +// TextTranslationFlow.swift +// ScreenTranslate +// +// Created for US-003: Create TextTranslationFlow for plain text translation +// + +import Foundation +import os.log + +/// Flow phase for plain text translation +enum TextTranslationPhase: Sendable, Equatable { + case idle + case translating + case completed + case failed(TextTranslationError) + + var isProcessing: Bool { + self == .translating + } + + var localizedDescription: String { + switch self { + case .idle: + return String(localized: "textTranslation.phase.idle") + case .translating: + return String(localized: "textTranslation.phase.translating") + case .completed: + return String(localized: "textTranslation.phase.completed") + case .failed: + return String(localized: "textTranslation.phase.failed") + } + } +} + +/// Errors that can occur during text translation +enum TextTranslationError: LocalizedError, Sendable, Equatable { + /// The input text is empty + case emptyInput + /// Translation failed with underlying error + case translationFailed(String) + /// The operation was cancelled + case cancelled + /// Translation service is not available + case serviceUnavailable + + var errorDescription: String? { + switch self { + case .emptyInput: + return String(localized: "textTranslation.error.emptyInput") + case .translationFailed(let message): + return String(format: NSLocalizedString("textTranslation.error.translationFailed", comment: ""), message) + case .cancelled: + return String(localized: "textTranslation.error.cancelled") + case .serviceUnavailable: + return String(localized: "textTranslation.error.serviceUnavailable") + } + } + + var recoverySuggestion: String? { + switch self { + case .emptyInput: + return String(localized: "textTranslation.recovery.emptyInput") + case .translationFailed: + return String(localized: "textTranslation.recovery.translationFailed") + case .cancelled: + return nil + case .serviceUnavailable: + return String(localized: "textTranslation.recovery.serviceUnavailable") + } + } +} + +/// Per-engine translation result for parallel/multi-engine display +struct EngineTranslationInfo: Sendable, Identifiable { + let id = UUID() + let engine: TranslationEngineType + let translatedText: String? + let errorMessage: String? + let latency: TimeInterval + + var isSuccess: Bool { translatedText != nil && !(translatedText?.isEmpty ?? true) } + + static func fromEngineResult(_ result: EngineResult) -> EngineTranslationInfo { + EngineTranslationInfo( + engine: result.engine, + translatedText: result.segments.first?.translated, + errorMessage: result.error?.localizedDescription, + latency: result.latency + ) + } +} + +/// Result of a plain text translation operation +struct TextTranslationResult: Sendable { + /// The original text that was translated + let originalText: String + /// The translated text (from first successful engine) + let translatedText: String + /// Detected or specified source language + let sourceLanguage: String? + /// Target language for translation + let targetLanguage: String + /// Bilingual segments (single segment for plain text) + let segments: [BilingualSegment] + /// Processing time in seconds + let processingTime: TimeInterval + /// Per-engine results (populated for parallel mode; empty for single-engine modes) + let engineResults: [EngineTranslationInfo] + /// Whether this result contains multiple engine results + var hasMultipleResults: Bool { engineResults.count > 1 } +} + +/// Configuration for text translation +struct TextTranslationConfig: Sendable { + /// Target language code + let targetLanguage: String + /// Source language code (nil for auto-detect) + let sourceLanguage: String? + /// Preferred translation engine + let preferredEngine: TranslationEngineType + /// Translation scene for prompt selection and scene bindings + let scene: TranslationScene? + /// Engine selection mode for the request + let mode: EngineSelectionMode + /// Whether fallback is enabled for the request + let fallbackEnabled: Bool + /// Engines participating in parallel/quick-switch modes + let parallelEngines: [TranslationEngineType] + /// Scene routing bindings active for the request + let sceneBindings: [TranslationScene: SceneEngineBinding] + + /// Default configuration using common settings + static let `default` = TextTranslationConfig( + targetLanguage: "zh-Hans", + sourceLanguage: nil, + preferredEngine: .apple, + scene: nil, + mode: .primaryWithFallback, + fallbackEnabled: true, + parallelEngines: [], + sceneBindings: [:] + ) +} + +/// Handles plain text translation without OCR/image analysis. +/// Reuses existing TranslationService for actual translation. +@available(macOS 13.0, *) +actor TextTranslationFlow { + + // MARK: - Properties + + /// Shared instance for convenience + static let shared = TextTranslationFlow() + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "ScreenTranslate", + category: "TextTranslationFlow" + ) + + /// Current translation phase + private(set) var currentPhase: TextTranslationPhase = .idle + + /// Last translation error (if any) + private(set) var lastError: TextTranslationError? + + /// Last successful translation result + private(set) var lastResult: TextTranslationResult? + + /// Current translation task (for cancellation) + private var currentTask: Task? + + /// Translation service used to execute the underlying work. + private let translationService: any TranslationServicing + + // MARK: - Initialization + + init(service: any TranslationServicing = TranslationService.shared) { + self.translationService = service + } + + // MARK: - Public API + + /// Translates plain text with explicit configuration. + /// - Parameters: + /// - text: The text to translate + /// - config: Translation configuration + /// - Returns: TextTranslationResult with translation details + func translate( + _ text: String, + config: TextTranslationConfig + ) async throws -> TextTranslationResult { + // Cancel any ongoing translation + cancel() + + // Validate input + let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedText.isEmpty else { + currentPhase = .failed(.emptyInput) + lastError = .emptyInput + throw TextTranslationError.emptyInput + } + + currentPhase = .translating + lastError = nil + + let startTime = Date() + + let task = Task { + let effectiveTargetLanguage = config.targetLanguage + let effectiveSourceLanguage = config.sourceLanguage + let effectiveEngine = config.preferredEngine + + logger.info("Starting text translation: \(trimmedText.count) chars to \(effectiveTargetLanguage)") + + // Use bundle API to get per-engine results + let bundle = try await translationService.translateBundle( + segments: [trimmedText], + to: effectiveTargetLanguage, + preferredEngine: effectiveEngine, + from: effectiveSourceLanguage, + scene: config.scene, + mode: config.mode, + fallbackEnabled: config.fallbackEnabled, + parallelEngines: config.parallelEngines, + sceneBindings: config.sceneBindings + ) + + // Collect per-engine results for display + let engineInfos = bundle.results.map { EngineTranslationInfo.fromEngineResult($0) } + + // Use first successful result (any engine) + guard let firstSuccess = bundle.results.first(where: { $0.isSuccess }), + let firstSegment = firstSuccess.segments.first else { + // All engines failed — build descriptive error + let errorSummaries = bundle.results.compactMap { result -> String? in + guard let error = result.error else { return nil } + return "\(result.engine.localizedName): \(error.localizedDescription)" + } + let message = errorSummaries.joined(separator: "\n") + throw TextTranslationError.translationFailed(message) + } + + let processingTime = Date().timeIntervalSince(startTime) + + return TextTranslationResult( + originalText: trimmedText, + translatedText: firstSegment.translated, + sourceLanguage: firstSegment.sourceLanguage, + targetLanguage: firstSegment.targetLanguage, + segments: firstSuccess.segments, + processingTime: processingTime, + engineResults: engineInfos + ) + } + + currentTask = task + + do { + let result = try await task.value + + lastResult = result + currentPhase = .completed + currentTask = nil + + logger.info("Text translation completed in \(result.processingTime * 1000)ms") + + return result + + } catch is CancellationError { + currentPhase = .failed(.cancelled) + lastError = .cancelled + currentTask = nil + throw TextTranslationError.cancelled + } catch let error as TextTranslationError { + currentPhase = .failed(error) + lastError = error + currentTask = nil + throw error + } catch { + let errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + logger.error("Text translation failed: \(errorMessage)") + let translationError = TextTranslationError.translationFailed(errorMessage) + currentPhase = .failed(translationError) + lastError = translationError + currentTask = nil + throw translationError + } + } + + /// Cancels any ongoing translation operation + func cancel() { + currentTask?.cancel() + currentTask = nil + + if currentPhase == .translating { + currentPhase = .failed(.cancelled) + lastError = .cancelled + } + } + + /// Resets the flow to idle state + func reset() { + cancel() + currentPhase = .idle + lastError = nil + lastResult = nil + } + + // MARK: - Convenience Methods + + /// Translates text and returns just the translated string. + /// Useful for quick translations without full result details. + func translateText(_ text: String, config: TextTranslationConfig = .default) async throws -> String { + let result = try await translate(text, config: config) + return result.translatedText + } + + /// Translates text to a specific target language. + func translate( + _ text: String, + to targetLanguage: String, + from sourceLanguage: String? = nil, + preferredEngine: TranslationEngineType = .apple + ) async throws -> TextTranslationResult { + let config = TextTranslationConfig( + targetLanguage: targetLanguage, + sourceLanguage: sourceLanguage, + preferredEngine: preferredEngine, + scene: nil, + mode: .primaryWithFallback, + fallbackEnabled: true, + parallelEngines: [], + sceneBindings: [:] + ) + return try await translate(text, config: config) + } +} + +// MARK: - AppSettings Integration + +extension TextTranslationConfig { + /// Creates a configuration from current AppSettings. + /// Must be called from @MainActor context. + @MainActor + static func fromAppSettings() -> TextTranslationConfig { + let settings = AppSettings.shared + let targetLanguage = settings.translationTargetLanguage?.rawValue ?? "zh-Hans" + let sourceLanguage: String? = settings.translationSourceLanguage == .auto ? nil : settings.translationSourceLanguage.rawValue + + return TextTranslationConfig( + targetLanguage: targetLanguage, + sourceLanguage: sourceLanguage, + preferredEngine: resolvePreferredEngine(from: settings, scene: .textSelection), + scene: .textSelection, + mode: settings.engineSelectionMode, + fallbackEnabled: settings.translationFallbackEnabled, + parallelEngines: settings.parallelEngines, + sceneBindings: settings.sceneBindings + ) + } + + /// Creates a configuration specifically for translate and insert functionality. + /// Uses separate language settings from global translation settings. + /// Must be called from @MainActor context. + @MainActor + static func forTranslateAndInsert() -> TextTranslationConfig { + let settings = AppSettings.shared + let targetLanguage = settings.translateAndInsertTargetLanguage?.rawValue ?? "zh-Hans" + let sourceLanguage: String? = settings.translateAndInsertSourceLanguage == .auto ? nil : settings.translateAndInsertSourceLanguage.rawValue + let preferredEngine = resolvePreferredEngine(from: settings, scene: .translateAndInsert) + + #if DEBUG + Logger.translation.debug( + """ + Translate-and-insert config resolved: \ + target=\(targetLanguage, privacy: .public), \ + source=\(sourceLanguage ?? "auto", privacy: .public), \ + engine=\(preferredEngine.rawValue, privacy: .public) + """ + ) + #endif + + return TextTranslationConfig( + targetLanguage: targetLanguage, + sourceLanguage: sourceLanguage, + preferredEngine: preferredEngine, + scene: .translateAndInsert, + mode: settings.engineSelectionMode, + fallbackEnabled: settings.translationFallbackEnabled, + parallelEngines: settings.parallelEngines, + sceneBindings: settings.sceneBindings + ) + } + + @MainActor + private static func resolvePreferredEngine( + from settings: AppSettings, + scene: TranslationScene + ) -> TranslationEngineType { + switch settings.engineSelectionMode { + case .sceneBinding: + return settings.sceneBindings[scene]?.primaryEngine ?? SceneEngineBinding.default(for: scene).primaryEngine + case .parallel, .quickSwitch, .primaryWithFallback: + if let firstConfiguredEngine = settings.parallelEngines.first { + return firstConfiguredEngine + } + return switch settings.preferredTranslationEngine { + case .apple: .apple + case .mtranServer: .mtranServer + } + } + } +} diff --git a/ScreenTranslate/Features/TextTranslation/TextTranslationPopupView.swift b/ScreenTranslate/Features/TextTranslation/TextTranslationPopupView.swift new file mode 100644 index 0000000..2151229 --- /dev/null +++ b/ScreenTranslate/Features/TextTranslation/TextTranslationPopupView.swift @@ -0,0 +1,282 @@ +// +// TextTranslationPopupView.swift +// ScreenTranslate +// +// Created for US-004: Create TextTranslationPopup window for showing translation results +// Updated: Standard window style with title bar, content, and toolbar +// + +import SwiftUI +import AppKit + +// MARK: - SwiftUI Content View + +struct TextTranslationPopupContentView: View { + let originalText: String + let translatedText: String + let sourceLanguage: String + let targetLanguage: String + let engineResults: [EngineTranslationInfo] + let onCopy: () -> Void + + @State private var showCopySuccess = false + + /// Whether to show multi-engine results + private var showMultiEngine: Bool { + engineResults.count > 1 + } + + private var isOriginalRTL: Bool { + Self.isRTLLanguage(sourceLanguage) || Self.containsRTLText(originalText) + } + + private var isTranslatedRTL: Bool { + Self.isRTLLanguage(targetLanguage) || Self.containsRTLText(translatedText) + } + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + // Original text section + originalTextSection + + if showMultiEngine { + // Multi-engine results + ForEach(engineResults) { result in + engineResultSection(result) + } + } else { + // Single result (backward compatible) + translatedTextSection + } + } + .padding(16) + } + .frame(maxHeight: 350) + .background(Color(nsColor: .windowBackgroundColor)) + + Divider() + + toolbar + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.bar) + } + .frame(minWidth: 380, idealWidth: 420, maxWidth: 520, minHeight: 200) + .onKeyPress(.escape) { + NSApp.keyWindow?.close() + return .handled + } + } + + // MARK: - Original Text + + private var originalTextSection: some View { + VStack(alignment: isOriginalRTL ? .trailing : .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "text.bubble") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + Text(sourceLanguage.uppercased()) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) + .tracking(0.5) + Spacer() + } + + Text(originalText) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(.secondary) + .multilineTextAlignment(isOriginalRTL ? .trailing : .leading) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: isOriginalRTL ? .trailing : .leading) + } + .padding(14) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + // MARK: - Single Translated Text (backward compatible) + + private var translatedTextSection: some View { + VStack(alignment: isTranslatedRTL ? .trailing : .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "checkmark.bubble") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.blue) + Text(targetLanguage.uppercased()) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.blue) + .tracking(0.5) + Spacer() + } + + Text(translatedText) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.primary) + .multilineTextAlignment(isTranslatedRTL ? .trailing : .leading) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: isTranslatedRTL ? .trailing : .leading) + } + .padding(14) + .background(Color.blue.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.blue.opacity(0.15), lineWidth: 0.5) + ) + } + + // MARK: - Per-Engine Result + + private func engineResultSection(_ result: EngineTranslationInfo) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + if result.isSuccess { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.green) + } else { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.red) + } + Text(result.engine.localizedName) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(result.isSuccess ? .green : .red) + .tracking(0.3) + Spacer() + if result.isSuccess { + Text(String(format: "%.1fs", result.latency)) + .font(.system(size: 9, weight: .regular, design: .monospaced)) + .foregroundStyle(.tertiary) + } + } + + if let text = result.translatedText { + Text(text) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } else if let error = result.errorMessage { + Text(error) + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(.red) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(12) + .background( + result.isSuccess + ? Color.green.opacity(0.04) + : Color.red.opacity(0.04) + ) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder( + result.isSuccess + ? Color.green.opacity(0.12) + : Color.red.opacity(0.12), + lineWidth: 0.5 + ) + ) + } + + // MARK: - Toolbar + + private var toolbar: some View { + HStack(spacing: 12) { + // Character count info + Text("\(originalText.count) → \(translatedText.count)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + + Divider() + .frame(height: 16) + + Spacer() + + Button(action: { + onCopy() + showCopySuccess = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + showCopySuccess = false + } + }) { + Label( + showCopySuccess ? String(localized: "common.copied") : String(localized: "common.copy"), + systemImage: showCopySuccess ? "checkmark" : "doc.on.clipboard" + ) + } + .buttonStyle(.bordered) + .disabled(showCopySuccess) + .foregroundColor(showCopySuccess ? .green : nil) + } + } + + // MARK: - RTL Detection + + private static func isRTLLanguage(_ languageName: String) -> Bool { + let rtlLanguageIndicators = [ + "ARABIC", "HEBREW", "PERSIAN", "FARSI", "URDU", + "阿拉伯语", "希伯来语", "波斯语", "乌尔都语" + ] + let uppercasedName = languageName.uppercased() + return rtlLanguageIndicators.contains { uppercasedName.contains($0) } + } + + private static func containsRTLText(_ text: String) -> Bool { + var rtlCount = 0 + var ltrCount = 0 + + for scalar in text.unicodeScalars { + let value = scalar.value + if (value >= 0x590 && value <= 0x5FF) || + (value >= 0x600 && value <= 0x6FF) || + (value >= 0x750 && value <= 0x77F) || + (value >= 0xFB50 && value <= 0xFDFF) || + (value >= 0xFE70 && value <= 0xFEFF) { + rtlCount += 1 + } else if value >= 0x41 && value <= 0x5A || value >= 0x61 && value <= 0x7A { + ltrCount += 1 + } + } + + return rtlCount > 0 && (ltrCount == 0 || Double(rtlCount) / Double(rtlCount + ltrCount) > 0.3) + } +} + +// MARK: - Preview + +#Preview { + TextTranslationPopupContentView( + originalText: "Hello, how are you today?", + translatedText: "你好,今天怎么样?", + sourceLanguage: "English", + targetLanguage: "Chinese", + engineResults: [], + onCopy: {} + ) + .frame(width: 420, height: 280) +} + +#Preview("Multi-Engine") { + TextTranslationPopupContentView( + originalText: "Hello, how are you today?", + translatedText: "你好,今天怎么样?", + sourceLanguage: "English", + targetLanguage: "Chinese", + engineResults: [ + EngineTranslationInfo(engine: .mtranServer, translatedText: nil, errorMessage: "Connection refused", latency: 2.1), + EngineTranslationInfo(engine: .apple, translatedText: "你好,今天怎么样?", errorMessage: nil, latency: 0.5), + EngineTranslationInfo(engine: .google, translatedText: "你好,你今天好吗?", errorMessage: nil, latency: 1.0), + ], + onCopy: {} + ) + .frame(width: 420, height: 380) +} diff --git a/ScreenTranslate/Features/TextTranslation/TextTranslationPopupWindow.swift b/ScreenTranslate/Features/TextTranslation/TextTranslationPopupWindow.swift new file mode 100644 index 0000000..532a150 --- /dev/null +++ b/ScreenTranslate/Features/TextTranslation/TextTranslationPopupWindow.swift @@ -0,0 +1,169 @@ +// +// TextTranslationPopupWindow.swift +// ScreenTranslate +// +// Created for US-004: Create TextTranslationPopup window for showing translation results +// Updated: Standard window style with title bar, consistent with BilingualResultWindow +// + +import AppKit +import CoreGraphics +import SwiftUI + +// MARK: - TextTranslationPopupDelegate + +/// Delegate protocol for text translation popup events. +@MainActor +protocol TextTranslationPopupDelegate: AnyObject { + /// Called when user dismisses the popup. + func textTranslationPopupDidDismiss() +} + +// MARK: - TextTranslationPopupWindowController + +/// Controller for managing text translation popup window. +/// Uses standard window style consistent with BilingualResultWindow. +@MainActor +final class TextTranslationPopupController: NSObject { + static let shared = TextTranslationPopupController() + + private var window: NSWindow? + private weak var popupDelegate: TextTranslationPopupDelegate? + var onDismiss: (() -> Void)? + + private let debounceInterval: TimeInterval = 0.3 + private var lastPresentationTime: Date? + + private override init() { + super.init() + } + + // MARK: - Public API + + func presentPopup(result: TextTranslationResult) { + guard canPresent() else { return } + + dismissPopup() + + let sourceLanguageName = languageDisplayName(for: result.sourceLanguage) + let targetLanguageName = languageDisplayName(for: result.targetLanguage) + + // Create SwiftUI view + let contentView = TextTranslationPopupContentView( + originalText: result.originalText, + translatedText: result.translatedText, + sourceLanguage: sourceLanguageName, + targetLanguage: targetLanguageName, + engineResults: result.engineResults, + onCopy: { [weak self] in + self?.copyToClipboard(result.translatedText) + } + ) + + let hostingView = NSHostingView(rootView: contentView) + + // Calculate window size + let windowSize = calculateWindowSize( + originalText: result.originalText, + translatedText: result.translatedText + ) + + // Create window with standard style + let newWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: windowSize.width, height: windowSize.height), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + + newWindow.contentView = hostingView + newWindow.title = String(localized: "textTranslation.window.title") + newWindow.center() + newWindow.isReleasedWhenClosed = false + newWindow.delegate = self + newWindow.minSize = NSSize(width: 380, height: 200) + newWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + self.window = newWindow + lastPresentationTime = Date() + + newWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + func dismissPopup() { + window?.close() + window = nil + onDismiss?() + } + + // MARK: - Private + + private func canPresent() -> Bool { + guard let lastTime = lastPresentationTime else { return true } + return Date().timeIntervalSince(lastTime) >= debounceInterval + } + + func resetDebounce() { + lastPresentationTime = nil + } + + private func languageDisplayName(for code: String?) -> String { + TranslationLanguage.displayName( + for: code, + locale: .current, + autoDisplayName: NSLocalizedString("language.auto", value: "Auto Detected", comment: "") + ) + } + + private func calculateWindowSize(originalText: String, translatedText: String) -> NSSize { + let textWidth: CGFloat = 388 // 420 - 16*2 padding + + let originalFont = NSFont.systemFont(ofSize: 13, weight: .regular) + let translatedFont = NSFont.systemFont(ofSize: 15, weight: .medium) + + let originalSize = (originalText as NSString).boundingRect( + with: NSSize(width: textWidth - 28, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: originalFont] + ) + + let translatedSize = (translatedText as NSString).boundingRect( + with: NSSize(width: textWidth - 28, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: translatedFont] + ) + + // Calculate height + var totalHeight: CGFloat = 0 + totalHeight += 16 // top padding + totalHeight += 28 + ceil(originalSize.height) + 28 // original section with padding + totalHeight += 16 // spacing + totalHeight += 28 + ceil(translatedSize.height) + 28 // translated section with padding + totalHeight += 16 // bottom padding + totalHeight += 44 // toolbar + + // Constrain + totalHeight = min(max(totalHeight, 200), 450) + + return NSSize(width: 420, height: totalHeight) + } + + private func copyToClipboard(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } +} + +// MARK: - NSWindowDelegate + +extension TextTranslationPopupController: NSWindowDelegate { + nonisolated func windowWillClose(_ notification: Notification) { + Task { @MainActor in + window = nil + onDismiss?() + popupDelegate?.textTranslationPopupDidDismiss() + } + } +} diff --git a/ScreenTranslate/Features/TranslationFlow/TranslationFlowController.swift b/ScreenTranslate/Features/TranslationFlow/TranslationFlowController.swift new file mode 100644 index 0000000..8f56fcd --- /dev/null +++ b/ScreenTranslate/Features/TranslationFlow/TranslationFlowController.swift @@ -0,0 +1,397 @@ +import AppKit +import Observation +import os.log + +/// 翻译流程阶段 +enum TranslationFlowPhase: Sendable, Equatable { + case idle + case analyzing + case translating + case rendering + case completed + case failed(TranslationFlowError) + + var isProcessing: Bool { + switch self { + case .analyzing, .translating, .rendering: + return true + default: + return false + } + } + + var localizedDescription: String { + switch self { + case .idle: + return String(localized: "translationFlow.phase.idle") + case .analyzing: + return String(localized: "translationFlow.phase.analyzing") + case .translating: + return String(localized: "translationFlow.phase.translating") + case .rendering: + return String(localized: "translationFlow.phase.rendering") + case .completed: + return String(localized: "translationFlow.phase.completed") + case .failed: + return String(localized: "translationFlow.phase.failed") + } + } + + var progress: Double { + switch self { + case .idle: return 0.0 + case .analyzing: return 0.25 + case .translating: return 0.50 + case .rendering: return 0.75 + case .completed: return 1.0 + case .failed: return 0.0 + } + } +} + +/// 翻译流程错误 +enum TranslationFlowError: LocalizedError, Sendable, Equatable { + case analysisFailure(String) + case translationFailure(String) + case renderingFailure(String) + case cancelled + case noTextFound + + var errorDescription: String? { + switch self { + case .analysisFailure(let message): + return String(format: NSLocalizedString("translationFlow.error.analysis", comment: ""), message) + case .translationFailure(let message): + return String(format: NSLocalizedString("translationFlow.error.translation", comment: ""), message) + case .renderingFailure(let message): + return String(format: NSLocalizedString("translationFlow.error.rendering", comment: ""), message) + case .cancelled: + return String(localized: "translationFlow.error.cancelled") + case .noTextFound: + return String(localized: "translationFlow.error.noTextFound") + } + } + + var recoverySuggestion: String? { + switch self { + case .analysisFailure: + return String(localized: "translationFlow.recovery.analysis") + case .translationFailure: + return String(localized: "translationFlow.recovery.translation") + case .renderingFailure: + return String(localized: "translationFlow.recovery.rendering") + case .cancelled: + return nil + case .noTextFound: + return String(localized: "translationFlow.recovery.noTextFound") + } + } +} + +/// 翻译流程结果 +struct TranslationFlowResult: Sendable { + let originalImage: CGImage + let renderedImage: CGImage + let segments: [BilingualSegment] + let processingTime: TimeInterval +} + +/// 翻译流程控制器 - 协调整个翻译流程 +@MainActor +@Observable +final class TranslationFlowController { + static let shared = TranslationFlowController() + + // MARK: - Observable State + + private(set) var currentPhase: TranslationFlowPhase = .idle + private(set) var lastError: TranslationFlowError? + private(set) var lastResult: TranslationFlowResult? + + // MARK: - Private + + private var currentTask: Task? + private let screenCoderEngine = ScreenCoderEngine.shared + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ScreenTranslate", category: "TranslationFlow") + private let overlayRenderer = OverlayRenderer() + + private init() {} + + // MARK: - Public API + + func startTranslation(image: CGImage, scaleFactor: CGFloat) { + cancel() + + currentTask = Task { + await performTranslation(image: image, scaleFactor: scaleFactor) + } + } + + func cancel() { + currentTask?.cancel() + currentTask = nil + + if currentPhase.isProcessing { + currentPhase = .failed(.cancelled) + lastError = .cancelled + } + } + + func reset() { + cancel() + currentPhase = .idle + lastError = nil + lastResult = nil + } + + // MARK: - Private Implementation + + private func performTranslation(image: CGImage, scaleFactor: CGFloat) async { + let startTime = Date() + lastError = nil + lastResult = nil + + // Show loading window immediately with original image + await MainActor.run { + BilingualResultWindowController.shared.showLoading( + originalImage: image, + scaleFactor: scaleFactor, + message: String(localized: "bilingualResult.loading.analyzing") + ) + } + + // Phase 1: 分析图像 + currentPhase = .analyzing + + let analysisResult: ScreenAnalysisResult + do { + try Task.checkCancellation() + let initialAnalysis = try await screenCoderEngine.analyze(image: image) + analysisResult = try await Self.recoverAnalysisResultIfNeeded(initialAnalysis) { + try await OCRService.shared.recognize(image) + } + + if analysisResult.segments.isEmpty { + throw TranslationFlowError.noTextFound + } + } catch is CancellationError { + handleCancellation() + return + } catch let error as TranslationFlowError { + handleError(error) + return + } catch { + logger.error("Analysis phase failed: \(String(describing: error))") + let errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + handleError(.analysisFailure(errorMessage)) + return + } + + // Phase 2: 翻译文本 + currentPhase = .translating + + let bilingualSegments: [BilingualSegment] + do { + try Task.checkCancellation() + + let settings = AppSettings.shared + let targetLanguage = settings.translationTargetLanguage?.rawValue ?? "zh-Hans" + let sourceLanguage: String? = settings.translationSourceLanguage == .auto + ? nil + : settings.translationSourceLanguage.rawValue + let engine = settings.translationEngine + let filteredAnalysisResult = analysisResult.filteredForTranslation() + if filteredAnalysisResult.segments.isEmpty { + throw TranslationFlowError.noTextFound + } + let texts = filteredAnalysisResult.segments.map(\.text) + + if #available(macOS 13.0, *) { + let translatedSegments = try await TranslationService.shared.translate( + segments: texts, + to: targetLanguage, + preferredEngine: engine, + from: sourceLanguage + ) + + // Merge bounding box info from VLM analysis back into translated segments + bilingualSegments = zip(filteredAnalysisResult.segments, translatedSegments).map { original, translated in + BilingualSegment( + segment: original, + translatedText: translated.translated, + sourceLanguage: translated.sourceLanguage, + targetLanguage: translated.targetLanguage + ) + } + } else { + throw TranslationFlowError.translationFailure("macOS 13.0+ required") + } + } catch is CancellationError { + handleCancellation() + return + } catch let error as TranslationFlowError { + handleError(error) + return + } catch { + logger.error("Translation phase failed: \(String(describing: error))") + let errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + handleError(.translationFailure(errorMessage)) + return + } + + // Phase 3: 渲染结果 + currentPhase = .rendering + + do { + try Task.checkCancellation() + + // Get theme on main thread (OverlayTheme.current requires @MainActor) + let theme = OverlayTheme.current + guard let renderedImage = overlayRenderer.render(image: image, segments: bilingualSegments, theme: theme) else { + throw TranslationFlowError.renderingFailure("Failed to render overlay") + } + + let processingTime = Date().timeIntervalSince(startTime) + + lastResult = TranslationFlowResult( + originalImage: image, + renderedImage: renderedImage, + segments: bilingualSegments, + processingTime: processingTime + ) + + currentPhase = .completed + + // Save to translation history + await saveToHistory( + originalImage: image, + segments: bilingualSegments, + renderedImage: renderedImage + ) + + showResultWindow(renderedImage: renderedImage, scaleFactor: scaleFactor) + + } catch is CancellationError { + handleCancellation() + return + } catch let error as TranslationFlowError { + handleError(error) + return + } catch { + logger.error("Rendering phase failed: \(String(describing: error))") + let errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + handleError(.renderingFailure(errorMessage)) + return + } + } + + private func handleCancellation() { + currentPhase = .failed(.cancelled) + lastError = .cancelled + } + + private func handleError(_ error: TranslationFlowError) { + currentPhase = .failed(error) + lastError = error + showErrorAlert(error) + } + + private func showResultWindow(renderedImage: CGImage, scaleFactor: CGFloat) { + // Get translated text from last result + let translatedText = lastResult?.segments.map { $0.translated }.joined(separator: "\n") + BilingualResultWindowController.shared.showResult(image: renderedImage, scaleFactor: scaleFactor, translatedText: translatedText) + } + + static func recoverAnalysisResultIfNeeded( + _ analysisResult: ScreenAnalysisResult, + ocrFallback: @Sendable () async throws -> OCRResult + ) async throws -> ScreenAnalysisResult { + guard analysisResult.containsOnlyPromptLeakage else { + return analysisResult + } + + let fallbackResult = try await ocrFallback() + let recoveredResult = ScreenAnalysisResult(ocrResult: fallbackResult) + return recoveredResult.segments.isEmpty ? analysisResult : recoveredResult + } + + private func saveToHistory( + originalImage: CGImage, + segments: [BilingualSegment], + renderedImage: CGImage + ) async { + // Combine all source text and translated text + let sourceText = segments.map { $0.original.text }.joined(separator: "\n") + let translatedText = segments.map { $0.translated }.joined(separator: "\n") + + // Get language info from first segment (all segments should have same language pair) + let sourceLanguage = segments.first?.sourceLanguage ?? "auto" + let targetLanguage = segments.first?.targetLanguage ?? "zh-Hans" + + // Create TranslationResult + let result = TranslationResult( + sourceText: sourceText, + translatedText: translatedText, + sourceLanguage: sourceLanguage, + targetLanguage: targetLanguage + ) + + // Save to history with thumbnail + await MainActor.run { + HistoryWindowController.shared.addTranslation( + result: result, + image: originalImage + ) + } + } + + private func showErrorAlert(_ error: TranslationFlowError) { + NSApp.activate(ignoringOtherApps: true) + + let alert = NSAlert() + + // Use different titles for different error types + switch error { + case .analysisFailure, .noTextFound: + alert.messageText = String(localized: "translationFlow.error.title.analysis") + case .translationFailure: + alert.messageText = String(localized: "translationFlow.error.title.translation") + case .renderingFailure: + alert.messageText = String(localized: "translationFlow.error.title.rendering") + case .cancelled: + alert.messageText = String(localized: "translationFlow.error.title") + } + + var errorDetails = error.errorDescription ?? String(localized: "translationFlow.error.unknown") + + // Add provider info for analysis errors + if case .analysisFailure = error { + let settings = AppSettings.shared + errorDetails += "\n\nProvider: \(settings.vlmProvider.localizedName)" + // Show appropriate model info based on provider type + switch settings.vlmProvider { + case .paddleocr: + // For local/cloud PaddleOCR modes, model info is not applicable + break + default: + errorDetails += "\nModel: \(settings.vlmModelName)" + } + } + + // Add provider info for translation errors + if case .translationFailure = error { + let settings = AppSettings.shared + errorDetails += "\n\n" + String(localized: "translationFlow.error.translation.engine") + errorDetails += ": \(settings.preferredTranslationEngine.rawValue)" + } + + alert.informativeText = errorDetails + + if let recovery = error.recoverySuggestion { + alert.informativeText += "\n\n" + recovery + } + alert.alertStyle = .warning + alert.addButton(withTitle: String(localized: "common.ok")) + alert.runModal() + } +} diff --git a/ScreenCapture/Models/Annotation.swift b/ScreenTranslate/Models/Annotation.swift similarity index 52% rename from ScreenCapture/Models/Annotation.swift rename to ScreenTranslate/Models/Annotation.swift index de1c810..61a8cd4 100644 --- a/ScreenCapture/Models/Annotation.swift +++ b/ScreenTranslate/Models/Annotation.swift @@ -2,12 +2,17 @@ import Foundation import CoreGraphics /// A drawing element placed on a screenshot. -/// Supports rectangle, freehand, arrow, and text annotation types. +/// Supports rectangle, freehand, arrow, text, ellipse, line, mosaic, highlight, and numberLabel annotation types. enum Annotation: Identifiable, Equatable, Sendable { case rectangle(RectangleAnnotation) case freehand(FreehandAnnotation) case arrow(ArrowAnnotation) case text(TextAnnotation) + case ellipse(EllipseAnnotation) + case line(LineAnnotation) + case mosaic(MosaicAnnotation) + case highlight(HighlightAnnotation) + case numberLabel(NumberLabelAnnotation) /// Unique identifier for this annotation var id: UUID { @@ -20,6 +25,16 @@ enum Annotation: Identifiable, Equatable, Sendable { return annotation.id case .text(let annotation): return annotation.id + case .ellipse(let annotation): + return annotation.id + case .line(let annotation): + return annotation.id + case .mosaic(let annotation): + return annotation.id + case .highlight(let annotation): + return annotation.id + case .numberLabel(let annotation): + return annotation.id } } @@ -34,6 +49,16 @@ enum Annotation: Identifiable, Equatable, Sendable { return annotation.bounds case .text(let annotation): return annotation.bounds + case .ellipse(let annotation): + return annotation.rect + case .line(let annotation): + return annotation.bounds + case .mosaic(let annotation): + return annotation.rect + case .highlight(let annotation): + return annotation.rect + case .numberLabel(let annotation): + return annotation.bounds } } } @@ -200,6 +225,175 @@ struct TextAnnotation: Identifiable, Equatable, Sendable { } } +// MARK: - Ellipse Annotation + +/// An ellipse annotation with position, size, and stroke style. +struct EllipseAnnotation: Identifiable, Equatable, Sendable { + /// Unique identifier + let id: UUID + + /// Position and size in image coordinates + var rect: CGRect + + /// Stroke color and line width + var style: StrokeStyle + + /// Whether the ellipse is filled (solid) or hollow (outline only) + var isFilled: Bool + + init(id: UUID = UUID(), rect: CGRect, style: StrokeStyle = .default, isFilled: Bool = false) { + self.id = id + self.rect = rect + self.style = style + self.isFilled = isFilled + } + + /// Whether this annotation has meaningful size + var isValid: Bool { + rect.width >= 5 && rect.height >= 5 + } +} + +// MARK: - Line Annotation + +/// A line annotation with start point, end point, and stroke style. +struct LineAnnotation: Identifiable, Equatable, Sendable { + /// Unique identifier + let id: UUID + + /// Start point in image coordinates + var startPoint: CGPoint + + /// End point in image coordinates + var endPoint: CGPoint + + /// Stroke color and line width + var style: StrokeStyle + + init(id: UUID = UUID(), startPoint: CGPoint, endPoint: CGPoint, style: StrokeStyle = .default) { + self.id = id + self.startPoint = startPoint + self.endPoint = endPoint + self.style = style + } + + /// Whether this annotation has meaningful length + var isValid: Bool { + let dx = endPoint.x - startPoint.x + let dy = endPoint.y - startPoint.y + let length = sqrt(dx * dx + dy * dy) + return length >= 5 + } + + /// The bounding rectangle of the line + var bounds: CGRect { + let minX = min(startPoint.x, endPoint.x) + let minY = min(startPoint.y, endPoint.y) + let maxX = max(startPoint.x, endPoint.x) + let maxY = max(startPoint.y, endPoint.y) + + let padding = style.lineWidth / 2 + return CGRect( + x: minX - padding, + y: minY - padding, + width: maxX - minX + padding * 2, + height: maxY - minY + padding * 2 + ) + } +} + +// MARK: - Mosaic Annotation + +/// A mosaic annotation that pixelates a region to hide sensitive content. +struct MosaicAnnotation: Identifiable, Equatable, Sendable { + /// Unique identifier + let id: UUID + + /// Position and size in image coordinates + var rect: CGRect + + /// Block size for pixelation (8-32) + var blockSize: Int + + init(id: UUID = UUID(), rect: CGRect, blockSize: Int = 10) { + self.id = id + self.rect = rect + self.blockSize = max(4, min(32, blockSize)) + } + + /// Whether this annotation has meaningful size + var isValid: Bool { + rect.width >= 10 && rect.height >= 10 + } +} + +// MARK: - Highlight Annotation + +/// A highlight annotation with semi-transparent background. +struct HighlightAnnotation: Identifiable, Equatable, Sendable { + /// Unique identifier + let id: UUID + + /// Position and size in image coordinates + var rect: CGRect + + /// Highlight color + var color: CodableColor + + /// Opacity (0.0 - 1.0) + var opacity: Double + + init(id: UUID = UUID(), rect: CGRect, color: CodableColor = CodableColor(.yellow), opacity: Double = 0.4) { + self.id = id + self.rect = rect + self.color = color + self.opacity = max(0.1, min(0.8, opacity)) + } + + /// Whether this annotation has meaningful size + var isValid: Bool { + rect.width >= 5 && rect.height >= 5 + } +} + +// MARK: - Number Label Annotation + +/// A numbered label annotation (①②③...) for marking sequential items. +struct NumberLabelAnnotation: Identifiable, Equatable, Sendable { + /// Unique identifier + let id: UUID + + /// Center position in image coordinates + var position: CGPoint + + /// The number to display + var number: Int + + /// Circle size (diameter) + var size: CGFloat + + /// Text color + var color: CodableColor + + init(id: UUID = UUID(), position: CGPoint, number: Int, size: CGFloat = 24, color: CodableColor = CodableColor(.red)) { + self.id = id + self.position = position + self.number = max(1, number) + self.size = max(16, min(64, size)) + self.color = color + } + + /// The bounding rectangle centered at position + var bounds: CGRect { + CGRect( + x: position.x - size / 2, + y: position.y - size / 2, + width: size, + height: size + ) + } +} + // MARK: - CGPoint Sendable Conformance extension CGPoint: @retroactive @unchecked Sendable {} @@ -218,6 +412,16 @@ extension Annotation { return NSLocalizedString("tool.arrow", comment: "") case .text: return NSLocalizedString("tool.text", comment: "") + case .ellipse: + return NSLocalizedString("tool.ellipse", comment: "") + case .line: + return NSLocalizedString("tool.line", comment: "") + case .mosaic: + return NSLocalizedString("tool.mosaic", comment: "") + case .highlight: + return NSLocalizedString("tool.highlight", comment: "") + case .numberLabel: + return NSLocalizedString("tool.numberLabel", comment: "") } } } diff --git a/ScreenTranslate/Models/AppLanguage.swift b/ScreenTranslate/Models/AppLanguage.swift new file mode 100644 index 0000000..c73807d --- /dev/null +++ b/ScreenTranslate/Models/AppLanguage.swift @@ -0,0 +1,233 @@ +import Foundation +import SwiftUI + +/// Supported application display languages. +enum AppLanguage: String, CaseIterable, Identifiable, Sendable { + /// Follow system language (fallback to English if unsupported) + case system = "system" + /// English + case english = "en" + /// Simplified Chinese + case simplifiedChinese = "zh-Hans" + /// German + case german = "de" + /// Spanish + case spanish = "es" + /// French + case french = "fr" + /// Italian + case italian = "it" + /// Japanese + case japanese = "ja" + /// Korean + case korean = "ko" + /// Portuguese + case portuguese = "pt" + /// Russian + case russian = "ru" + + var id: String { rawValue } + + /// The display name for this language option + var displayName: String { + switch self { + case .system: + return String(localized: "settings.language.system") + case .english: + return "English" + case .simplifiedChinese: + return "简体中文" + case .german: + return "Deutsch" + case .spanish: + return "Español" + case .french: + return "Français" + case .italian: + return "Italiano" + case .japanese: + return "日本語" + case .korean: + return "한국어" + case .portuguese: + return "Português" + case .russian: + return "Русский" + } + } + + /// The locale identifier for this language + var localeIdentifier: String? { + switch self { + case .system: + return nil + default: + return rawValue + } + } + + /// All supported language codes (excluding system) + static var supportedLanguageCodes: [String] { + allCases.compactMap { $0.localeIdentifier } + } + + static func from(localeIdentifier: String) -> AppLanguage? { + let normalizedIdentifier = localeIdentifier.replacingOccurrences(of: "_", with: "-").lowercased() + + if normalizedIdentifier.hasPrefix("zh-hans") + || normalizedIdentifier == "zh-cn" + || normalizedIdentifier == "zh-sg" + || normalizedIdentifier == "zh" { + return .simplifiedChinese + } + + return allCases.first { language in + guard language != .system else { + return false + } + + let languageCode = language.rawValue.lowercased() + return normalizedIdentifier == languageCode + || normalizedIdentifier.hasPrefix(languageCode + "-") + } + } +} + +/// Manages application language settings and provides runtime language switching. +@MainActor +@Observable +final class LanguageManager { + // MARK: - Singleton + + static let shared = LanguageManager() + + // MARK: - Properties + + /// The currently selected language + var currentLanguage: AppLanguage { + didSet { + if oldValue != currentLanguage { + applyLanguage() + saveLanguage() + } + } + } + + /// The active bundle for localized strings + private(set) var bundle: Bundle = .main + + /// Notification name for language change + static let languageDidChangeNotification = Notification.Name("LanguageDidChange") + + // MARK: - UserDefaults Key + + private let languageKey = "ScreenTranslate.appLanguage" + + // MARK: - Initialization + + private init() { + // Load saved language preference + if let savedLanguage = UserDefaults.standard.string(forKey: languageKey), + let language = AppLanguage(rawValue: savedLanguage) { + currentLanguage = language + } else { + currentLanguage = .system + } + + applyLanguage() + } + + // MARK: - Public Methods + + /// Returns a localized string for the given key + func localizedString(_ key: String, comment: String = "") -> String { + NSLocalizedString(key, tableName: "Localizable", bundle: bundle, comment: comment) + } + + /// Returns the effective locale identifier (resolves system to actual language) + var effectiveLocaleIdentifier: String { + if let localeId = currentLanguage.localeIdentifier { + return localeId + } + + // For system, detect the preferred language + for preferredLanguage in Locale.preferredLanguages { + if let matchedLanguage = AppLanguage.from(localeIdentifier: preferredLanguage), + let localeIdentifier = matchedLanguage.localeIdentifier { + return localeIdentifier + } + } + + // Default to English + return AppLanguage.english.rawValue + } + + // MARK: - Private Methods + + private func applyLanguage() { + let localeId = effectiveLocaleIdentifier + + // Find the bundle for this language + if let path = Bundle.main.path(forResource: localeId, ofType: "lproj"), + let languageBundle = Bundle(path: path) { + bundle = languageBundle + } else { + // Fallback to main bundle (English) + bundle = .main + } + + // Apply to UserDefaults for system-level settings + UserDefaults.standard.set([localeId], forKey: "AppleLanguages") + + // Post notification for views to refresh + NotificationCenter.default.post(name: Self.languageDidChangeNotification, object: nil) + } + + private func saveLanguage() { + UserDefaults.standard.set(currentLanguage.rawValue, forKey: languageKey) + } +} + +// MARK: - String Extension for Localization + +extension String { + /// Returns a localized version of this string using the current app language + @MainActor + var localized: String { + LanguageManager.shared.localizedString(self) + } + + /// Returns a localized string with format arguments + @MainActor + func localized(with arguments: CVarArg...) -> String { + String(format: localized, arguments: arguments) + } +} + +// MARK: - SwiftUI LocalizedText View + +/// A Text view that automatically updates when app language changes +struct LocalizedText: View { + private let key: String + @State private var refreshID = UUID() + + init(_ key: String) { + self.key = key + } + + var body: some View { + Text(LanguageManager.shared.localizedString(key)) + .id(refreshID) + .onReceive(NotificationCenter.default.publisher(for: LanguageManager.languageDidChangeNotification)) { _ in + refreshID = UUID() + } + } +} + +// MARK: - Localized String Helper Function + +/// Returns a localized string using the current app language bundle +@MainActor +func localized(_ key: String) -> String { + LanguageManager.shared.localizedString(key) +} diff --git a/ScreenTranslate/Models/AppSettings.swift b/ScreenTranslate/Models/AppSettings.swift new file mode 100644 index 0000000..bdeb37b --- /dev/null +++ b/ScreenTranslate/Models/AppSettings.swift @@ -0,0 +1,921 @@ +import Foundation +import SwiftUI +import os +import Security + +/// PaddleOCR mode selection +enum PaddleOCRMode: String, Codable, CaseIterable, Sendable { + case fast + case precise + + var localizedName: String { + switch self { + case .fast: + return NSLocalizedString("settings.paddleocr.mode.fast", comment: "Fast mode") + case .precise: + return NSLocalizedString("settings.paddleocr.mode.precise", comment: "Precise mode") + } + } + + var description: String { + switch self { + case .fast: + return NSLocalizedString("settings.paddleocr.mode.fast.description", comment: "~1s, uses groupIntoLines") + case .precise: + return NSLocalizedString("settings.paddleocr.mode.precise.description", comment: "~12s, VL-1.5 model") + } + } +} + +/// GLM OCR backend mode selection +enum GLMOCRMode: String, Codable, CaseIterable, Sendable { + case cloud + case local + + var localizedName: String { + switch self { + case .cloud: + return NSLocalizedString("settings.glmocr.mode.cloud", comment: "Cloud mode") + case .local: + return NSLocalizedString("settings.glmocr.mode.local", comment: "Local mode") + } + } + + var providerDescription: String { + switch self { + case .cloud: + return NSLocalizedString( + "vlm.provider.glmocr.description", + comment: "Zhipu GLM-OCR layout parsing API" + ) + case .local: + return NSLocalizedString( + "vlm.provider.glmocr.local.description", + comment: "Local MLX-VLM server for GLM-OCR" + ) + } + } + + var defaultBaseURL: String { + switch self { + case .cloud: + return "https://open.bigmodel.cn/api/paas/v4" + case .local: + return "http://127.0.0.1:18081" + } + } + + var defaultModelName: String { + switch self { + case .cloud: + return "glm-ocr" + case .local: + return "mlx-community/GLM-OCR-bf16" + } + } + + var requiresAPIKey: Bool { + switch self { + case .cloud: + return true + case .local: + return false + } + } +} + +/// User preferences persisted across sessions via UserDefaults. +/// All properties automatically sync to UserDefaults with the `ScreenTranslate.` prefix. +@MainActor +@Observable +final class AppSettings { + // MARK: - Singleton + + /// Shared settings instance + static let shared = AppSettings() + + // MARK: - UserDefaults Keys + + enum Keys { + static let prefix = "ScreenTranslate." + static let saveLocation = prefix + "saveLocation" + static let defaultFormat = prefix + "defaultFormat" + static let jpegQuality = prefix + "jpegQuality" + static let heicQuality = prefix + "heicQuality" + static let fullScreenShortcut = prefix + "fullScreenShortcut" + static let selectionShortcut = prefix + "selectionShortcut" + static let translationModeShortcut = prefix + "translationModeShortcut" + static let textSelectionTranslationShortcut = prefix + "textSelectionTranslationShortcut" + static let translateAndInsertShortcut = prefix + "translateAndInsertShortcut" + static let strokeColor = prefix + "strokeColor" + static let strokeWidth = prefix + "strokeWidth" + static let textSize = prefix + "textSize" + static let rectangleFilled = prefix + "rectangleFilled" + static let ellipseFilled = prefix + "ellipseFilled" + static let mosaicBlockSize = prefix + "mosaicBlockSize" + static let translationTargetLanguage = prefix + "translationTargetLanguage" + static let translationSourceLanguage = prefix + "translationSourceLanguage" + static let translationAutoDetect = prefix + "translationAutoDetect" + static let ocrEngine = prefix + "ocrEngine" + static let translationEngine = prefix + "translationEngine" + static let translationMode = prefix + "translationMode" + static let onboardingCompleted = prefix + "onboardingCompleted" + static let paddleOCRServerAddress = prefix + "paddleOCRServerAddress" + static let mtranServerHost = prefix + "mtranServerHost" + static let mtranServerPort = prefix + "mtranServerPort" + // VLM Configuration + static let vlmProvider = prefix + "vlmProvider" + static let vlmAPIKey = prefix + "vlmAPIKey" + static let vlmBaseURL = prefix + "vlmBaseURL" + static let vlmModelName = prefix + "vlmModelName" + static let glmOCRMode = prefix + "glmOCRMode" + static let glmOCRCloudBaseURL = prefix + "glmOCRCloudBaseURL" + static let glmOCRCloudModelName = prefix + "glmOCRCloudModelName" + static let glmOCRLocalBaseURL = prefix + "glmOCRLocalBaseURL" + static let glmOCRLocalModelName = prefix + "glmOCRLocalModelName" + // Translation Workflow Configuration + static let preferredTranslationEngine = prefix + "preferredTranslationEngine" + static let mtranServerURL = prefix + "mtranServerURL" + static let translationFallbackEnabled = prefix + "translationFallbackEnabled" + // Translate and Insert Language Configuration + static let translateAndInsertSourceLanguage = prefix + "translateAndInsertSourceLanguage" + static let translateAndInsertTargetLanguage = prefix + "translateAndInsertTargetLanguage" + // Multi-Engine Configuration + static let engineSelectionMode = prefix + "engineSelectionMode" + static let engineConfigs = prefix + "engineConfigs" + static let promptConfig = prefix + "promptConfig" + static let sceneBindings = prefix + "sceneBindings" + static let parallelEngines = prefix + "parallelEngines" + static let compatibleProviderConfigs = prefix + "compatibleProviderConfigs" + // PaddleOCR Configuration + static let paddleOCRMode = prefix + "paddleOCRMode" + static let paddleOCRUseCloud = prefix + "paddleOCRUseCloud" + static let paddleOCRCloudBaseURL = prefix + "paddleOCRCloudBaseURL" + static let paddleOCRCloudAPIKey = prefix + "paddleOCRCloudAPIKey" + static let paddleOCRCloudModelId = prefix + "paddleOCRCloudModelId" + static let paddleOCRLocalVLModelDir = prefix + "paddleOCRLocalVLModelDir" + } + + // MARK: - Properties + + /// Default save directory + var saveLocation: URL { + didSet { save(saveLocation.path, forKey: Keys.saveLocation) } + } + + /// Default export format (PNG or JPEG) + var defaultFormat: ExportFormat { + didSet { save(defaultFormat.rawValue, forKey: Keys.defaultFormat) } + } + + /// JPEG compression quality (0.0-1.0) + var jpegQuality: Double { + didSet { save(jpegQuality, forKey: Keys.jpegQuality) } + } + + /// HEIC compression quality (0.0-1.0) + var heicQuality: Double { + didSet { save(heicQuality, forKey: Keys.heicQuality) } + } + + /// Global hotkey for full screen capture + var fullScreenShortcut: KeyboardShortcut { + didSet { saveShortcut(fullScreenShortcut, forKey: Keys.fullScreenShortcut) } + } + + /// Global hotkey for selection capture + var selectionShortcut: KeyboardShortcut { + didSet { saveShortcut(selectionShortcut, forKey: Keys.selectionShortcut) } + } + + /// Global hotkey for translation mode + var translationModeShortcut: KeyboardShortcut { + didSet { saveShortcut(translationModeShortcut, forKey: Keys.translationModeShortcut) } + } + + /// Global hotkey for text selection translation + var textSelectionTranslationShortcut: KeyboardShortcut { + didSet { saveShortcut(textSelectionTranslationShortcut, forKey: Keys.textSelectionTranslationShortcut) } + } + + /// Global hotkey for translate clipboard and insert + var translateAndInsertShortcut: KeyboardShortcut { + didSet { saveShortcut(translateAndInsertShortcut, forKey: Keys.translateAndInsertShortcut) } + } + + /// Default annotation stroke color + var strokeColor: CodableColor { + didSet { saveColor(strokeColor, forKey: Keys.strokeColor) } + } + + /// Default annotation stroke width + var strokeWidth: CGFloat { + didSet { save(Double(strokeWidth), forKey: Keys.strokeWidth) } + } + + /// Default text annotation font size + var textSize: CGFloat { + didSet { save(Double(textSize), forKey: Keys.textSize) } + } + + /// Whether rectangles are filled (solid) by default + var rectangleFilled: Bool { + didSet { save(rectangleFilled, forKey: Keys.rectangleFilled) } + } + + /// Whether ellipses are filled (solid) by default + var ellipseFilled: Bool { + didSet { save(ellipseFilled, forKey: Keys.ellipseFilled) } + } + + /// Default mosaic block size (pixelation level) + var mosaicBlockSize: CGFloat { + didSet { save(Double(mosaicBlockSize), forKey: Keys.mosaicBlockSize) } + } + + /// Translation target language (nil = use system default) + var translationTargetLanguage: TranslationLanguage? { + didSet { + if let language = translationTargetLanguage { + save(language.rawValue, forKey: Keys.translationTargetLanguage) + } else { + UserDefaults.standard.removeObject(forKey: Keys.translationTargetLanguage) + } + } + } + + /// Translation source language (.auto for automatic detection) + var translationSourceLanguage: TranslationLanguage { + didSet { save(translationSourceLanguage.rawValue, forKey: Keys.translationSourceLanguage) } + } + + /// Whether to automatically detect source language + var translationAutoDetect: Bool { + didSet { save(translationAutoDetect, forKey: Keys.translationAutoDetect) } + } + + /// OCR engine type + var ocrEngine: OCREngineType { + didSet { save(ocrEngine.rawValue, forKey: Keys.ocrEngine) } + } + + /// Translation engine type + var translationEngine: TranslationEngineType { + didSet { save(translationEngine.rawValue, forKey: Keys.translationEngine) } + } + + /// Translation display mode + var translationMode: TranslationMode { + didSet { save(translationMode.rawValue, forKey: Keys.translationMode) } + } + + /// Whether the user has completed the first launch onboarding + var onboardingCompleted: Bool { + didSet { save(onboardingCompleted, forKey: Keys.onboardingCompleted) } + } + + var paddleOCRServerAddress: String { + didSet { save(paddleOCRServerAddress, forKey: Keys.paddleOCRServerAddress) } + } + + var mtranServerHost: String { + didSet { save(mtranServerHost, forKey: Keys.mtranServerHost) } + } + + var mtranServerPort: Int { + didSet { save(mtranServerPort, forKey: Keys.mtranServerPort) } + } + + // MARK: - VLM Configuration + + var vlmProvider: VLMProviderType { + didSet { save(vlmProvider.rawValue, forKey: Keys.vlmProvider) } + } + + var vlmAPIKey: String { + didSet { save(vlmAPIKey, forKey: Keys.vlmAPIKey) } + } + + var vlmBaseURL: String { + didSet { + save(vlmBaseURL, forKey: Keys.vlmBaseURL) + saveGLMOCRVLMValue( + vlmBaseURL, + cloudKey: Keys.glmOCRCloudBaseURL, + localKey: Keys.glmOCRLocalBaseURL + ) + } + } + + var vlmModelName: String { + didSet { + save(vlmModelName, forKey: Keys.vlmModelName) + saveGLMOCRVLMValue( + vlmModelName, + cloudKey: Keys.glmOCRCloudModelName, + localKey: Keys.glmOCRLocalModelName + ) + } + } + + var glmOCRMode: GLMOCRMode { + didSet { save(glmOCRMode.rawValue, forKey: Keys.glmOCRMode) } + } + + // MARK: - Translation Workflow Configuration + + var preferredTranslationEngine: PreferredTranslationEngine { + didSet { save(preferredTranslationEngine.rawValue, forKey: Keys.preferredTranslationEngine) } + } + + var mtranServerURL: String { + didSet { save(mtranServerURL, forKey: Keys.mtranServerURL) } + } + + var translationFallbackEnabled: Bool { + didSet { save(translationFallbackEnabled, forKey: Keys.translationFallbackEnabled) } + } + + // MARK: - Translate and Insert Language Configuration + + /// Source language for translate and insert (default: auto-detect) + var translateAndInsertSourceLanguage: TranslationLanguage { + didSet { save(translateAndInsertSourceLanguage.rawValue, forKey: Keys.translateAndInsertSourceLanguage) } + } + + /// Target language for translate and insert (nil = follow system) + var translateAndInsertTargetLanguage: TranslationLanguage? { + didSet { + if let language = translateAndInsertTargetLanguage { + save(language.rawValue, forKey: Keys.translateAndInsertTargetLanguage) + } else { + UserDefaults.standard.removeObject(forKey: Keys.translateAndInsertTargetLanguage) + } + } + } + + // MARK: - Multi-Engine Configuration + + /// Engine selection mode + var engineSelectionMode: EngineSelectionMode { + didSet { save(engineSelectionMode.rawValue, forKey: Keys.engineSelectionMode) } + } + + /// Engine configurations (JSON encoded) + /// Note: no didSet because @Observable does not reliably trigger + /// didSet for value-type mutations, which can cause stale data to overwrite + /// fresh saves. Always call saveEngineConfigs() explicitly after modifying. + var engineConfigs: [TranslationEngineType: TranslationEngineConfig] + + /// Prompt configuration + var promptConfig: TranslationPromptConfig { + didSet { savePromptConfig() } + } + + /// Scene-to-engine bindings + var sceneBindings: [TranslationScene: SceneEngineBinding] { + didSet { saveSceneBindings() } + } + + /// Engines to run in parallel mode + var parallelEngines: [TranslationEngineType] { + didSet { saveParallelEngines() } + } + + /// Compatible provider configurations + var compatibleProviderConfigs: [CompatibleTranslationProvider.CompatibleConfig] { + didSet { saveCompatibleConfigs() } + } + + // MARK: - PaddleOCR Configuration + + /// PaddleOCR mode: fast (ocr command) or precise (doc_parser VL-1.5) + var paddleOCRMode: PaddleOCRMode { + didSet { save(paddleOCRMode.rawValue, forKey: Keys.paddleOCRMode) } + } + + /// Whether to use cloud API instead of local CLI + var paddleOCRUseCloud: Bool { + didSet { save(paddleOCRUseCloud, forKey: Keys.paddleOCRUseCloud) } + } + + /// Cloud API base URL (for third-party PaddleOCR cloud services) + var paddleOCRCloudBaseURL: String { + didSet { save(paddleOCRCloudBaseURL, forKey: Keys.paddleOCRCloudBaseURL) } + } + + /// Cloud API key (stored securely in Keychain, not UserDefaults) + var paddleOCRCloudAPIKey: String { + didSet { + // Capture the value on the actor before spawning detached task + let capturedKey = paddleOCRCloudAPIKey + // Save to Keychain asynchronously + Task.detached { + do { + try await KeychainService.shared.savePaddleOCRCredentials(apiKey: capturedKey) + } catch { + Logger.settings.error("Failed to save PaddleOCR cloud API key to Keychain: \(error)") + } + } + } + } + + /// Cloud API model ID (optional, for specifying which model to use) + var paddleOCRCloudModelId: String { + didSet { save(paddleOCRCloudModelId, forKey: Keys.paddleOCRCloudModelId) } + } + + /// Local VL model directory (for vllm backend) + var paddleOCRLocalVLModelDir: String { + didSet { save(paddleOCRLocalVLModelDir, forKey: Keys.paddleOCRLocalVLModelDir) } + } + + // MARK: - Initialization + + private init() { + let defaults = UserDefaults.standard + + // Load save location from bookmark first, then path, or use Desktop + let loadedLocation: URL + if let bookmarkData = defaults.data(forKey: "SaveLocationBookmark"), + let url = Self.resolveBookmark(bookmarkData) { + loadedLocation = url + } else if let path = defaults.string(forKey: Keys.saveLocation) { + loadedLocation = URL(fileURLWithPath: path) + } else { + loadedLocation = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory()) + } + saveLocation = loadedLocation + + // Load format + if let formatRaw = defaults.string(forKey: Keys.defaultFormat), + let format = ExportFormat(rawValue: formatRaw) { + defaultFormat = format + } else { + defaultFormat = .png + } + + // Load JPEG quality + jpegQuality = defaults.object(forKey: Keys.jpegQuality) as? Double ?? 0.9 + + // Load HEIC quality + heicQuality = defaults.object(forKey: Keys.heicQuality) as? Double ?? 0.9 + + // Load shortcuts + fullScreenShortcut = Self.loadShortcut(forKey: Keys.fullScreenShortcut) + ?? KeyboardShortcut.fullScreenDefault + selectionShortcut = Self.loadShortcut(forKey: Keys.selectionShortcut) + ?? KeyboardShortcut.selectionDefault + translationModeShortcut = Self.loadShortcut(forKey: Keys.translationModeShortcut) + ?? KeyboardShortcut.translationModeDefault + textSelectionTranslationShortcut = Self.loadShortcut(forKey: Keys.textSelectionTranslationShortcut) + ?? KeyboardShortcut.textSelectionTranslationDefault + translateAndInsertShortcut = Self.loadShortcut(forKey: Keys.translateAndInsertShortcut) + ?? KeyboardShortcut.translateAndInsertDefault + + // Load annotation defaults + strokeColor = Self.loadColor(forKey: Keys.strokeColor) ?? .red + strokeWidth = CGFloat(defaults.object(forKey: Keys.strokeWidth) as? Double ?? 2.0) + textSize = CGFloat(defaults.object(forKey: Keys.textSize) as? Double ?? 14.0) + rectangleFilled = defaults.object(forKey: Keys.rectangleFilled) as? Bool ?? false + ellipseFilled = defaults.object(forKey: Keys.ellipseFilled) as? Bool ?? false + mosaicBlockSize = CGFloat(defaults.object(forKey: Keys.mosaicBlockSize) as? Double ?? 10.0) + + // Load translation settings + translationTargetLanguage = defaults.string(forKey: Keys.translationTargetLanguage) + .flatMap { TranslationLanguage(rawValue: $0) } + translationSourceLanguage = defaults.string(forKey: Keys.translationSourceLanguage) + .flatMap { TranslationLanguage(rawValue: $0) } ?? .auto + translationAutoDetect = defaults.object(forKey: Keys.translationAutoDetect) as? Bool ?? true + + // Load engine settings + ocrEngine = defaults.string(forKey: Keys.ocrEngine) + .flatMap { OCREngineType(rawValue: $0) } ?? .vision + translationEngine = defaults.string(forKey: Keys.translationEngine) + .flatMap { TranslationEngineType(rawValue: $0) } ?? .apple + translationMode = defaults.string(forKey: Keys.translationMode) + .flatMap { TranslationMode(rawValue: $0) } ?? .below + onboardingCompleted = defaults.object(forKey: Keys.onboardingCompleted) as? Bool ?? false + paddleOCRServerAddress = defaults.string(forKey: Keys.paddleOCRServerAddress) ?? "" + mtranServerHost = defaults.string(forKey: Keys.mtranServerHost) ?? "localhost" + mtranServerPort = defaults.object(forKey: Keys.mtranServerPort) as? Int ?? 8989 + + let resolvedVLMProvider = defaults.string(forKey: Keys.vlmProvider) + .flatMap { VLMProviderType(rawValue: $0) } ?? .openai + let resolvedGLMOCRMode = defaults.string(forKey: Keys.glmOCRMode) + .flatMap { GLMOCRMode(rawValue: $0) } ?? .cloud + vlmProvider = resolvedVLMProvider + vlmAPIKey = defaults.string(forKey: Keys.vlmAPIKey) ?? "" + glmOCRMode = resolvedGLMOCRMode + if resolvedVLMProvider == .glmOCR { + let modeSpecificBaseURLKey = resolvedGLMOCRMode == .cloud ? Keys.glmOCRCloudBaseURL : Keys.glmOCRLocalBaseURL + let modeSpecificModelKey = resolvedGLMOCRMode == .cloud ? Keys.glmOCRCloudModelName : Keys.glmOCRLocalModelName + vlmBaseURL = defaults.string(forKey: modeSpecificBaseURLKey) ?? defaults.string(forKey: Keys.vlmBaseURL) ?? resolvedVLMProvider.defaultBaseURL(glmOCRMode: resolvedGLMOCRMode) + vlmModelName = defaults.string(forKey: modeSpecificModelKey) ?? defaults.string(forKey: Keys.vlmModelName) ?? resolvedVLMProvider.defaultModelName(glmOCRMode: resolvedGLMOCRMode) + } else { + vlmBaseURL = defaults.string(forKey: Keys.vlmBaseURL) ?? resolvedVLMProvider.defaultBaseURL(glmOCRMode: resolvedGLMOCRMode) + vlmModelName = defaults.string(forKey: Keys.vlmModelName) ?? resolvedVLMProvider.defaultModelName(glmOCRMode: resolvedGLMOCRMode) + } + + preferredTranslationEngine = defaults.string(forKey: Keys.preferredTranslationEngine) + .flatMap { PreferredTranslationEngine(rawValue: $0) } ?? .apple + mtranServerURL = defaults.string(forKey: Keys.mtranServerURL) ?? "http://localhost:8989" + translationFallbackEnabled = defaults.object(forKey: Keys.translationFallbackEnabled) as? Bool ?? true + + // Load translate and insert language settings + translateAndInsertSourceLanguage = defaults.string(forKey: Keys.translateAndInsertSourceLanguage) + .flatMap { TranslationLanguage(rawValue: $0) } ?? .auto + translateAndInsertTargetLanguage = defaults.string(forKey: Keys.translateAndInsertTargetLanguage) + .flatMap { TranslationLanguage(rawValue: $0) } + + // Load multi-engine configuration + engineSelectionMode = defaults.string(forKey: Keys.engineSelectionMode) + .flatMap { EngineSelectionMode(rawValue: $0) } ?? .primaryWithFallback + + engineConfigs = Self.loadEngineConfigs() + let loadedCompatibleProviderConfigs = Self.loadCompatibleConfigs() + compatibleProviderConfigs = loadedCompatibleProviderConfigs + promptConfig = Self.loadPromptConfig(compatibleConfigs: loadedCompatibleProviderConfigs) + sceneBindings = Self.loadSceneBindings() + parallelEngines = Self.loadParallelEngines() + + // Load PaddleOCR configuration + paddleOCRMode = defaults.string(forKey: Keys.paddleOCRMode) + .flatMap { PaddleOCRMode(rawValue: $0) } ?? .fast + paddleOCRUseCloud = defaults.object(forKey: Keys.paddleOCRUseCloud) as? Bool ?? false + paddleOCRCloudBaseURL = defaults.string(forKey: Keys.paddleOCRCloudBaseURL) ?? "" + + // Load PaddleOCR cloud API key from Keychain (secure storage) + paddleOCRCloudAPIKey = Self.loadPaddleOCRAPIKeyFromKeychain() + + // Load cloud model ID + paddleOCRCloudModelId = defaults.string(forKey: Keys.paddleOCRCloudModelId) ?? "" + + // Load vLLM model directory + paddleOCRLocalVLModelDir = defaults.string(forKey: Keys.paddleOCRLocalVLModelDir) ?? "" + + Logger.settings.info("ScreenCapture launched - settings loaded from: \(loadedLocation.path)") + } + + // MARK: - Computed Properties + + /// Default stroke style based on current settings + var defaultStrokeStyle: StrokeStyle { + StrokeStyle(color: strokeColor, lineWidth: strokeWidth) + } + + /// Default text style based on current settings + var defaultTextStyle: TextStyle { + TextStyle(color: strokeColor, fontSize: textSize, fontName: ".AppleSystemUIFont") + } + + // MARK: - Reset + + /// Resets all settings to defaults + func resetToDefaults() { + let defaults = UserDefaults.standard + saveLocation = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory()) + defaultFormat = .png + jpegQuality = 0.9 + heicQuality = 0.9 + fullScreenShortcut = .fullScreenDefault + selectionShortcut = .selectionDefault + translationModeShortcut = .translationModeDefault + textSelectionTranslationShortcut = .textSelectionTranslationDefault + translateAndInsertShortcut = .translateAndInsertDefault + strokeColor = .red + strokeWidth = 2.0 + textSize = 14.0 + rectangleFilled = false + ellipseFilled = false + mosaicBlockSize = 10.0 + translationTargetLanguage = nil + translationSourceLanguage = .auto + translationAutoDetect = true + ocrEngine = .vision + translationEngine = .apple + translationMode = .below + vlmProvider = .openai + vlmAPIKey = "" + glmOCRMode = .cloud + vlmBaseURL = VLMProviderType.openai.defaultBaseURL + vlmModelName = VLMProviderType.openai.defaultModelName + defaults.set(GLMOCRMode.cloud.defaultBaseURL, forKey: Keys.glmOCRCloudBaseURL) + defaults.set(GLMOCRMode.cloud.defaultModelName, forKey: Keys.glmOCRCloudModelName) + defaults.set(GLMOCRMode.local.defaultBaseURL, forKey: Keys.glmOCRLocalBaseURL) + defaults.set(GLMOCRMode.local.defaultModelName, forKey: Keys.glmOCRLocalModelName) + onboardingCompleted = false + translateAndInsertSourceLanguage = .auto + translateAndInsertTargetLanguage = nil + // Reset PaddleOCR settings + paddleOCRMode = .fast + paddleOCRUseCloud = false + paddleOCRCloudBaseURL = "" + paddleOCRCloudAPIKey = "" + paddleOCRCloudModelId = "" + // Delete PaddleOCR cloud API key from Keychain + Task.detached { + do { + try await KeychainService.shared.deletePaddleOCRCredentials() + } catch { + Logger.settings.error("Failed to delete PaddleOCR credentials from keychain: \(error.localizedDescription)") + } + } + // Reset vLLM model directory + paddleOCRLocalVLModelDir = "" + // Reset multi-engine configuration - directly create defaults, don't load from persistence + engineSelectionMode = .primaryWithFallback + var defaultConfigs: [TranslationEngineType: TranslationEngineConfig] = [:] + for type in TranslationEngineType.allCases { + defaultConfigs[type] = .default(for: type) + } + engineConfigs = defaultConfigs + saveEngineConfigs() + promptConfig = TranslationPromptConfig() + sceneBindings = SceneEngineBinding.allDefaults + parallelEngines = [.apple] + compatibleProviderConfigs = [] + } + + func storedGLMOCRBaseURL(for mode: GLMOCRMode) -> String? { + let key = mode == .cloud ? Keys.glmOCRCloudBaseURL : Keys.glmOCRLocalBaseURL + return UserDefaults.standard.string(forKey: key) + } + + func storedGLMOCRModelName(for mode: GLMOCRMode) -> String? { + let key = mode == .cloud ? Keys.glmOCRCloudModelName : Keys.glmOCRLocalModelName + return UserDefaults.standard.string(forKey: key) + } + + // MARK: - Notifications + + /// Posted when any keyboard shortcut is changed + static let shortcutDidChangeNotification = Notification.Name("AppSettings.shortcutDidChange") + + // MARK: - Persistence Helpers + // Made internal so SettingsViewModel can call them explicitly + // to guarantee persistence (since @Observable does not reliably + // trigger didSet for value-type subscript mutations). + + func save(_ value: Any, forKey key: String) { + UserDefaults.standard.set(value, forKey: key) + } + + private func saveGLMOCRVLMValue(_ value: String, cloudKey: String, localKey: String) { + guard vlmProvider == .glmOCR else { + return + } + + switch glmOCRMode { + case .cloud: + save(value, forKey: cloudKey) + case .local: + save(value, forKey: localKey) + } + } + + func saveShortcut(_ shortcut: KeyboardShortcut, forKey key: String) { + let data: [String: UInt32] = [ + "keyCode": shortcut.keyCode, + "modifiers": shortcut.modifiers + ] + UserDefaults.standard.set(data, forKey: key) + NotificationCenter.default.post(name: Self.shortcutDidChangeNotification, object: nil) + } + + private static func loadShortcut(forKey key: String) -> KeyboardShortcut? { + guard let data = UserDefaults.standard.dictionary(forKey: key) as? [String: UInt32], + let keyCode = data["keyCode"], + let modifiers = data["modifiers"] else { + return nil + } + return KeyboardShortcut(keyCode: keyCode, modifiers: modifiers) + } + + func saveColor(_ color: CodableColor, forKey key: String) { + if let data = try? JSONEncoder().encode(color) { + UserDefaults.standard.set(data, forKey: key) + } + } + + private static func loadColor(forKey key: String) -> CodableColor? { + guard let data = UserDefaults.standard.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(CodableColor.self, from: data) + } + + // MARK: - Keychain Helpers + + /// Load PaddleOCR cloud API key from Keychain synchronously + private static func loadPaddleOCRAPIKeyFromKeychain() -> String { + // Use shared constants from KeychainService + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: KeychainService.serviceIdentifier, + kSecAttrAccount as String: KeychainService.paddleOCRAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data, + let credentials = try? JSONDecoder().decode(StoredCredentials.self, from: data) else { + return "" + } + + return credentials.apiKey + } + + // MARK: - Multi-Engine Persistence Helpers + + /// Explicitly save engine configs to UserDefaults. + /// Call this after modifying `engineConfigs` to ensure persistence, + /// since `@Observable` does not guarantee `didSet` fires for value-type subscript mutations. + func saveEngineConfigs() { + let configArray = Array(engineConfigs.values) + Logger.settings.info("Saving engine configs: \(configArray.map { "\($0.id.rawValue)=\($0.isEnabled)" })") + if let data = try? JSONEncoder().encode(configArray) { + UserDefaults.standard.set(data, forKey: Keys.engineConfigs) + Logger.settings.info("Engine configs saved (\(data.count) bytes)") + } else { + Logger.settings.error("Failed to encode engine configs") + } + } + + private static func loadEngineConfigs() -> [TranslationEngineType: TranslationEngineConfig] { + // Start with defaults + var result: [TranslationEngineType: TranslationEngineConfig] = [:] + for type in TranslationEngineType.allCases { + result[type] = .default(for: type) + } + + // Load saved configs and merge + let data = UserDefaults.standard.data(forKey: Keys.engineConfigs) + Logger.settings.info("Loading engine configs from UserDefaults: data exists = \(data != nil), size = \(data?.count ?? 0)") + guard let data, + let configs = try? JSONDecoder().decode([TranslationEngineConfig].self, from: data) else { + Logger.settings.warning("No saved engine configs found, using defaults") + return result + } + Logger.settings.info("Decoded engine configs: \(configs.map { "\($0.id.rawValue)=\($0.isEnabled)" })") + + // Merge loaded configs over defaults + result = configs.reduce(into: result) { dict, config in + dict[config.id] = config + } + + return result + } + + private func savePromptConfig() { + if let data = try? JSONEncoder().encode(promptConfig) { + UserDefaults.standard.set(data, forKey: Keys.promptConfig) + } + } + + private static func loadPromptConfig( + compatibleConfigs: [CompatibleTranslationProvider.CompatibleConfig] + ) -> TranslationPromptConfig { + guard let data = UserDefaults.standard.data(forKey: Keys.promptConfig) else { + return TranslationPromptConfig() + } + + if let config = try? JSONDecoder().decode(TranslationPromptConfig.self, from: data) { + let migratedCompatiblePrompts = migrateCompatiblePromptKeys( + config.compatibleEnginePrompts, + compatibleConfigs: compatibleConfigs + ) + if migratedCompatiblePrompts == config.compatibleEnginePrompts { + return config + } + + return TranslationPromptConfig( + enginePrompts: config.enginePrompts, + compatibleEnginePrompts: migratedCompatiblePrompts, + scenePrompts: config.scenePrompts + ) + } + + struct LegacyTranslationPromptConfig: Decodable { + var enginePrompts: [TranslationEngineType: String] + var compatibleEnginePrompts: [Int: String] + var scenePrompts: [TranslationScene: String] + } + + guard let legacyConfig = try? JSONDecoder().decode(LegacyTranslationPromptConfig.self, from: data) else { + return TranslationPromptConfig() + } + + let migratedCompatiblePrompts = legacyConfig.compatibleEnginePrompts.reduce(into: [String: String]()) { + result, + entry in + let (index, prompt) = entry + guard compatibleConfigs.indices.contains(index) else { return } + result[compatibleConfigs[index].id.uuidString] = prompt + } + + return TranslationPromptConfig( + enginePrompts: legacyConfig.enginePrompts, + compatibleEnginePrompts: migratedCompatiblePrompts, + scenePrompts: legacyConfig.scenePrompts + ) + } + + private static func migrateCompatiblePromptKeys( + _ prompts: [String: String], + compatibleConfigs: [CompatibleTranslationProvider.CompatibleConfig] + ) -> [String: String] { + prompts.reduce(into: [String: String]()) { result, entry in + let (key, value) = entry + if let index = Int(key), compatibleConfigs.indices.contains(index) { + result[compatibleConfigs[index].id.uuidString] = value + } else { + result[key] = value + } + } + } + + /// Explicitly save scene bindings to UserDefaults. + func saveSceneBindings() { + let bindingArray = Array(sceneBindings.values) + if let data = try? JSONEncoder().encode(bindingArray) { + UserDefaults.standard.set(data, forKey: Keys.sceneBindings) + } + } + + private static func loadSceneBindings() -> [TranslationScene: SceneEngineBinding] { + // Start with defaults + var result = SceneEngineBinding.allDefaults + + // Load saved bindings and merge + guard let data = UserDefaults.standard.data(forKey: Keys.sceneBindings), + let bindings = try? JSONDecoder().decode([SceneEngineBinding].self, from: data) else { + return result + } + + // Merge loaded bindings over defaults + result = bindings.reduce(into: result) { dict, binding in + dict[binding.scene] = binding + } + + return result + } + + /// Explicitly save parallel engines to UserDefaults. + func saveParallelEngines() { + let rawValues = parallelEngines.map { $0.rawValue } + UserDefaults.standard.set(rawValues, forKey: Keys.parallelEngines) + } + + private static func loadParallelEngines() -> [TranslationEngineType] { + guard let rawValues = UserDefaults.standard.array(forKey: Keys.parallelEngines) as? [String] else { + return [.apple] + } + let engines = rawValues.compactMap { TranslationEngineType(rawValue: $0) } + // Return default if result is empty (dirty data case) + return engines.isEmpty ? [.apple] : engines + } + + /// Explicitly save compatible provider configs to UserDefaults. + func saveCompatibleConfigs() { + if let data = try? JSONEncoder().encode(compatibleProviderConfigs) { + UserDefaults.standard.set(data, forKey: Keys.compatibleProviderConfigs) + } + } + + private static func loadCompatibleConfigs() -> [CompatibleTranslationProvider.CompatibleConfig] { + guard let data = UserDefaults.standard.data(forKey: Keys.compatibleProviderConfigs), + let configs = try? JSONDecoder().decode([CompatibleTranslationProvider.CompatibleConfig].self, from: data) else { + return [] + } + return configs + } + + /// Resolves a security-scoped bookmark to a URL + private static func resolveBookmark(_ bookmarkData: Data) -> URL? { + var isStale = false + do { + let url = try URL( + resolvingBookmarkData: bookmarkData, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + + // Start accessing the security-scoped resource + if url.startAccessingSecurityScopedResource() { + // Note: We don't call stopAccessingSecurityScopedResource() + // because we need ongoing access throughout the app's lifetime + return url + } + return url + } catch { + Logger.settings.error("Failed to resolve bookmark: \(error.localizedDescription)") + return nil + } + } +} diff --git a/ScreenTranslate/Models/BilingualSegment.swift b/ScreenTranslate/Models/BilingualSegment.swift new file mode 100644 index 0000000..da7a3b2 --- /dev/null +++ b/ScreenTranslate/Models/BilingualSegment.swift @@ -0,0 +1,249 @@ +import CoreGraphics +import Foundation +import SwiftUI + +// MARK: - BilingualSegment + +/// Represents a text segment with both original and translated content. +/// Used for bilingual overlay rendering. +struct BilingualSegment: Sendable, Equatable, Identifiable { + let id: UUID + let original: TextSegment + let translated: String + let sourceLanguage: String? + let targetLanguage: String + + init( + id: UUID = UUID(), + original: TextSegment, + translated: String, + sourceLanguage: String? = nil, + targetLanguage: String + ) { + self.id = id + self.original = original + self.translated = translated + self.sourceLanguage = sourceLanguage + self.targetLanguage = targetLanguage + } + + /// Convenience initializer from TranslationResult (creates a TextSegment with empty bounding box) + init(from result: TranslationResult) { + self.id = UUID() + self.original = TextSegment( + text: result.sourceText, + boundingBox: .zero, + confidence: 1.0 + ) + self.translated = result.translatedText + self.sourceLanguage = result.sourceLanguage + self.targetLanguage = result.targetLanguage + } + + /// Convenience initializer pairing a TextSegment with its translation + init(segment: TextSegment, translatedText: String, sourceLanguage: String? = nil, targetLanguage: String) { + self.id = segment.id + self.original = segment + self.translated = translatedText + self.sourceLanguage = sourceLanguage + self.targetLanguage = targetLanguage + } +} + +// MARK: - BilingualSegment Utilities + +extension BilingualSegment { + /// The original text content + var sourceText: String { + original.text + } + + /// The bounding box of the original text (normalized coordinates) + var boundingBox: CGRect { + original.boundingBox + } + + /// Returns the pixel bounding box for the original text + func pixelBoundingBox(in imageSize: CGSize) -> CGRect { + original.pixelBoundingBox(in: imageSize) + } +} + +// MARK: - OverlayStyle + +/// Styling configuration for translation overlay rendering. +/// Controls how translated text appears over the original content. +struct OverlayStyle: Sendable, Equatable, Codable { + /// Font for displaying translated text + var translationFont: TranslationFont + + /// Color of the translated text + var translationColor: CodableColor + + /// Background color behind the translated text (supports transparency) + var backgroundColor: CodableColor + + /// Padding around the translated text in points + var padding: EdgePadding + + /// Default overlay style with readable defaults + static let `default` = OverlayStyle( + translationFont: .default, + translationColor: CodableColor(.white), + backgroundColor: CodableColor(red: 0.0, green: 0.0, blue: 0.0, opacity: 0.75), + padding: .default + ) + + /// Dark mode optimized style + static let dark = OverlayStyle( + translationFont: .default, + translationColor: CodableColor(.white), + backgroundColor: CodableColor(red: 0.1, green: 0.1, blue: 0.1, opacity: 0.85), + padding: .default + ) + + /// Minimal style with transparent background + static let minimal = OverlayStyle( + translationFont: TranslationFont(size: 12, weight: .regular), + translationColor: CodableColor(.black), + backgroundColor: CodableColor(red: 0, green: 0, blue: 0, opacity: 0), + padding: EdgePadding(top: 2, leading: 4, bottom: 2, trailing: 4) + ) +} + +// MARK: - TranslationFont + +/// Font configuration for translation overlay text +struct TranslationFont: Sendable, Equatable, Codable { + /// Font size in points (8.0...48.0) + var size: CGFloat + + /// Font weight + var weight: FontWeight + + /// Optional custom font family name (nil uses system font) + var fontName: String? + + /// Default translation font (14pt, medium weight, system font) + static let `default` = TranslationFont(size: 14, weight: .medium) + + init(size: CGFloat, weight: FontWeight = .regular, fontName: String? = nil) { + self.size = min(max(size, 8.0), 48.0) + self.weight = weight + self.fontName = fontName + } + + /// The SwiftUI Font representation + var font: Font { + if let fontName = fontName, !fontName.isEmpty { + return .custom(fontName, size: size) + } + return .system(size: size, weight: weight.swiftUIWeight) + } + + /// The NSFont representation (non-isolated for use in rendering context) + func makeNSFont() -> NSFont { + if let fontName = fontName, let font = NSFont(name: fontName, size: size) { + return font + } + return NSFont.systemFont(ofSize: size, weight: weight.nsWeight) + } +} + +// MARK: - FontWeight + +/// Font weight options for translation text +enum FontWeight: String, Sendable, Codable, CaseIterable { + case ultraLight + case thin + case light + case regular + case medium + case semibold + case bold + case heavy + case black + + var swiftUIWeight: Font.Weight { + switch self { + case .ultraLight: return .ultraLight + case .thin: return .thin + case .light: return .light + case .regular: return .regular + case .medium: return .medium + case .semibold: return .semibold + case .bold: return .bold + case .heavy: return .heavy + case .black: return .black + } + } + + var nsWeight: NSFont.Weight { + switch self { + case .ultraLight: return .ultraLight + case .thin: return .thin + case .light: return .light + case .regular: return .regular + case .medium: return .medium + case .semibold: return .semibold + case .bold: return .bold + case .heavy: return .heavy + case .black: return .black + } + } +} + +// MARK: - EdgePadding + +/// Padding configuration for overlay content +struct EdgePadding: Sendable, Equatable, Codable { + var top: CGFloat + var leading: CGFloat + var bottom: CGFloat + var trailing: CGFloat + + /// Default padding (4pt vertical, 8pt horizontal) + static let `default` = EdgePadding(top: 4, leading: 8, bottom: 4, trailing: 8) + + /// Zero padding + static let zero = EdgePadding(top: 0, leading: 0, bottom: 0, trailing: 0) + + /// Uniform padding on all sides + init(all: CGFloat) { + self.top = all + self.leading = all + self.bottom = all + self.trailing = all + } + + /// Custom padding per edge + init(top: CGFloat, leading: CGFloat, bottom: CGFloat, trailing: CGFloat) { + self.top = top + self.leading = leading + self.bottom = bottom + self.trailing = trailing + } + + /// Horizontal + vertical padding + init(horizontal: CGFloat, vertical: CGFloat) { + self.top = vertical + self.leading = horizontal + self.bottom = vertical + self.trailing = horizontal + } + + /// SwiftUI EdgeInsets representation + var edgeInsets: EdgeInsets { + EdgeInsets(top: top, leading: leading, bottom: bottom, trailing: trailing) + } + + /// Total horizontal padding + var horizontal: CGFloat { + leading + trailing + } + + /// Total vertical padding + var vertical: CGFloat { + top + bottom + } +} diff --git a/ScreenCapture/Models/DisplayInfo.swift b/ScreenTranslate/Models/DisplayInfo.swift similarity index 75% rename from ScreenCapture/Models/DisplayInfo.swift rename to ScreenTranslate/Models/DisplayInfo.swift index ad4b532..4377b83 100644 --- a/ScreenCapture/Models/DisplayInfo.swift +++ b/ScreenTranslate/Models/DisplayInfo.swift @@ -63,7 +63,24 @@ struct DisplayInfo: Identifiable, Hashable, Sendable { self.isPrimary = screen == NSScreen.main } else { self.name = "Display \(scDisplay.displayID)" - self.scaleFactor = 1.0 + + // Fallback 1: Calculate scaleFactor from pixel vs point dimensions + // scDisplay.width/height are pixels, frame.width/height are points + let pixelWidth = CGFloat(scDisplay.width) + let pointWidth = scDisplay.frame.width + + if pointWidth > 0 { + self.scaleFactor = pixelWidth / pointWidth + } else { + // Fallback 2: Query via CoreGraphics mode + if let mode = CGDisplayCopyDisplayMode(scDisplay.displayID) { + self.scaleFactor = CGFloat(mode.pixelWidth) / CGFloat(mode.width) + } else { + // Final fallback: default to Retina (2.0) for modern Macs + self.scaleFactor = 2.0 + } + } + self.isPrimary = CGDisplayIsMain(scDisplay.displayID) != 0 } } @@ -101,7 +118,9 @@ extension DisplayInfo { @MainActor var matchingScreen: NSScreen? { NSScreen.screens.first { screen in - guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else { + let deviceDescription = screen.deviceDescription + let screenNumberKey = NSDeviceDescriptionKey("NSScreenNumber") + guard let screenNumber = deviceDescription[screenNumberKey] as? CGDirectDisplayID else { return false } return screenNumber == id diff --git a/ScreenTranslate/Models/EngineSelectionMode.swift b/ScreenTranslate/Models/EngineSelectionMode.swift new file mode 100644 index 0000000..3902956 --- /dev/null +++ b/ScreenTranslate/Models/EngineSelectionMode.swift @@ -0,0 +1,91 @@ +// +// EngineSelectionMode.swift +// ScreenTranslate +// +// Multi-translation engine selection modes +// + +import Foundation + +/// Engine selection mode for multi-engine translation +enum EngineSelectionMode: String, Codable, CaseIterable, Identifiable, Sendable { + /// Primary engine with fallback on failure + case primaryWithFallback = "primary_fallback" + + /// Run multiple engines in parallel + case parallel = "parallel" + + /// Quick switch between engines (lazy load) + case quickSwitch = "quick_switch" + + /// Bind specific engines to translation scenes + case sceneBinding = "scene_binding" + + var id: String { rawValue } + + /// Localized display name + var localizedName: String { + switch self { + case .primaryWithFallback: + return NSLocalizedString( + "engine.selection.mode.primary_fallback", + comment: "Primary with Fallback" + ) + case .parallel: + return NSLocalizedString( + "engine.selection.mode.parallel", + comment: "Parallel" + ) + case .quickSwitch: + return NSLocalizedString( + "engine.selection.mode.quick_switch", + comment: "Quick Switch" + ) + case .sceneBinding: + return NSLocalizedString( + "engine.selection.mode.scene_binding", + comment: "Scene Binding" + ) + } + } + + /// Detailed description + var modeDescription: String { + switch self { + case .primaryWithFallback: + return NSLocalizedString( + "engine.selection.mode.primary_fallback.description", + comment: "Use primary engine, fall back to secondary on failure" + ) + case .parallel: + return NSLocalizedString( + "engine.selection.mode.parallel.description", + comment: "Run multiple engines simultaneously and compare results" + ) + case .quickSwitch: + return NSLocalizedString( + "engine.selection.mode.quick_switch.description", + comment: "Start with primary, quickly switch to other engines on demand" + ) + case .sceneBinding: + return NSLocalizedString( + "engine.selection.mode.scene_binding.description", + comment: "Use different engines for different translation scenarios" + ) + } + } + + /// Icon name for UI + var iconName: String { + switch self { + case .primaryWithFallback: + return "arrow.triangle.branch" + case .parallel: + return "arrow.triangle.merge" + case .quickSwitch: + return "arrow.left.arrow.right" + case .sceneBinding: + return "slider.horizontal.3" + } + } +} diff --git a/ScreenCapture/Models/ExportFormat.swift b/ScreenTranslate/Models/ExportFormat.swift similarity index 100% rename from ScreenCapture/Models/ExportFormat.swift rename to ScreenTranslate/Models/ExportFormat.swift diff --git a/ScreenCapture/Models/KeyboardShortcut.swift b/ScreenTranslate/Models/KeyboardShortcut.swift similarity index 67% rename from ScreenCapture/Models/KeyboardShortcut.swift rename to ScreenTranslate/Models/KeyboardShortcut.swift index c6761b0..a9fe5f2 100644 --- a/ScreenCapture/Models/KeyboardShortcut.swift +++ b/ScreenTranslate/Models/KeyboardShortcut.swift @@ -37,6 +37,24 @@ struct KeyboardShortcut: Equatable, Codable, Sendable { modifiers: UInt32(cmdKey | shiftKey) ) + /// Default translation mode shortcut: Command + Shift + T + static let translationModeDefault = KeyboardShortcut( + keyCode: UInt32(kVK_ANSI_T), + modifiers: UInt32(cmdKey | shiftKey) + ) + + /// Default text selection translation shortcut: Command + Shift + Y + static let textSelectionTranslationDefault = KeyboardShortcut( + keyCode: UInt32(kVK_ANSI_Y), + modifiers: UInt32(cmdKey | shiftKey) + ) + + /// Default translate and insert shortcut: Command + Shift + I + static let translateAndInsertDefault = KeyboardShortcut( + keyCode: UInt32(kVK_ANSI_I), + modifiers: UInt32(cmdKey | shiftKey) + ) + // MARK: - Validation /// Checks if the shortcut includes at least one modifier key @@ -47,7 +65,7 @@ struct KeyboardShortcut: Equatable, Codable, Sendable { /// Validates this shortcut configuration var isValid: Bool { - hasRequiredModifiers && keyCode != 0 + hasRequiredModifiers } // MARK: - Display @@ -144,63 +162,26 @@ struct KeyboardShortcut: Equatable, Codable, Sendable { // MARK: - Key Code to String + private static let keyCodeToStringMap: [Int: String] = [ + kVK_ANSI_0: "0", kVK_ANSI_1: "1", kVK_ANSI_2: "2", kVK_ANSI_3: "3", kVK_ANSI_4: "4", + kVK_ANSI_5: "5", kVK_ANSI_6: "6", kVK_ANSI_7: "7", kVK_ANSI_8: "8", kVK_ANSI_9: "9", + kVK_ANSI_A: "A", kVK_ANSI_B: "B", kVK_ANSI_C: "C", kVK_ANSI_D: "D", kVK_ANSI_E: "E", + kVK_ANSI_F: "F", kVK_ANSI_G: "G", kVK_ANSI_H: "H", kVK_ANSI_I: "I", kVK_ANSI_J: "J", + kVK_ANSI_K: "K", kVK_ANSI_L: "L", kVK_ANSI_M: "M", kVK_ANSI_N: "N", kVK_ANSI_O: "O", + kVK_ANSI_P: "P", kVK_ANSI_Q: "Q", kVK_ANSI_R: "R", kVK_ANSI_S: "S", kVK_ANSI_T: "T", + kVK_ANSI_U: "U", kVK_ANSI_V: "V", kVK_ANSI_W: "W", kVK_ANSI_X: "X", kVK_ANSI_Y: "Y", + kVK_ANSI_Z: "Z", kVK_F1: "F1", kVK_F2: "F2", kVK_F3: "F3", kVK_F4: "F4", kVK_F5: "F5", + kVK_F6: "F6", kVK_F7: "F7", kVK_F8: "F8", kVK_F9: "F9", kVK_F10: "F10", kVK_F11: "F11", + kVK_F12: "F12", kVK_Space: "Space", kVK_Return: "Return", kVK_Tab: "Tab" + ] + + /// The main key name for this shortcut + var mainKey: String { + Self.keyCodeToStringMap[Int(keyCode)] ?? "Key \(keyCode)" + } + /// Converts a virtual key code to its string representation private static func keyCodeToString(_ keyCode: UInt32) -> String? { - switch Int(keyCode) { - case kVK_ANSI_0: return "0" - case kVK_ANSI_1: return "1" - case kVK_ANSI_2: return "2" - case kVK_ANSI_3: return "3" - case kVK_ANSI_4: return "4" - case kVK_ANSI_5: return "5" - case kVK_ANSI_6: return "6" - case kVK_ANSI_7: return "7" - case kVK_ANSI_8: return "8" - case kVK_ANSI_9: return "9" - case kVK_ANSI_A: return "A" - case kVK_ANSI_B: return "B" - case kVK_ANSI_C: return "C" - case kVK_ANSI_D: return "D" - case kVK_ANSI_E: return "E" - case kVK_ANSI_F: return "F" - case kVK_ANSI_G: return "G" - case kVK_ANSI_H: return "H" - case kVK_ANSI_I: return "I" - case kVK_ANSI_J: return "J" - case kVK_ANSI_K: return "K" - case kVK_ANSI_L: return "L" - case kVK_ANSI_M: return "M" - case kVK_ANSI_N: return "N" - case kVK_ANSI_O: return "O" - case kVK_ANSI_P: return "P" - case kVK_ANSI_Q: return "Q" - case kVK_ANSI_R: return "R" - case kVK_ANSI_S: return "S" - case kVK_ANSI_T: return "T" - case kVK_ANSI_U: return "U" - case kVK_ANSI_V: return "V" - case kVK_ANSI_W: return "W" - case kVK_ANSI_X: return "X" - case kVK_ANSI_Y: return "Y" - case kVK_ANSI_Z: return "Z" - case kVK_F1: return "F1" - case kVK_F2: return "F2" - case kVK_F3: return "F3" - case kVK_F4: return "F4" - case kVK_F5: return "F5" - case kVK_F6: return "F6" - case kVK_F7: return "F7" - case kVK_F8: return "F8" - case kVK_F9: return "F9" - case kVK_F10: return "F10" - case kVK_F11: return "F11" - case kVK_F12: return "F12" - case kVK_Space: return "Space" - case kVK_Return: return "Return" - case kVK_Tab: return "Tab" - case kVK_Delete: return "Delete" - case kVK_Escape: return "Esc" - default: return nil - } + keyCodeToStringMap[Int(keyCode)] } } diff --git a/ScreenTranslate/Models/OCREngineType.swift b/ScreenTranslate/Models/OCREngineType.swift new file mode 100644 index 0000000..a60165f --- /dev/null +++ b/ScreenTranslate/Models/OCREngineType.swift @@ -0,0 +1,135 @@ +import Foundation +import os + +/// OCR engine types supported by the application +enum OCREngineType: String, CaseIterable, Sendable, Codable { + /// macOS native Vision framework (local, default) + case vision = "vision" + + /// PaddleOCR (optional, external) + case paddleOCR = "paddleocr" + + /// Localized display name + var localizedName: String { + switch self { + case .vision: + return NSLocalizedString("ocr.engine.vision", comment: "Vision (Local)") + case .paddleOCR: + return NSLocalizedString("ocr.engine.paddleocr", comment: "PaddleOCR") + } + } + + /// Description of the engine + var description: String { + switch self { + case .vision: + return NSLocalizedString( + "ocr.engine.vision.description", + comment: "Built-in macOS engine, no setup required" + ) + case .paddleOCR: + return NSLocalizedString( + "ocr.engine.paddleocr.description", + comment: "External OCR engine for enhanced accuracy" + ) + } + } + + /// Whether this engine is available + /// Vision is always available; PaddleOCR requires external setup + var isAvailable: Bool { + switch self { + case .vision: + return true + case .paddleOCR: + return PaddleOCRChecker.isAvailable + } + } +} + +// MARK: - PaddleOCR Availability Checker + +/// Helper to check if PaddleOCR is available on the system +enum PaddleOCRChecker { + private nonisolated(unsafe) static var _isAvailable: Bool = false + private nonisolated(unsafe) static var _executablePath: String? + private nonisolated(unsafe) static var _version: String? + private nonisolated(unsafe) static var _checkCompleted: Bool = false + + static var isAvailable: Bool { _isAvailable } + static var executablePath: String? { _executablePath } + static var version: String? { _version } + static var checkCompleted: Bool { _checkCompleted } + + static func checkAvailabilityAsync() { + Task.detached(priority: .userInitiated) { + let result = await performFullCheck() + _isAvailable = result.available + _executablePath = result.path + _version = result.version + _checkCompleted = true + } + } + + private static func performFullCheck() async -> (available: Bool, path: String?, version: String?) { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let possiblePaths = [ + "\(NSHomeDirectory())/.pyenv/shims/paddleocr", + "/usr/local/bin/paddleocr", + "/opt/homebrew/bin/paddleocr", + "\(NSHomeDirectory())/.local/bin/paddleocr" + ] + + Logger.ocr.debug("[PaddleOCRChecker] Checking paths: \(possiblePaths)") + + for path in possiblePaths where FileManager.default.isExecutableFile(atPath: path) { + Logger.ocr.debug("[PaddleOCRChecker] Found executable at: \(path)") + + let task = Process() + task.executableURL = URL(fileURLWithPath: path) + task.arguments = ["--version"] + task.environment = [ + "PATH": "\(NSHomeDirectory())/.pyenv/shims:/usr/local/bin:/usr/bin:/bin", + "HOME": NSHomeDirectory(), + "PYENV_ROOT": "\(NSHomeDirectory())/.pyenv", + "PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK": "True" + ] + + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + + do { + try task.run() + task.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + Logger.ocr.debug("Version output: \(output)") + + let versionLine = output.components(separatedBy: .newlines) + .first { $0.contains("paddleocr") }? + .trimmingCharacters(in: .whitespaces) + + Logger.ocr.info("Found: path=\(path), version=\(versionLine ?? "unknown")") + continuation.resume(returning: (true, path, versionLine)) + return + } catch { + Logger.ocr.error("Error running \(path): \(error.localizedDescription)") + } + } + + Logger.ocr.info("Not found in any known path") + continuation.resume(returning: (false, nil, nil)) + } + } + } + + static func resetCache() { + _isAvailable = false + _executablePath = nil + _version = nil + _checkCompleted = false + } +} diff --git a/ScreenTranslate/Models/OCRResult.swift b/ScreenTranslate/Models/OCRResult.swift new file mode 100644 index 0000000..7fdf9c3 --- /dev/null +++ b/ScreenTranslate/Models/OCRResult.swift @@ -0,0 +1,170 @@ +import Foundation +import CoreGraphics +import Vision + +/// The result of an OCR operation on an image. +/// Contains all recognized text with their positions and confidence scores. +struct OCRResult: Sendable { + /// All text observations found in the image + let observations: [OCRText] + + /// The source image dimensions (width x height in pixels) + let imageSize: CGSize + + /// When OCR was performed + let timestamp: Date + + /// Total number of text observations + var count: Int { + observations.count + } + + /// All recognized text concatenated with newlines + var fullText: String { + observations + .sorted { $0.boundingBox.minY < $1.boundingBox.minY } + .map(\.text) + .joined(separator: "\n") + } + + /// Whether any text was found + var hasResults: Bool { + !observations.isEmpty + } + + /// Initialize with observations and image size + init(observations: [OCRText] = [], imageSize: CGSize, timestamp: Date = Date()) { + self.observations = observations + self.imageSize = imageSize + self.timestamp = timestamp + } + + /// Filter observations by minimum confidence level + func filter(minimumConfidence: Float) -> OCRResult { + let filtered = observations.filter { $0.confidence >= minimumConfidence } + return OCRResult(observations: filtered, imageSize: imageSize, timestamp: timestamp) + } + + /// Get observations within a specific region + func observations(in rect: CGRect) -> [OCRText] { + observations.filter { $0.boundingBox.intersects(rect) } + } +} + +// MARK: - Empty Result + +extension OCRResult { + /// Creates an empty OCR result for the given image size + static func empty(imageSize: CGSize) -> OCRResult { + OCRResult(observations: [], imageSize: imageSize) + } +} + +/// A single text observation from OCR. +/// Contains the recognized text, its position, and confidence score. +struct OCRText: Identifiable, Sendable { + /// Unique identifier for this observation + let id: UUID + + /// The recognized text content + let text: String + + /// Bounding box of the text in the image (normalized 0-1) + let boundingBox: CGRect + + /// Confidence score (0.0 to 1.0) + let confidence: Float + + /// Initialize with text, bounding box, and confidence + init(id: UUID = UUID(), text: String, boundingBox: CGRect, confidence: Float) { + self.id = id + self.text = text + self.boundingBox = boundingBox + self.confidence = confidence + } + + /// Whether this observation has high confidence (> 0.5) + var isHighConfidence: Bool { + confidence > 0.5 + } + + /// Whether this observation has very high confidence (> 0.8) + var isVeryHighConfidence: Bool { + confidence > 0.8 + } +} + +// MARK: - Vision Framework Conversion + +extension OCRText { + /// Creates an OCRText from a VNRecognizedTextObservation + /// - Parameter observation: The Vision framework text observation + /// - Parameter imageSize: The source image size for coordinate conversion + /// - Returns: An OCRText if text extraction succeeds, nil otherwise + static func from( + _ observation: VNRecognizedTextObservation, + imageSize: CGSize + ) -> OCRText? { + guard let topCandidate = observation.topCandidates(1).first else { + return nil + } + + // Vision returns normalized bounding box (bottom-left origin) + // Convert to standard coordinate system (top-left origin) + let visionBox = observation.boundingBox + + // Convert from bottom-left origin to top-left origin + let normalizedY = 1.0 - visionBox.maxY + let normalizedHeight = visionBox.height + + let boundingBox = CGRect( + x: visionBox.minX, + y: normalizedY, + width: visionBox.width, + height: normalizedHeight + ) + + return OCRText( + text: topCandidate.string, + boundingBox: boundingBox, + confidence: topCandidate.confidence + ) + } +} + +// MARK: - Bounding Box Utilities + +extension OCRText { + /// Returns the bounding box in pixel coordinates + /// - Parameter imageSize: The image size in pixels + /// - Returns: Bounding box in pixel coordinates + func pixelBoundingBox(in imageSize: CGSize) -> CGRect { + CGRect( + x: boundingBox.minX * imageSize.width, + y: boundingBox.minY * imageSize.height, + width: boundingBox.width * imageSize.width, + height: boundingBox.height * imageSize.height + ) + } + + /// Returns the center point of the text in pixel coordinates + /// - Parameter imageSize: The image size in pixels + /// - Returns: Center point in pixel coordinates + func centerPoint(in imageSize: CGSize) -> CGPoint { + CGPoint( + x: boundingBox.midX * imageSize.width, + y: boundingBox.midY * imageSize.height + ) + } +} + +// MARK: - Equatable Conformance + +extension OCRText: Equatable { + static func == (lhs: OCRText, rhs: OCRText) -> Bool { + lhs.id == rhs.id && + lhs.text == rhs.text && + lhs.boundingBox == rhs.boundingBox && + lhs.confidence == rhs.confidence + } +} diff --git a/ScreenTranslate/Models/SceneEngineBinding.swift b/ScreenTranslate/Models/SceneEngineBinding.swift new file mode 100644 index 0000000..09b96d1 --- /dev/null +++ b/ScreenTranslate/Models/SceneEngineBinding.swift @@ -0,0 +1,59 @@ +// +// SceneEngineBinding.swift +// ScreenTranslate +// +// Configuration for binding engines to translation scenes +// + +import Foundation + +/// Binding configuration between a translation scene and engines +struct SceneEngineBinding: Codable, Identifiable, Equatable, Sendable { + /// Scene identifier (also serves as unique ID) + let scene: TranslationScene + + /// Primary engine for this scene + var primaryEngine: TranslationEngineType + + /// Fallback engine when primary fails + var fallbackEngine: TranslationEngineType? + + /// Whether fallback is enabled + var fallbackEnabled: Bool + + /// Custom prompt for this scene (overrides default) + var customPrompt: String? + + var id: TranslationScene { scene } + + init( + scene: TranslationScene, + primaryEngine: TranslationEngineType, + fallbackEngine: TranslationEngineType? = nil, + fallbackEnabled: Bool = true, + customPrompt: String? = nil + ) { + self.scene = scene + self.primaryEngine = primaryEngine + self.fallbackEngine = fallbackEngine + self.fallbackEnabled = fallbackEnabled + self.customPrompt = customPrompt + } + + /// Default binding for a scene + static func `default`(for scene: TranslationScene) -> SceneEngineBinding { + SceneEngineBinding( + scene: scene, + primaryEngine: .apple, + fallbackEngine: .mtranServer, + fallbackEnabled: true + ) + } + + /// All default bindings + static var allDefaults: [TranslationScene: SceneEngineBinding] { + TranslationScene.allCases.reduce(into: [:]) { result, scene in + result[scene] = .default(for: scene) + } + } +} diff --git a/ScreenTranslate/Models/ScreenAnalysisResult.swift b/ScreenTranslate/Models/ScreenAnalysisResult.swift new file mode 100644 index 0000000..3188121 --- /dev/null +++ b/ScreenTranslate/Models/ScreenAnalysisResult.swift @@ -0,0 +1,236 @@ +import CoreGraphics +import Foundation + +// MARK: - TextSegment + +/// A single text segment extracted by VLM analysis. +/// Contains the recognized text, its normalized position, and confidence score. +struct TextSegment: Identifiable, Codable, Sendable, Equatable { + /// Unique identifier for this segment + let id: UUID + + /// The recognized text content + let text: String + + /// Bounding box of the text in the image (normalized coordinates 0-1) + /// Origin is top-left, coordinates represent (x, y, width, height) + let boundingBox: CGRect + + /// Confidence score from VLM (0.0 to 1.0) + let confidence: Float + + /// Initialize with all properties + init(id: UUID = UUID(), text: String, boundingBox: CGRect, confidence: Float) { + self.id = id + self.text = text + self.boundingBox = boundingBox + self.confidence = confidence + } + + /// Whether this segment has high confidence (> 0.7) + var isHighConfidence: Bool { + confidence > 0.7 + } +} + +// MARK: - TextSegment Bounding Box Utilities + +extension TextSegment { + /// Returns the bounding box in pixel coordinates + /// - Parameter imageSize: The image size in pixels + /// - Returns: Bounding box in pixel coordinates + func pixelBoundingBox(in imageSize: CGSize) -> CGRect { + CGRect( + x: boundingBox.minX * imageSize.width, + y: boundingBox.minY * imageSize.height, + width: boundingBox.width * imageSize.width, + height: boundingBox.height * imageSize.height + ) + } + + /// Returns the center point in pixel coordinates + /// - Parameter imageSize: The image size in pixels + /// - Returns: Center point in pixel coordinates + func centerPoint(in imageSize: CGSize) -> CGPoint { + CGPoint( + x: boundingBox.midX * imageSize.width, + y: boundingBox.midY * imageSize.height + ) + } + + /// Heuristic filter for OCR noise that should not be translated as primary content. + var isLikelyTranslationNoise: Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return true } + let isNearImageEdge = + boundingBox.minX < 0.08 + || boundingBox.maxX > 0.92 + || boundingBox.minY < 0.08 + || boundingBox.maxY > 0.92 + + // Filter coordinate-like strings (e.g., "0.5, 0.3", "(x:0.5, y:0.3)", "x: 0.5") + if trimmed.range(of: #"^[\(\[]?[xy]?\s*[:\:]?\s*[\d.]+\s*[,,]\s*[xy]?\s*[:\:]?\s*[\d.]+[\)\]]?$"#, options: .regularExpression) != nil { + return true + } + // Filter single coordinate values (e.g., "x: 0.5", "y: 0.3") + if trimmed.range(of: #"^[xy]\s*[:\:]?\s*[\d.]+$"#, options: .regularExpression) != nil { + return true + } + + if trimmed.count == 1, + trimmed.range(of: #"^[\d\p{P}\p{S}]$"#, options: .regularExpression) != nil { + return true + } + + if trimmed.count <= 12, + trimmed.range(of: #"^[\d\s.,:;%+\-_=(){}\[\]/\\|<>]+$"#, options: .regularExpression) != nil { + return true + } + + if trimmed.count <= 4, + confidence < 0.35, + trimmed.range(of: #"^[\p{P}\p{S}\dA-Za-z]{1,4}$"#, options: .regularExpression) != nil { + return true + } + + if isNearImageEdge, + trimmed.count <= 8, + trimmed.range( + of: #"^(?:q[1-4]|jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec|mon|tue|wed|thu|fri|sat|sun|\d{1,2}:\d{2}(?:am|pm)?|\d{4})$"#, + options: [.regularExpression, .caseInsensitive] + ) != nil { + return true + } + + return false + } + /// Heuristic for leaked OCR prompt/schema instructions accidentally returned by VLMs. + var isLikelyOCRPromptLeakage: Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + + let normalized = trimmed.lowercased() + let strongSignals = [ + "\"segments\"", + "\"boundingbox\"", + "\"confidence\"", + "\"width\"", + "\"height\"", + "return json", + "json format", + "top-left corner", + "normalized to image size", + "boundingbox", + "置信度", + "左上角", + "右上角", + "箱形尺寸", + "json格式", + "返回json", + ] + + if strongSignals.contains(where: normalized.contains) { + return true + } + + let weakSignals = [ + "x, y", + "width, height", + "0.0-1.0", + "0.0–1.0", + "宽度", + "高度", + "归一化", + ] + let weakSignalCount = weakSignals.reduce(into: 0) { count, signal in + if normalized.contains(signal) { + count += 1 + } + } + + return weakSignalCount >= 2 + } +} + +// MARK: - ScreenAnalysisResult + +/// The result of VLM-based screen analysis. +/// Contains all extracted text segments with their positions. +struct ScreenAnalysisResult: Codable, Sendable, Equatable { + /// All text segments found in the image + let segments: [TextSegment] + + /// The source image dimensions (width x height in pixels) + let imageSize: CGSize + + /// Initialize with segments and image size + init(segments: [TextSegment] = [], imageSize: CGSize) { + self.segments = segments + self.imageSize = imageSize + } + + /// Total number of text segments + var count: Int { + segments.count + } + + /// Whether any text was found + var hasResults: Bool { + !segments.isEmpty + } + + /// All recognized text concatenated with newlines, sorted by vertical position + var fullText: String { + segments + .sorted { $0.boundingBox.minY < $1.boundingBox.minY } + .map(\.text) + .joined(separator: "\n") + } + + /// Filter segments by minimum confidence level + func filter(minimumConfidence: Float) -> ScreenAnalysisResult { + let filtered = segments.filter { $0.confidence >= minimumConfidence } + return ScreenAnalysisResult(segments: filtered, imageSize: imageSize) + } + + /// Get segments within a specific region + func segments(in rect: CGRect) -> [TextSegment] { + segments.filter { $0.boundingBox.intersects(rect) } + } + + /// Removes coordinate ticks, isolated symbols, and similar OCR noise before translation. + func filteredForTranslation() -> ScreenAnalysisResult { + let filteredSegments = segments.filter { + !$0.isLikelyTranslationNoise && !$0.isLikelyOCRPromptLeakage + } + return ScreenAnalysisResult(segments: filteredSegments, imageSize: imageSize) + } + + /// Whether every segment appears to be prompt/schema leakage instead of real UI text. + var containsOnlyPromptLeakage: Bool { + let nonNoiseSegments = segments.filter { !$0.isLikelyTranslationNoise } + return !nonNoiseSegments.isEmpty && nonNoiseSegments.allSatisfy(\.isLikelyOCRPromptLeakage) + } +} + +// MARK: - Empty Result + +extension ScreenAnalysisResult { + /// Creates an empty analysis result for the given image size + static func empty(imageSize: CGSize) -> ScreenAnalysisResult { + ScreenAnalysisResult(segments: [], imageSize: imageSize) + } + + init(ocrResult: OCRResult) { + self.init( + segments: ocrResult.observations.map { + TextSegment( + text: $0.text, + boundingBox: $0.boundingBox, + confidence: $0.confidence + ) + }, + imageSize: ocrResult.imageSize + ) + } +} diff --git a/ScreenCapture/Models/Screenshot.swift b/ScreenTranslate/Models/Screenshot.swift similarity index 100% rename from ScreenCapture/Models/Screenshot.swift rename to ScreenTranslate/Models/Screenshot.swift diff --git a/ScreenCapture/Models/Styles.swift b/ScreenTranslate/Models/Styles.swift similarity index 100% rename from ScreenCapture/Models/Styles.swift rename to ScreenTranslate/Models/Styles.swift diff --git a/ScreenTranslate/Models/TranslationEngineConfig.swift b/ScreenTranslate/Models/TranslationEngineConfig.swift new file mode 100644 index 0000000..687d785 --- /dev/null +++ b/ScreenTranslate/Models/TranslationEngineConfig.swift @@ -0,0 +1,128 @@ +// +// TranslationEngineConfig.swift +// ScreenTranslate +// +// Configuration model for individual translation engines +// + +import Foundation + +/// Configuration for a translation engine +struct TranslationEngineConfig: Codable, Identifiable, Equatable, Sendable { + /// Engine type identifier + let id: TranslationEngineType + + /// Whether this engine is enabled for use + var isEnabled: Bool + + /// Engine-specific options + var options: EngineOptions? + + /// Custom display name (for custom engines) + var customName: String? + + init( + id: TranslationEngineType, + isEnabled: Bool = false, + options: EngineOptions? = nil, + customName: String? = nil + ) { + self.id = id + self.isEnabled = isEnabled + self.options = options + self.customName = customName + } + + /// Default configuration for an engine type + static func `default`(for type: TranslationEngineType) -> TranslationEngineConfig { + TranslationEngineConfig( + id: type, + isEnabled: type == .apple, // Only Apple enabled by default + options: EngineOptions.default(for: type) + ) + } +} + +/// Engine-specific configuration options +struct EngineOptions: Codable, Equatable, Sendable { + /// Custom base URL (for self-hosted or alternative endpoints) + var baseURL: String? + + /// Model name (for LLM engines) + var modelName: String? + + /// Request timeout in seconds + var timeout: TimeInterval? + + /// Maximum tokens for LLM responses + var maxTokens: Int? + + /// Temperature for LLM responses (0.0-2.0) + var temperature: Double? + + /// Custom headers for API requests + var customHeaders: [String: String]? + + init( + baseURL: String? = nil, + modelName: String? = nil, + timeout: TimeInterval? = nil, + maxTokens: Int? = nil, + temperature: Double? = nil, + customHeaders: [String: String]? = nil + ) { + self.baseURL = baseURL + self.modelName = modelName + self.timeout = timeout + self.maxTokens = maxTokens + self.temperature = temperature + self.customHeaders = customHeaders + } + + /// Default options for an engine type + static func `default`(for type: TranslationEngineType) -> EngineOptions? { + switch type { + case .openai: + return EngineOptions( + baseURL: type.defaultBaseURL, + modelName: type.defaultModelName, + timeout: 30, + maxTokens: 2048, + temperature: 0.3 + ) + case .claude: + return EngineOptions( + baseURL: type.defaultBaseURL, + modelName: type.defaultModelName, + timeout: 30, + maxTokens: 2048, + temperature: 0.3 + ) + case .ollama: + return EngineOptions( + baseURL: type.defaultBaseURL, + modelName: type.defaultModelName, + timeout: 60, + maxTokens: 2048, + temperature: 0.3 + ) + case .google: + return EngineOptions( + baseURL: type.defaultBaseURL, + timeout: 30 + ) + case .deepl: + return EngineOptions( + baseURL: type.defaultBaseURL, + timeout: 30 + ) + case .baidu: + return EngineOptions( + baseURL: type.defaultBaseURL, + timeout: 30 + ) + default: + return nil + } + } +} diff --git a/ScreenTranslate/Models/TranslationEngineType.swift b/ScreenTranslate/Models/TranslationEngineType.swift new file mode 100644 index 0000000..52e6c8b --- /dev/null +++ b/ScreenTranslate/Models/TranslationEngineType.swift @@ -0,0 +1,344 @@ +import Foundation +import os.log + +/// Translation engine types supported by the application +enum TranslationEngineType: String, CaseIterable, Sendable, Codable, Identifiable { + // MARK: - Built-in Engines + + /// macOS native Translation API (local, default) + case apple = "apple" + + /// MTranServer (optional, external) + case mtranServer = "mtran" + + // MARK: - LLM Translation Engines + + /// OpenAI GPT translation + case openai = "openai" + + /// Anthropic Claude translation + case claude = "claude" + + /// Google Gemini translation + case gemini = "gemini" + + /// Ollama local LLM translation + case ollama = "ollama" + + // MARK: - Cloud Service Providers + + /// Google Cloud Translation API + case google = "google" + + /// DeepL Translation API + case deepl = "deepl" + + /// Baidu Translation API + case baidu = "baidu" + + // MARK: - Custom/Compatible + + /// Custom OpenAI-compatible endpoint + case custom = "custom" + + var id: String { rawValue } + + /// Localized display name + var localizedName: String { + switch self { + case .apple: + return NSLocalizedString("translation.engine.apple", comment: "Apple Translation (Local)") + case .mtranServer: + return NSLocalizedString("translation.engine.mtran", comment: "MTranServer") + case .openai: + return NSLocalizedString("translation.engine.openai", comment: "OpenAI") + case .claude: + return NSLocalizedString("translation.engine.claude", comment: "Claude") + case .gemini: + return NSLocalizedString("translation.engine.gemini", comment: "Gemini") + case .ollama: + return NSLocalizedString("translation.engine.ollama", comment: "Ollama") + case .google: + return NSLocalizedString("translation.engine.google", comment: "Google Translate") + case .deepl: + return NSLocalizedString("translation.engine.deepl", comment: "DeepL") + case .baidu: + return NSLocalizedString("translation.engine.baidu", comment: "Baidu Translate") + case .custom: + return NSLocalizedString("translation.engine.custom", comment: "Custom") + } + } + + /// Description of the engine + var engineDescription: String { + switch self { + case .apple: + return NSLocalizedString( + "translation.engine.apple.description", + comment: "Built-in macOS translation, no setup required" + ) + case .mtranServer: + return NSLocalizedString( + "translation.engine.mtran.description", + comment: "Self-hosted translation server" + ) + case .openai: + return NSLocalizedString( + "translation.engine.openai.description", + comment: "GPT-4 translation via OpenAI API" + ) + case .claude: + return NSLocalizedString( + "translation.engine.claude.description", + comment: "Claude translation via Anthropic API" + ) + case .gemini: + return NSLocalizedString( + "translation.engine.gemini.description", + comment: "Gemini translation via Google AI API" + ) + case .ollama: + return NSLocalizedString( + "translation.engine.ollama.description", + comment: "Local LLM translation via Ollama" + ) + case .google: + return NSLocalizedString( + "translation.engine.google.description", + comment: "Google Cloud Translation API" + ) + case .deepl: + return NSLocalizedString( + "translation.engine.deepl.description", + comment: "High-quality translation via DeepL API" + ) + case .baidu: + return NSLocalizedString( + "translation.engine.baidu.description", + comment: "Baidu Translation API" + ) + case .custom: + return NSLocalizedString( + "translation.engine.custom.description", + comment: "Custom OpenAI-compatible endpoint" + ) + } + } + + /// Whether this engine is available (local engines are always available) + var isAvailable: Bool { + switch self { + case .apple: + return true + case .mtranServer: + // MTranServer requires external setup + return MTranServerChecker.isAvailable + default: + // Other engines require configuration + return true + } + } + + /// Whether this engine requires an API key + var requiresAPIKey: Bool { + switch self { + case .apple, .mtranServer, .ollama: + return false + case .openai, .claude, .gemini, .google, .deepl, .custom: + return true + case .baidu: + return true // Baidu requires both appID and secretKey + } + } + + /// Whether this engine requires an App ID (Baidu specific) + var requiresAppID: Bool { + switch self { + case .baidu: + return true + default: + return false + } + } + + /// Engine category for grouping + var category: EngineCategory { + switch self { + case .apple, .mtranServer: + return .builtIn + case .openai, .claude, .gemini, .ollama: + return .llm + case .google, .deepl, .baidu: + return .cloudService + case .custom: + return .compatible + } + } + + /// Default base URL for this engine (if applicable) + var defaultBaseURL: String? { + switch self { + case .openai: + return "https://api.openai.com/v1" + case .claude: + return "https://api.anthropic.com/v1" + case .gemini: + return "https://generativelanguage.googleapis.com/v1beta" + case .ollama: + return "http://localhost:11434" + case .google: + return "https://translation.googleapis.com/language/translate/v2" + case .deepl: + return "https://api.deepl.com/v2" + case .baidu: + return "https://fanyi-api.baidu.com/api/trans/vip/translate" + default: + return nil + } + } + + /// Default model name for LLM engines + var defaultModelName: String? { + switch self { + case .openai: + return "gpt-4o-mini" + case .claude: + return "claude-sonnet-4-20250514" + case .gemini: + return "gemini-2.0-flash" + case .ollama: + return "llama3" + default: + return nil + } + } + + /// URL to get API key for this engine + var apiKeyURL: URL? { + switch self { + case .openai: + return URL(string: "https://platform.openai.com/api-keys") + case .claude: + return URL(string: "https://console.anthropic.com/settings/keys") + case .gemini: + return URL(string: "https://aistudio.google.com/apikey") + case .google: + return URL(string: "https://console.cloud.google.com/apis/credentials") + case .deepl: + return URL(string: "https://www.deepl.com/pro-api?cta=header-pro-api") + case .baidu: + return URL(string: "https://fanyi-api.baidu.com/api/trans/product/desktop?req=developer") + default: + return nil + } + } +} + +/// Engine category for grouping in UI +enum EngineCategory: String, CaseIterable, Sendable, Codable { + case builtIn + case llm + case cloudService + case compatible + + var localizedName: String { + switch self { + case .builtIn: + return NSLocalizedString("engine.category.builtin", comment: "Built-in") + case .llm: + return NSLocalizedString("engine.category.llm", comment: "LLM Translation") + case .cloudService: + return NSLocalizedString("engine.category.cloud", comment: "Cloud Services") + case .compatible: + return NSLocalizedString("engine.category.compatible", comment: "Compatible") + } + } +} + +// MARK: - MTranServer Availability Checker + +/// Helper to check if MTranServer is available on the system +enum MTranServerChecker { + /// Cached availability status (nonisolated(unsafe) for singleton cache) + private nonisolated(unsafe) static var _isAvailable: Bool? + + /// Check if MTranServer is available + static var isAvailable: Bool { + if let cached = _isAvailable { + return cached + } + + let result = checkMTranServer() + _isAvailable = result + return result + } + + private final class ResultBox: @unchecked Sendable { + var value: Bool = false + } + + private static func checkMTranServer() -> Bool { + // Read settings directly from UserDefaults to avoid MainActor isolation issues + let defaults = UserDefaults.standard + let prefix = "ScreenTranslate." + var host = defaults.string(forKey: prefix + "mtranServerHost") ?? "localhost" + let port = defaults.object(forKey: prefix + "mtranServerPort") as? Int ?? 8989 + + // Normalize localhost to 127.0.0.1 to avoid IPv6 resolution issues + if host == "localhost" { + host = "127.0.0.1" + } + + let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ScreenTranslate", category: "MTranServerChecker") + logger.info("Checking MTranServer at \(host):\(port)") + + // Try multiple endpoints for health check + let endpoints = ["/health", "/", "/translate"] + var isAvailable = false + + for endpoint in endpoints { + var components = URLComponents() + components.scheme = "http" + components.host = host + components.port = port + components.path = endpoint + + guard let url = components.url else { continue } + + var request = URLRequest(url: url) + request.timeoutInterval = 2.0 + request.httpMethod = "GET" + + let semaphore = DispatchSemaphore(value: 0) + let resultBox = ResultBox() + + let task = URLSession.shared.dataTask(with: request) { _, response, error in + if let error = error { + logger.debug("MTranServer check \(endpoint) failed: \(error.localizedDescription)") + } + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + logger.debug("MTranServer check \(endpoint): status \(statusCode)") + // Accept any response that indicates server is running (not connection refused) + resultBox.value = statusCode > 0 + semaphore.signal() + } + + task.resume() + _ = semaphore.wait(timeout: .now() + 2.5) + + if resultBox.value { + isAvailable = true + logger.info("MTranServer available via \(endpoint)") + break + } + } + + logger.info("MTranServer final availability: \(isAvailable)") + return isAvailable + } + + /// Reset the cached availability check + static func resetCache() { + _isAvailable = nil + } +} diff --git a/ScreenTranslate/Models/TranslationHistory.swift b/ScreenTranslate/Models/TranslationHistory.swift new file mode 100644 index 0000000..f97ff6b --- /dev/null +++ b/ScreenTranslate/Models/TranslationHistory.swift @@ -0,0 +1,145 @@ +import Foundation + +/// A single translation history entry. +/// Contains the source text, translated text, screenshot thumbnail, and metadata. +struct TranslationHistory: Identifiable, Codable, Sendable { + // MARK: - Types + + /// Coding keys for custom encoding/decoding + private enum CodingKeys: String, CodingKey { + case id + case sourceText + case translatedText + case sourceLanguage + case targetLanguage + case timestamp + case thumbnailData + } + + // MARK: - Properties + + /// Unique identifier + let id: UUID + + /// Original source text + let sourceText: String + + /// Translated text + let translatedText: String + + /// Source language name + let sourceLanguage: String + + /// Target language name + let targetLanguage: String + + /// When the translation was performed + let timestamp: Date + + /// JPEG thumbnail data (max 10KB, 128px on longest edge) + let thumbnailData: Data? + + // MARK: - Initialization + + init( + id: UUID = UUID(), + sourceText: String, + translatedText: String, + sourceLanguage: String, + targetLanguage: String, + timestamp: Date = Date(), + thumbnailData: Data? = nil + ) { + self.id = id + self.sourceText = sourceText + self.translatedText = translatedText + self.sourceLanguage = sourceLanguage + self.targetLanguage = targetLanguage + self.timestamp = timestamp + self.thumbnailData = thumbnailData + } + + // MARK: - Computed Properties + + /// A formatted description of the translation + var description: String { + "\(sourceLanguage) → \(targetLanguage)" + } + + /// Whether the history entry has a thumbnail + var hasThumbnail: Bool { + thumbnailData != nil && !(thumbnailData?.isEmpty ?? true) + } + + /// Truncated source text for preview (max 500 characters) + var sourcePreview: String { + String(sourceText.prefix(500)) + } + + /// Truncated translated text for preview (max 500 characters) + var translatedPreview: String { + String(translatedText.prefix(500)) + } + + /// Whether the source text is longer than preview + var isSourceTruncated: Bool { + sourceText.count > 500 + } + + /// Whether the translated text is longer than preview + var isTranslatedTruncated: Bool { + translatedText.count > 500 + } + + /// Formatted timestamp string + var formattedTimestamp: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: timestamp, relativeTo: Date()) + } + + /// Full date string + var fullDateString: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: timestamp) + } +} + +// MARK: - Search Match + +extension TranslationHistory { + /// Checks if the history entry matches the search query. + /// - Parameter query: The search string to match against + /// - Returns: True if the query is found in source or translated text + func matches(_ query: String) -> Bool { + guard !query.isEmpty else { return true } + let lowercaseQuery = query.lowercased() + return sourceText.lowercased().contains(lowercaseQuery) || + translatedText.lowercased().contains(lowercaseQuery) + } +} + +// MARK: - Factory from TranslationResult + +extension TranslationHistory { + /// Creates a history entry from a translation result. + /// - Parameters: + /// - result: The translation result to convert + /// - thumbnailData: Optional thumbnail data + /// - Returns: A new TranslationHistory entry + static func from( + result: TranslationResult, + thumbnailData: Data? = nil + ) -> TranslationHistory { + TranslationHistory( + sourceText: result.sourceText, + translatedText: result.translatedText, + sourceLanguage: result.sourceLanguage, + targetLanguage: result.targetLanguage, + timestamp: result.timestamp, + thumbnailData: thumbnailData + ) + } +} diff --git a/ScreenTranslate/Models/TranslationMode.swift b/ScreenTranslate/Models/TranslationMode.swift new file mode 100644 index 0000000..dad91b9 --- /dev/null +++ b/ScreenTranslate/Models/TranslationMode.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Translation display mode options +enum TranslationMode: String, CaseIterable, Sendable, Codable { + /// Overlay translation at the exact position of original text + case inline + + /// Show translation in a popover below the selected area + case below + + /// Localized display name + var localizedName: String { + switch self { + case .inline: + return NSLocalizedString( + "translation.mode.inline", + comment: "In-place Replacement" + ) + case .below: + return NSLocalizedString( + "translation.mode.below", + comment: "Below Original" + ) + } + } + + /// Description of the mode + var description: String { + switch self { + case .inline: + return NSLocalizedString( + "translation.mode.inline.description", + comment: "Replace original text with translation" + ) + case .below: + return NSLocalizedString( + "translation.mode.below.description", + comment: "Show translation in a floating window" + ) + } + } +} diff --git a/ScreenTranslate/Models/TranslationPromptConfig.swift b/ScreenTranslate/Models/TranslationPromptConfig.swift new file mode 100644 index 0000000..7b31f64 --- /dev/null +++ b/ScreenTranslate/Models/TranslationPromptConfig.swift @@ -0,0 +1,165 @@ +// +// TranslationPromptConfig.swift +// ScreenTranslate +// +// Configuration for customizable translation prompts +// + +import Foundation + +/// Configuration for translation prompts +struct TranslationPromptConfig: Codable, Equatable, Sendable { + /// Per-engine custom prompts + var enginePrompts: [TranslationEngineType: String] + + /// Per-compatible-engine custom prompts keyed by stable provider UUID strings. + var compatibleEnginePrompts: [String: String] + + /// Per-scene custom prompts + var scenePrompts: [TranslationScene: String] + + init( + enginePrompts: [TranslationEngineType: String] = [:], + compatibleEnginePrompts: [String: String] = [:], + scenePrompts: [TranslationScene: String] = [:] + ) { + self.enginePrompts = enginePrompts + self.compatibleEnginePrompts = compatibleEnginePrompts + self.scenePrompts = scenePrompts + } + + /// Default translation prompt + static let defaultPrompt = """ + Translate the following text from {source_language} to {target_language}. + Provide only the translation without any explanations or additional text. + + Text to translate: + {text} + """ + + /// Default prompt for translate and insert scenario + static let defaultInsertPrompt = """ + Translate the following text from {source_language} to {target_language}. + The translation will be inserted at the cursor position. + Provide only the translation without any explanations, formatting, or additional text. + Keep the translation concise and natural for the target language. + + Text to translate: + {text} + """ + + /// Resolve the effective prompt for a given engine and scene + /// - Parameters: + /// - engine: The translation engine type + /// - scene: The translation scene + /// - sourceLanguage: Source language name + /// - targetLanguage: Target language name + /// - text: Text to translate + /// - compatiblePromptID: Optional stable identifier for compatible engine instances + /// - Returns: The resolved prompt with variables substituted + func resolvedPrompt( + for engine: TranslationEngineType, + scene: TranslationScene, + sourceLanguage: String, + targetLanguage: String, + text: String, + compatiblePromptID: String? = nil + ) -> String { + // Priority: scene-specific > compatible-specific > engine-specific > default + let basePrompt: String + if let scenePrompt = scenePrompts[scene], !scenePrompt.isEmpty { + basePrompt = scenePrompt + } else if engine == .custom, let promptID = compatiblePromptID, + let compatiblePrompt = compatibleEnginePrompts[promptID], !compatiblePrompt.isEmpty { + basePrompt = compatiblePrompt + } else if let enginePrompt = enginePrompts[engine], !enginePrompt.isEmpty { + basePrompt = enginePrompt + } else if scene == .translateAndInsert { + basePrompt = Self.defaultInsertPrompt + } else { + basePrompt = Self.defaultPrompt + } + + // Replace template variables + return basePrompt + .replacingOccurrences(of: "{source_language}", with: sourceLanguage) + .replacingOccurrences(of: "{target_language}", with: targetLanguage) + .replacingOccurrences(of: "{text}", with: text) + } + + /// Get prompt preview for a specific context + /// - Parameters: + /// - engine: The translation engine type + /// - scene: The translation scene + /// - compatiblePromptID: Optional stable identifier for compatible engine instances + /// - Returns: The prompt preview string + func promptPreview( + for engine: TranslationEngineType, + scene: TranslationScene, + compatiblePromptID: String? = nil + ) -> String { + if let scenePrompt = scenePrompts[scene], !scenePrompt.isEmpty { + return scenePrompt + } + if engine == .custom, let promptID = compatiblePromptID, + let compatiblePrompt = compatibleEnginePrompts[promptID], !compatiblePrompt.isEmpty { + return compatiblePrompt + } + if let enginePrompt = enginePrompts[engine], !enginePrompt.isEmpty { + return enginePrompt + } + if scene == .translateAndInsert { + return Self.defaultInsertPrompt + } + return Self.defaultPrompt + } + + /// Check if there are any custom prompts configured + var hasCustomPrompts: Bool { + !enginePrompts.isEmpty || !compatibleEnginePrompts.isEmpty || !scenePrompts.isEmpty + } + + /// Reset to default prompts + mutating func reset() { + enginePrompts.removeAll() + compatibleEnginePrompts.removeAll() + scenePrompts.removeAll() + } +} + +// MARK: - Prompt Template Variables + +extension TranslationPromptConfig { + /// Available template variables for prompts + static let templateVariables: [PromptVariable] = [ + PromptVariable( + name: "{source_language}", + description: NSLocalizedString( + "prompt.variable.source_language", + comment: "Source language name" + ) + ), + PromptVariable( + name: "{target_language}", + description: NSLocalizedString( + "prompt.variable.target_language", + comment: "Target language name" + ) + ), + PromptVariable( + name: "{text}", + description: NSLocalizedString( + "prompt.variable.text", + comment: "Text to translate" + ) + ) + ] +} + +/// Template variable description +struct PromptVariable: Identifiable, Sendable { + let name: String + let description: String + + var id: String { name } +} diff --git a/ScreenTranslate/Models/TranslationResult.swift b/ScreenTranslate/Models/TranslationResult.swift new file mode 100644 index 0000000..a290206 --- /dev/null +++ b/ScreenTranslate/Models/TranslationResult.swift @@ -0,0 +1,79 @@ +import Foundation + +/// The result of a translation operation. +/// Contains the original text, translated text, and language information. +struct TranslationResult: Sendable { + /// The original source text + let sourceText: String + + /// The translated text + let translatedText: String + + /// The source language name (e.g., "English", "Chinese (Simplified)") + let sourceLanguage: String + + /// The target language name (e.g., "Spanish", "Japanese") + let targetLanguage: String + + /// When the translation was performed + let timestamp: Date + + /// Initialize with translation data + init( + sourceText: String, + translatedText: String, + sourceLanguage: String, + targetLanguage: String, + timestamp: Date = Date() + ) { + self.sourceText = sourceText + self.translatedText = translatedText + self.sourceLanguage = sourceLanguage + self.targetLanguage = targetLanguage + self.timestamp = timestamp + } + + /// A formatted description of the translation + var description: String { + "\(sourceLanguage) → \(targetLanguage)" + } + + /// Whether the translation is different from the source + var hasChanges: Bool { + sourceText != translatedText + } +} + +// MARK: - Empty Result + +extension TranslationResult { + /// Creates an empty translation result (no-op translation) + static func empty(for text: String) -> TranslationResult { + TranslationResult( + sourceText: text, + translatedText: text, + sourceLanguage: NSLocalizedString("translation.unknown", comment: ""), + targetLanguage: NSLocalizedString("translation.unknown", comment: "") + ) + } +} + +// MARK: - Batch Translation + +extension TranslationResult { + /// Combines multiple translation results into a single result + static func combine(_ results: [TranslationResult]) -> TranslationResult? { + guard let first = results.first else { return nil } + + let combinedSource = results.map(\.sourceText).joined(separator: "\n") + let combinedTranslated = results.map(\.translatedText).joined(separator: "\n") + + return TranslationResult( + sourceText: combinedSource, + translatedText: combinedTranslated, + sourceLanguage: first.sourceLanguage, + targetLanguage: first.targetLanguage, + timestamp: first.timestamp + ) + } +} diff --git a/ScreenTranslate/Models/TranslationResultBundle.swift b/ScreenTranslate/Models/TranslationResultBundle.swift new file mode 100644 index 0000000..3034b6c --- /dev/null +++ b/ScreenTranslate/Models/TranslationResultBundle.swift @@ -0,0 +1,215 @@ +// +// TranslationResultBundle.swift +// ScreenTranslate +// +// Bundle containing results from multiple translation engines +// + +import Foundation + +/// Result from a single translation engine +struct EngineResult: Sendable, Identifiable { + /// Engine type that produced this result + let engine: TranslationEngineType + + /// Translated segments + let segments: [BilingualSegment] + + /// Time taken for translation in seconds + let latency: TimeInterval + + /// Error if translation failed + let error: Error? + + /// Unique identifier + let id: UUID + + init( + engine: TranslationEngineType, + segments: [BilingualSegment], + latency: TimeInterval, + error: Error? = nil + ) { + self.id = UUID() + self.engine = engine + self.segments = segments + self.latency = latency + self.error = error + } + + /// Whether this result was successful + var isSuccess: Bool { + error == nil && !segments.isEmpty + } + + /// Create a failed result + static func failed(engine: TranslationEngineType, error: Error, latency: TimeInterval = 0) -> EngineResult { + EngineResult( + engine: engine, + segments: [], + latency: latency, + error: error + ) + } +} + +/// Bundle containing results from multiple translation engines +struct TranslationResultBundle: Sendable { + /// Results from all attempted engines + let results: [EngineResult] + + /// The primary engine that was used + let primaryEngine: TranslationEngineType + + /// Selection mode used for this translation + let selectionMode: EngineSelectionMode + + /// Scene that triggered this translation + let scene: TranslationScene? + + /// When the translation was performed + let timestamp: Date + + init( + results: [EngineResult], + primaryEngine: TranslationEngineType, + selectionMode: EngineSelectionMode, + scene: TranslationScene? = nil, + timestamp: Date = Date() + ) { + self.results = results + self.primaryEngine = primaryEngine + self.selectionMode = selectionMode + self.scene = scene + self.timestamp = timestamp + } + + // MARK: - Computed Properties + + /// Primary result (from the primary engine) + var primaryResult: [BilingualSegment] { + results.first { $0.engine == primaryEngine && $0.isSuccess }?.segments ?? [] + } + + /// Whether any engine had errors + var hasErrors: Bool { + results.contains { $0.error != nil } + } + + /// Whether all engines failed + var allFailed: Bool { + results.allSatisfy { $0.error != nil } + } + + /// List of engines that succeeded + var successfulEngines: [TranslationEngineType] { + results.filter { $0.isSuccess }.map { $0.engine } + } + + /// List of engines that failed + var failedEngines: [TranslationEngineType] { + results.filter { !$0.isSuccess }.map { $0.engine } + } + + /// Get result for a specific engine + func result(for engine: TranslationEngineType) -> EngineResult? { + results.first { $0.engine == engine } + } + + /// Get segments from a specific engine + func segments(for engine: TranslationEngineType) -> [BilingualSegment]? { + result(for: engine)?.segments + } + + /// Average latency across successful engines + var averageLatency: TimeInterval { + let successfulResults = results.filter { $0.isSuccess } + guard !successfulResults.isEmpty else { return 0 } + return successfulResults.map(\.latency).reduce(0, +) / Double(successfulResults.count) + } + + // MARK: - Factory Methods + + /// Create a bundle from a single engine result (backward compatible) + static func single( + engine: TranslationEngineType, + segments: [BilingualSegment], + latency: TimeInterval, + selectionMode: EngineSelectionMode = .primaryWithFallback, + scene: TranslationScene? = nil + ) -> TranslationResultBundle { + let result = EngineResult( + engine: engine, + segments: segments, + latency: latency + ) + return TranslationResultBundle( + results: [result], + primaryEngine: engine, + selectionMode: selectionMode, + scene: scene + ) + } + + /// Create a failed bundle + static func failed( + engine: TranslationEngineType, + error: Error, + selectionMode: EngineSelectionMode = .primaryWithFallback, + scene: TranslationScene? = nil + ) -> TranslationResultBundle { + let result = EngineResult.failed(engine: engine, error: error) + return TranslationResultBundle( + results: [result], + primaryEngine: engine, + selectionMode: selectionMode, + scene: scene + ) + } +} + +// MARK: - Error Types for Bundle + +/// Errors specific to multi-engine translation +enum MultiEngineError: LocalizedError, Sendable { + /// All engines failed + case allEnginesFailed([Error]) + + /// No engines configured + case noEnginesConfigured + + /// Primary engine not available + case primaryNotAvailable(TranslationEngineType) + + /// No results available + case noResults + + var errorDescription: String? { + switch self { + case .allEnginesFailed(let errors): + let errorMessages = errors.map { $0.localizedDescription }.joined(separator: "; ") + return NSLocalizedString( + "multiengine.error.all_failed", + comment: "All translation engines failed" + ) + ": " + errorMessages + case .noEnginesConfigured: + return NSLocalizedString( + "multiengine.error.no_engines", + comment: "No translation engines are configured" + ) + case .primaryNotAvailable(let engine): + return String( + format: NSLocalizedString( + "multiengine.error.primary_unavailable", + comment: "Primary engine %@ is not available" + ), + engine.localizedName + ) + case .noResults: + return NSLocalizedString( + "multiengine.error.no_results", + comment: "No translation results available" + ) + } + } +} diff --git a/ScreenTranslate/Models/TranslationScene.swift b/ScreenTranslate/Models/TranslationScene.swift new file mode 100644 index 0000000..0a89832 --- /dev/null +++ b/ScreenTranslate/Models/TranslationScene.swift @@ -0,0 +1,76 @@ +// +// TranslationScene.swift +// ScreenTranslate +// +// Translation scenarios for scene-based engine binding +// + +import Foundation + +/// Translation scene type for scene-based engine selection +enum TranslationScene: String, Codable, CaseIterable, Identifiable, Sendable { + /// Screenshot translation + case screenshot = "screenshot" + + /// Text selection translation + case textSelection = "text_selection" + + /// Translate and insert to clipboard + case translateAndInsert = "translate_and_insert" + + var id: String { rawValue } + + /// Localized display name + var localizedName: String { + switch self { + case .screenshot: + return NSLocalizedString( + "translation.scene.screenshot", + comment: "Screenshot Translation" + ) + case .textSelection: + return NSLocalizedString( + "translation.scene.text_selection", + comment: "Text Selection Translation" + ) + case .translateAndInsert: + return NSLocalizedString( + "translation.scene.translate_and_insert", + comment: "Translate and Insert" + ) + } + } + + /// Scene description + var sceneDescription: String { + switch self { + case .screenshot: + return NSLocalizedString( + "translation.scene.screenshot.description", + comment: "OCR and translate captured screenshot regions" + ) + case .textSelection: + return NSLocalizedString( + "translation.scene.text_selection.description", + comment: "Translate selected text from any application" + ) + case .translateAndInsert: + return NSLocalizedString( + "translation.scene.translate_and_insert.description", + comment: "Translate clipboard text and insert at cursor" + ) + } + } + + /// Icon name for UI + var iconName: String { + switch self { + case .screenshot: + return "camera.viewfinder" + case .textSelection: + return "translate" + case .translateAndInsert: + return "doc.on.clipboard" + } + } +} diff --git a/ScreenTranslate/Models/VLMProviderType.swift b/ScreenTranslate/Models/VLMProviderType.swift new file mode 100644 index 0000000..e22c73e --- /dev/null +++ b/ScreenTranslate/Models/VLMProviderType.swift @@ -0,0 +1,157 @@ +// +// VLMProviderType.swift +// ScreenTranslate +// +// Created for US-015: VLM and Translation Configuration UI +// + +import Foundation + +/// VLM (Vision Language Model) provider types supported by the application +enum VLMProviderType: String, CaseIterable, Sendable, Codable, Identifiable { + case openai = "openai" + case claude = "claude" + case glmOCR = "glm_ocr" + case ollama = "ollama" + case paddleocr = "paddleocr" + + var id: String { rawValue } + + /// Localized display name + var localizedName: String { + switch self { + case .openai: + return NSLocalizedString("vlm.provider.openai", comment: "OpenAI") + case .claude: + return NSLocalizedString("vlm.provider.claude", comment: "Claude") + case .glmOCR: + return NSLocalizedString("vlm.provider.glmocr", comment: "GLM OCR") + case .ollama: + return NSLocalizedString("vlm.provider.ollama", comment: "Ollama") + case .paddleocr: + return NSLocalizedString("vlm.provider.paddleocr", comment: "PaddleOCR") + } + } + + /// Description of the provider + var providerDescription: String { + providerDescription(glmOCRMode: .cloud) + } + + func providerDescription(glmOCRMode: GLMOCRMode) -> String { + switch self { + case .openai: + return NSLocalizedString( + "vlm.provider.openai.description", + comment: "OpenAI GPT-4 Vision API" + ) + case .claude: + return NSLocalizedString( + "vlm.provider.claude.description", + comment: "Anthropic Claude Vision API" + ) + case .glmOCR: + return glmOCRMode.providerDescription + case .ollama: + return NSLocalizedString( + "vlm.provider.ollama.description", + comment: "Local Ollama server" + ) + case .paddleocr: + return NSLocalizedString( + "vlm.provider.paddleocr.description", + comment: "Local OCR engine (free, offline)" + ) + } + } + + /// Default base URL for this provider + var defaultBaseURL: String { + defaultBaseURL(glmOCRMode: .cloud) + } + + func defaultBaseURL(glmOCRMode: GLMOCRMode) -> String { + switch self { + case .openai: + return "https://api.openai.com/v1" + case .claude: + return "https://api.anthropic.com/v1" + case .glmOCR: + return glmOCRMode.defaultBaseURL + case .ollama: + return "http://localhost:11434" + case .paddleocr: + return "" + } + } + + /// Default model name for this provider + var defaultModelName: String { + defaultModelName(glmOCRMode: .cloud) + } + + func defaultModelName(glmOCRMode: GLMOCRMode) -> String { + switch self { + case .openai: + return "gpt-4o" + case .claude: + return "claude-sonnet-4-20250514" + case .glmOCR: + return glmOCRMode.defaultModelName + case .ollama: + return "llava" + case .paddleocr: + return "" + } + } + + /// Whether this provider requires an API key + var requiresAPIKey: Bool { + requiresAPIKey(glmOCRMode: .cloud) + } + + func requiresAPIKey(glmOCRMode: GLMOCRMode) -> Bool { + switch self { + case .openai, .claude: + return true + case .glmOCR: + return glmOCRMode.requiresAPIKey + case .ollama, .paddleocr: + return false + } + } +} + +/// Preferred translation engine type for the translation workflow +enum PreferredTranslationEngine: String, CaseIterable, Sendable, Codable, Identifiable { + case apple = "apple" + case mtranServer = "mtran" + + var id: String { rawValue } + + /// Localized display name + var localizedName: String { + switch self { + case .apple: + return NSLocalizedString("translation.engine.apple", comment: "Apple Translation") + case .mtranServer: + return NSLocalizedString("translation.engine.mtran", comment: "MTransServer") + } + } + + /// Description of the engine + var engineDescription: String { + switch self { + case .apple: + return NSLocalizedString( + "translation.preferred.apple.description", + comment: "Built-in macOS translation, works offline" + ) + case .mtranServer: + return NSLocalizedString( + "translation.preferred.mtran.description", + comment: "Self-hosted translation server for better quality" + ) + } + } +} diff --git a/ScreenCapture/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ScreenTranslate/Resources/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from ScreenCapture/Resources/Assets.xcassets/AccentColor.colorset/Contents.json rename to ScreenTranslate/Resources/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from ScreenCapture/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json rename to ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..287875a Binary files /dev/null and b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..0eca749 Binary files /dev/null and b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..a88a890 Binary files /dev/null and b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..aa6e6a8 Binary files /dev/null and b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..0eca749 Binary files /dev/null and b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..b901b6f Binary files /dev/null and b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..aa6e6a8 Binary files /dev/null and b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..ab9f889 Binary files /dev/null and b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..b901b6f Binary files /dev/null and b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..11d1888 Binary files /dev/null and b/ScreenTranslate/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/ScreenCapture/Resources/Assets.xcassets/Contents.json b/ScreenTranslate/Resources/Assets.xcassets/Contents.json similarity index 100% rename from ScreenCapture/Resources/Assets.xcassets/Contents.json rename to ScreenTranslate/Resources/Assets.xcassets/Contents.json diff --git a/ScreenCapture/Resources/Assets.xcassets/MenuBarIcon.imageset/Contents.json b/ScreenTranslate/Resources/Assets.xcassets/MenuBarIcon.imageset/Contents.json similarity index 100% rename from ScreenCapture/Resources/Assets.xcassets/MenuBarIcon.imageset/Contents.json rename to ScreenTranslate/Resources/Assets.xcassets/MenuBarIcon.imageset/Contents.json diff --git a/ScreenTranslate/Resources/DesignSystem.swift b/ScreenTranslate/Resources/DesignSystem.swift new file mode 100644 index 0000000..8b05fa3 --- /dev/null +++ b/ScreenTranslate/Resources/DesignSystem.swift @@ -0,0 +1,116 @@ +import SwiftUI + +/// macOS 26 "Liquid Glass" Design System +enum DesignSystem { + enum Colors { + static let meshColors: [Color] = [ + Color(red: 0.1, green: 0.2, blue: 0.45), // Deep Ocean + Color(red: 0.3, green: 0.1, blue: 0.4), // Royal Purple + Color(red: 0.05, green: 0.25, blue: 0.25), // Emerald Teal + Color(red: 0.15, green: 0.15, blue: 0.3) // Midnight + ] + + static let glassBorder = Color.white.opacity(0.18) + static let liquidHighlight = Color.white.opacity(0.3) + } + + enum Radii { + static let window: CGFloat = 32 + static let card: CGFloat = 24 + static let control: CGFloat = 12 + } +} + +/// A dynamic, flowing mesh gradient for the window background +struct MeshGradientView: View { + @State private var animate = false + + var body: some View { + ZStack { + // Base layer + LinearGradient( + colors: [Color(red: 0.08, green: 0.1, blue: 0.15), Color(red: 0.03, green: 0.05, blue: 0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + // Flowing blobs + TimelineView(.animation) { timeline in + Canvas { context, size in + let time = timeline.date.timeIntervalSinceReferenceDate + + for index in 0..<4 { + let x = size.width * (0.5 + 0.3 * sin(time * 0.5 + Double(index))) + let y = size.height * (0.5 + 0.3 * cos(time * 0.4 + Double(index) * 1.5)) + let radius = max(size.width, size.height) * 0.6 + + context.fill( + Circle().path( + in: CGRect(x: x - radius / 2, y: y - radius / 2, width: radius, height: radius) + ), + with: .radialGradient( + Gradient(colors: [DesignSystem.Colors.meshColors[index].opacity(0.4), .clear]), + center: CGPoint(x: x, y: y), + startRadius: 0, + endRadius: radius / 2 + ) + ) + } + } + } + .blur(radius: 60) + } + .ignoresSafeArea() + } +} + +extension View { + /// Standard macOS 26 grouped card style + func macos26LiquidGlass(cornerRadius: CGFloat = DesignSystem.Radii.card) -> some View { + self + .padding(20) + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Color(.controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color.black.opacity(0.05), lineWidth: 0.5) + ) + } + + /// Icon glow for macOS 26 + func macos26IconGlow(color: Color = .blue) -> some View { + self + .padding(8) + .background { + Circle() + .fill(color.opacity(0.12)) + .blur(radius: 6) + .overlay { + Circle() + .stroke(color.opacity(0.3), lineWidth: 0.5) + } + } + .foregroundStyle(color) + } +} + +/// Bridge to NSVisualEffectView for SwiftUI +struct VisualEffectView: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = blendingMode + view.state = .active + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + nsView.material = material + nsView.blendingMode = blendingMode + } +} diff --git a/ScreenTranslate/Resources/de.lproj/Localizable.strings b/ScreenTranslate/Resources/de.lproj/Localizable.strings new file mode 100644 index 0000000..3f118e9 --- /dev/null +++ b/ScreenTranslate/Resources/de.lproj/Localizable.strings @@ -0,0 +1,823 @@ +/* + Localizable.strings (Deutsch) + ScreenTranslate +*/ + +/* ======================================== + Fehlermeldungen + ======================================== */ + +/* Berechtigungsfehler */ +"error.permission.denied" = "Für das Erstellen von Screenshots ist die Bildschirmaufnahme-Berechtigung erforderlich."; +"error.permission.denied.recovery" = "Öffnen Sie die Systemeinstellungen, um die Berechtigung zu erteilen."; + +/* Anzeigefehler */ +"error.display.not.found" = "Das ausgewählte Display ist nicht mehr verfügbar."; +"error.display.not.found.recovery" = "Bitte wählen Sie ein anderes Display aus."; +"error.display.disconnected" = "Das Display '%@' wurde während der Aufnahme getrennt."; +"error.display.disconnected.recovery" = "Bitte schließen Sie das Display wieder an und versuchen Sie es erneut."; + +/* Aufnahmefehler */ +"error.capture.failed" = "Bildschirmaufnahme fehlgeschlagen."; +"error.capture.failed.recovery" = "Bitte versuchen Sie es erneut."; + +/* Speicherfehler */ +"error.save.location.invalid" = "Der Speicherort ist nicht zugänglich."; +"error.save.location.invalid.recovery" = "Wählen Sie einen anderen Speicherort in den Einstellungen."; +"error.save.location.invalid.detail" = "Kann nicht unter %@ gespeichert werden. Der Speicherort ist nicht zugänglich."; +"error.save.unknown" = "Beim Speichern ist ein unerwarteter Fehler aufgetreten."; +"error.disk.full" = "Nicht genug Speicherplatz zum Speichern des Screenshots."; +"error.disk.full.recovery" = "Geben Sie Speicherplatz frei und versuchen Sie es erneut."; + +/* Exportfehler */ +"error.export.encoding.failed" = "Fehler beim Kodieren des Bildes."; +"error.export.encoding.failed.recovery" = "Versuchen Sie ein anderes Format in den Einstellungen."; +"error.export.encoding.failed.detail" = "Fehler beim Kodieren des Bildes als %@."; + +/* Zwischenablage-Fehler */ +"error.clipboard.write.failed" = "Fehler beim Kopieren des Screenshots in die Zwischenablage."; +"error.clipboard.write.failed.recovery" = "Bitte versuchen Sie es erneut."; + +/* Tastenkombinationsfehler */ +"error.hotkey.registration.failed" = "Fehler beim Registrieren der Tastenkombination."; +"error.hotkey.registration.failed.recovery" = "Die Tastenkombination steht möglicherweise in Konflikt mit einer anderen App. Versuchen Sie eine andere Kombination."; +"error.hotkey.conflict" = "Diese Tastenkombination steht in Konflikt mit einer anderen Anwendung."; +"error.hotkey.conflict.recovery" = "Wählen Sie eine andere Tastenkombination."; + +/* OCR-Fehler */ +"error.ocr.failed" = "Texterkennung fehlgeschlagen."; +"error.ocr.failed.recovery" = "Bitte versuchen Sie es erneut mit einem klareren Bild."; +"error.ocr.no.text" = "Es wurde kein Text im Bild erkannt."; +"error.ocr.no.text.recovery" = "Versuchen Sie, einen Bereich mit sichtbarem Text aufzunehmen."; +"error.ocr.cancelled" = "Texterkennung wurde abgebrochen."; +"error.ocr.server.unreachable" = "Keine Verbindung zum OCR-Server möglich."; +"error.ocr.server.unreachable.recovery" = "Überprüfen Sie die Serveradresse und die Netzwerkverbindung."; + +/* Übersetzungsfehler */ +"error.translation.in.progress" = "Eine Übersetzung wird bereits ausgeführt"; +"error.translation.in.progress.recovery" = "Bitte warten Sie, bis die aktuelle Übersetzung abgeschlossen ist"; +"error.translation.empty.input" = "Kein Text zum Übersetzen"; +"error.translation.empty.input.recovery" = "Bitte wählen Sie zuerst Text aus"; +"error.translation.timeout" = "Übersetzungszeitüberschreitung"; +"error.translation.timeout.recovery" = "Bitte versuchen Sie es erneut"; +"error.translation.unsupported.pair" = "Übersetzung von %@ nach %@ wird nicht unterstützt"; +"error.translation.unsupported.pair.recovery" = "Bitte wählen Sie andere Sprachen"; +"error.translation.failed" = "Übersetzung fehlgeschlagen"; +"error.translation.failed.recovery" = "Bitte versuchen Sie es erneut"; +"error.translation.language.not.installed" = "Übersetzungssprache '%@' ist nicht installiert"; +"error.translation.language.download.instructions" = "Gehen Sie zu Systemeinstellungen > Allgemein > Sprache & Region > Übersetzungssprachen und laden Sie die erforderliche Sprache herunter."; + +/* Allgemeine Fehler-Benutzeroberfläche */ +"error.title" = "Fehler"; +"error.ok" = "OK"; +"error.dismiss" = "Schließen"; +"error.retry.capture" = "Wiederholen"; +"error.permission.open.settings" = "Systemeinstellungen öffnen"; + + +/* ======================================== + Menüpunkte + ======================================== */ + +"menu.capture.full.screen" = "Vollbild aufnehmen"; +"menu.capture.fullscreen" = "Vollbild aufnehmen"; +"menu.capture.selection" = "Auswahl aufnehmen"; +"menu.translation.mode" = "Übersetzungsmodus"; +"menu.translation.history" = "Übersetzungsverlauf"; +"menu.settings" = "Einstellungen..."; +"menu.about" = "Über ScreenTranslate"; +"menu.quit" = "ScreenTranslate beenden"; + + +/* ======================================== + Anzeigeauswahl + ======================================== */ + +"display.selector.title" = "Display auswählen"; +"display.selector.header" = "Zu aufnehmendes Display wählen:"; +"display.selector.cancel" = "Abbrechen"; + + +/* ======================================== + Vorschaufenster + ======================================== */ + +"preview.window.title" = "Screenshot-Vorschau"; +"preview.title" = "Screenshot-Vorschau"; +"preview.dimensions" = "%d × %d Pixel"; +"preview.file.size" = "ca. %@ %@"; +"preview.screenshot" = "Screenshot"; +"preview.enter.text" = "Text eingeben"; +"preview.image.dimensions" = "Bildabmessungen"; +"preview.estimated.size" = "Geschätzte Dateigröße"; +"preview.edit.label" = "Bearbeiten:"; +"preview.active.tool" = "Aktives Werkzeug"; +"preview.crop.mode.active" = "Zuschneide-Modus aktiv"; + +/* Zuschneiden */ +"preview.crop" = "Zuschneiden"; +"preview.crop.cancel" = "Abbrechen"; +"preview.crop.apply" = "Zuschneiden anwenden"; + +/* Erkannter Text */ +"preview.recognized.text" = "Erkannter Text:"; +"preview.translation" = "Übersetzung:"; +"preview.results.panel" = "Textergebnisse"; +"preview.copy.text" = "Text kopieren"; + +/* Toolbar-Tool-Tipps */ +"preview.tooltip.crop" = "Zuschneiden (C)"; +"preview.tooltip.pin" = "Anheften (P)"; +"preview.tooltip.undo" = "Rückgängig (⌘Z)"; +"preview.tooltip.redo" = "Wiederholen (⌘⇧Z)"; +"preview.tooltip.copy" = "In die Zwischenablage kopieren (⌘C)"; +"preview.tooltip.save" = "Speichern (⌘S)"; +"preview.tooltip.ocr" = "Text erkennen (OCR)"; +"preview.tooltip.confirm" = "In die Zwischenablage kopieren und schließen (Eingabe)"; +"preview.tooltip.dismiss" = "Schließen (Escape)"; +"preview.tooltip.delete" = "Ausgewählte Anmerkung löschen"; + +/* Barrierefreiheits-Bezeichnungen */ +"preview.accessibility.save" = "Screenshot speichern"; +"preview.accessibility.saving" = "Screenshot wird gespeichert"; +"preview.accessibility.confirm" = "Bestätigen und in die Zwischenablage kopieren"; +"preview.accessibility.copying" = "Wird in die Zwischenablage kopiert"; +"preview.accessibility.hint.commandS" = "Befehl S"; +"preview.accessibility.hint.enter" = "Eingabetaste"; + +/* Form-Wechsel */ +"preview.shape.filled" = "Gefüllt"; +"preview.shape.hollow" = "Hohl"; +"preview.shape.toggle.hint" = "Klicken, um zwischen gefüllt und hohl zu wechseln"; + + +/* ======================================== + Anmerkungswerkzeuge + ======================================== */ + +"tool.rectangle" = "Rechteck"; +"tool.freehand" = "Freihand"; +"tool.text" = "Text"; +"tool.arrow" = "Pfeil"; +"tool.ellipse" = "Ellipse"; +"tool.line" = "Linie"; +"tool.highlight" = "Markierung"; +"tool.mosaic" = "Mosaik"; +"tool.numberLabel" = "Nummernbeschriftung"; + + +/* ======================================== + Farben + ======================================== */ + +"color.red" = "Rot"; +"color.orange" = "Orange"; +"color.yellow" = "Gelb"; +"color.green" = "Grün"; +"color.blue" = "Blau"; +"color.purple" = "Lila"; +"color.pink" = "Rosa"; +"color.white" = "Weiß"; +"color.black" = "Schwarz"; +"color.custom" = "Benutzerdefiniert"; + + +/* ======================================== + Aktionen + ======================================== */ + +"action.save" = "Speichern"; +"action.copy" = "Kopieren"; +"action.cancel" = "Abbrechen"; +"action.undo" = "Rückgängig"; +"action.redo" = "Wiederholen"; +"action.delete" = "Löschen"; +"action.clear" = "Löschen"; +"action.reset" = "Zurücksetzen"; +"action.close" = "Schließen"; +"action.done" = "Fertig"; + +/* Schaltflächen */ +"button.ok" = "OK"; +"button.cancel" = "Abbrechen"; +"button.clear" = "Löschen"; +"button.reset" = "Zurücksetzen"; +"button.save" = "Speichern"; +"button.delete" = "Löschen"; +"button.confirm" = "Bestätigen"; + +/* Speicher erfolgreich */ +"save.success.title" = "Erfolgreich gespeichert"; +"save.success.message" = "Gespeichert unter %@"; +"save.with.translations.message" = "Speicherort für das übersetzte Bild wählen"; + +/* Keine Übersetzungen Fehler */ +"error.no.translations" = "Keine Übersetzungen verfügbar. Bitte übersetzen Sie zuerst den Text."; + +/* Kopier erfolgreich */ +"copy.success.message" = "In die Zwischenablage kopiert"; + + +/* ======================================== + Einstellungsfenster + ======================================== */ + +"settings.window.title" = "ScreenTranslate-Einstellungen"; +"settings.title" = "ScreenTranslate-Einstellungen"; + +/* Einstellungen-Tabs/Abschnitte */ +"settings.section.permissions" = "Berechtigungen"; +"settings.section.general" = "Allgemein"; +"settings.section.engines" = "Engines"; +"settings.section.prompts" = "Prompt-Konfiguration"; +"settings.section.languages" = "Sprachen"; +"settings.section.export" = "Export"; +"settings.section.shortcuts" = "Tastaturkürzel"; +"settings.section.text.translation" = "Textübersetzung"; +"settings.section.annotations" = "Anmerkungen"; + +/* Spracheinstellungen */ +"settings.language" = "Sprache"; +"settings.language.system" = "Systemstandard"; +"settings.language.restart.hint" = "Einige Änderungen erfordern möglicherweise einen Neustart"; + +/* Berechtigungen */ +"settings.permission.screen.recording" = "Bildschirmaufnahme"; +"settings.permission.screen.recording.hint" = "Erforderlich für Screenshots"; +"settings.permission.accessibility" = "Bedienungshilfe"; +"settings.permission.accessibility.hint" = "Erforderlich für globale Tastenkürzel"; +"settings.permission.granted" = "Erteilt"; +"settings.permission.not.granted" = "Nicht erteilt"; +"settings.permission.grant" = "Zugriff gewähren"; +"settings.permission.authorization.title" = "Autorisierung erforderlich"; +"settings.permission.authorization.cancel" = "Abbrechen"; +"settings.permission.authorization.go" = "Autorisieren"; +"settings.permission.authorization.screen.message" = "Bildschirmaufnahme-Berechtigung ist erforderlich. Klicken Sie auf 'Autorisieren', um die Systemeinstellungen zu öffnen und ScreenCapture für diese App zu aktivieren."; +"settings.permission.authorization.accessibility.message" = "Bedienungshilfe-Berechtigung ist erforderlich. Klicken Sie auf 'Autorisieren', um die Systemeinstellungen zu öffnen und diese App zur Bedienungshilfe-Liste hinzuzufügen."; + +/* Speicherort */ +"settings.save.location" = "Speicherort"; +"settings.save.location.choose" = "Auswählen..."; +"settings.save.location.select" = "Auswählen"; +"settings.save.location.message" = "Wählen Sie den Standardspeicherort für Screenshots"; +"settings.save.location.reveal" = "Im Finder anzeigen"; + +/* Exportformat */ +"settings.format" = "Standardformat"; +"settings.format.png" = "PNG"; +"settings.format.jpeg" = "JPEG"; +"settings.format.heic" = "HEIC"; +"settings.jpeg.quality" = "JPEG-Qualität"; +"settings.jpeg.quality.hint" = "Höhere Qualität führt zu größeren Dateien"; +"settings.heic.quality" = "HEIC-Qualität"; +"settings.heic.quality.hint" = "HEIC bietet bessere Komprimierung"; + +/* Tastaturkürzel */ +"settings.shortcuts" = "Tastaturkürzel"; +"settings.shortcut.fullscreen" = "Vollbild-Aufnahme"; +"settings.shortcut.selection" = "Auswahl-Aufnahme"; +"settings.shortcut.translation.mode" = "Übersetzungsmodus"; +"settings.shortcut.text.selection.translation" = "Textauswahl-Übersetzung"; +"settings.shortcut.translate.and.insert" = "Übersetzen und Einfügen"; +"settings.shortcut.recording" = "Tasten drücken..."; +"settings.shortcut.reset" = "Auf Standard zurücksetzen"; +"settings.shortcut.error.no.modifier" = "Tastenkürzel müssen Befehl, Steuerung oder Option enthalten"; +"settings.shortcut.error.conflict" = "Dieses Tastenkürzel wird bereits verwendet"; + +/* Anmerkungen */ +"settings.annotations" = "Anmerkungsstandards"; +"settings.stroke.color" = "Strichfarbe"; +"settings.stroke.width" = "Strichbreite"; +"settings.text.size" = "Textgröße"; +"settings.mosaic.blockSize" = "Mosaik-Blockgröße"; + +/* Engines */ +"settings.ocr.engine" = "OCR-Engine"; +"settings.translation.engine" = "Übersetzungs-Engine"; +"settings.translation.mode" = "Übersetzungsmodus"; + +/* Zurücksetzen */ +"settings.reset.all" = "Alle auf Standard zurücksetzen"; + +/* Fehler */ +"settings.error.title" = "Fehler"; +"settings.error.ok" = "OK"; + + +/* ======================================== + OCR-Engines + ======================================== */ + +"ocr.engine.vision" = "Apple Vision"; +"ocr.engine.vision.description" = "Integriertes macOS-Vision-Framework, schnell und privat"; +"ocr.engine.paddleocr" = "PaddleOCR"; +"ocr.engine.paddleocr.description" = "Selbst gehosteter OCR-Server für bessere Genauigkeit"; + + +/* ======================================== + Übersetzungs-Engines + ======================================== */ + +"translation.engine.apple" = "Apple-Übersetzung"; +"translation.engine.apple.description" = "Integrierte macOS-Übersetzung, keine Einrichtung erforderlich"; +"translation.engine.mtran" = "MTranServer"; +"translation.engine.mtran.description" = "Selbst gehosteter Übersetzungsserver"; + +/* Neue Übersetzungs-Engines */ +"translation.engine.openai" = "OpenAI"; +"translation.engine.openai.description" = "GPT-4-Übersetzung über die OpenAI-API"; +"translation.engine.claude" = "Claude"; +"translation.engine.claude.description" = "Claude-Übersetzung über die Anthropic-API"; +"translation.engine.gemini" = "Gemini"; +"translation.engine.gemini.description" = "Gemini-Übersetzung über die Google AI API"; +"translation.engine.ollama" = "Ollama"; +"translation.engine.ollama.description" = "Lokale LLM-Übersetzung über Ollama"; +"translation.engine.google" = "Google Übersetzer"; +"translation.engine.google.description" = "Google Cloud Translation API"; +"translation.engine.deepl" = "DeepL"; +"translation.engine.deepl.description" = "Hochwertige Übersetzung über die DeepL-API"; +"translation.engine.baidu" = "Baidu Übersetzer"; +"translation.engine.baidu.description" = "Baidu-Übersetzungs-API"; +"translation.engine.custom" = "OpenAI-kompatibel"; +"translation.engine.custom.description" = "Benutzerdefinierter OpenAI-kompatibler Endpunkt"; + +/* Engine-Kategorien */ +"engine.category.builtin" = "Integriert"; +"engine.category.llm" = "LLM-Übersetzung"; +"engine.category.cloud" = "Cloud-Dienste"; +"engine.category.compatible" = "Kompatibel"; + +/* Engine-Konfigurationstitel */ +"engine.config.title" = "Übersetzungs-Engine-Konfiguration"; + +/* Engine-Auswahlmodi */ +"engine.selection.mode.title" = "Engine-Auswahlmodus"; +"engine.selection.mode.primary_fallback" = "Primär/Fallback"; +"engine.selection.mode.primary_fallback.description" = "Primäre Engine verwenden, bei Fehlern auf Fallback zurückgreifen"; +"engine.selection.mode.parallel" = "Parallel"; +"engine.selection.mode.parallel.description" = "Mehrere Engines gleichzeitig ausführen und Ergebnisse vergleichen"; +"engine.selection.mode.quick_switch" = "Schnellwechsel"; +"engine.selection.mode.quick_switch.description" = "Mit primärer Engine starten, bei Bedarf schnell zu anderen Engines wechseln"; +"engine.selection.mode.scene_binding" = "Szenen-Bindung"; +"engine.selection.mode.scene_binding.description" = "Verschiedene Engines für verschiedene Übersetzungsszenarien verwenden"; + +/* Modus-spezifische Bezeichnungen */ +"engine.config.primary" = "Primär"; +"engine.config.fallback" = "Fallback"; +"engine.config.switch.order" = "Wechselreihenfolge"; +"engine.config.parallel.select" = "Engines für Parallelbetrieb auswählen"; +"engine.config.replace" = "Engine ersetzen"; +"engine.config.remove" = "Entfernen"; +"engine.config.add" = "Engine hinzufügen"; + +/* Übersetzungsszenarien */ +"translation.scene.screenshot" = "Screenshot-Übersetzung"; +"translation.scene.screenshot.description" = "OCR und Übersetzung von erfassten Screenshot-Bereichen"; +"translation.scene.text_selection" = "Textauswahl-Übersetzung"; +"translation.scene.text_selection.description" = "Übersetzung von ausgewähltem Text aus jeder Anwendung"; +"translation.scene.translate_and_insert" = "Übersetzen und Einfügen"; +"translation.scene.translate_and_insert.description" = "Zwischenablagentext übersetzen und an Cursorposition einfügen"; + +/* Engine-Konfiguration */ +"engine.config.enabled" = "Diese Engine aktivieren"; +"engine.config.apiKey" = "API-Schlüssel"; +"engine.config.apiKey.placeholder" = "Geben Sie Ihren API-Schlüssel ein"; +"engine.config.getApiKey" = "API-Schlüssel erhalten"; +"engine.config.baseURL" = "Basis-URL"; +"engine.config.model" = "Modellname"; +"engine.config.test" = "Verbindung testen"; +"engine.config.test.success" = "Verbindung erfolgreich"; +"engine.config.test.failed" = "Verbindung fehlgeschlagen"; +"engine.config.baidu.credentials" = "Baidu-Anmeldedaten"; +"engine.config.baidu.appID" = "App-ID"; +"engine.config.baidu.secretKey" = "Geheimer Schlüssel"; +"engine.config.mtran.url" = "Server-URL"; + +/* Engine-Status */ +"engine.status.configured" = "Konfiguriert"; +"engine.status.unconfigured" = "Nicht konfiguriert"; +"engine.available.title" = "Verfügbare Engines"; +"engine.parallel.title" = "Parallel-Engines"; +"engine.parallel.description" = "Engines auswählen, die im Parallelmodus gleichzeitig ausgeführt werden sollen"; +"engine.scene.binding.title" = "Szenen-Engine-Bindung"; +"engine.scene.binding.description" = "Konfigurieren, welche Engine für jedes Übersetzungsszenario verwendet werden soll"; +"engine.scene.fallback.tooltip" = "Fallback auf andere Engines aktivieren"; + +/* Keychain-Fehler */ +"keychain.error.item_not_found" = "Anmeldedaten nicht im Schlüsselbund gefunden"; +"keychain.error.item_not_found.recovery" = "Bitte konfigurieren Sie Ihre API-Anmeldedaten in den Einstellungen"; +"keychain.error.duplicate_item" = "Anmeldedaten bereits im Schlüsselbund vorhanden"; +"keychain.error.duplicate_item.recovery" = "Versuchen Sie zuerst, die vorhandenen Anmeldedaten zu löschen"; +"keychain.error.invalid_data" = "Ungültiges Format der Anmeldedaten"; +"keychain.error.invalid_data.recovery" = "Versuchen Sie, Ihre Anmeldedaten erneut einzugeben"; +"keychain.error.unexpected_status" = "Schlüsselbund-Operation fehlgeschlagen"; +"keychain.error.unexpected_status.recovery" = "Bitte überprüfen Sie Ihre Schlüsselbund-Zugriffsrechte"; + +/* Multi-Engine-Fehler */ +"multiengine.error.all_failed" = "Alle Übersetzungs-Engines sind fehlgeschlagen"; +"multiengine.error.no_engines" = "Keine Übersetzungs-Engines konfiguriert"; +"multiengine.error.primary_unavailable" = "Primäre Engine %@ ist nicht verfügbar"; +"multiengine.error.no_results" = "Keine Übersetzungsergebnisse verfügbar"; + +/* Registrierungsfehler */ +"registry.error.already_registered" = "Anbieter bereits registriert"; +"registry.error.not_registered" = "Kein Anbieter für %@ registriert"; +"registry.error.config_missing" = "Konfiguration für %@ fehlt"; +"registry.error.credentials_not_found" = "Anmeldedaten für %@ nicht gefunden"; + +/* Prompt-Konfiguration */ +"prompt.engine.title" = "Engine-Prompts"; +"prompt.engine.description" = "Übersetzungsprompts für jede LLM-Engine anpassen"; +"prompt.scene.title" = "Szenario-Prompts"; +"prompt.scene.description" = "Übersetzungsprompts für jedes Übersetzungsszenario anpassen"; +"prompt.default.title" = "Standard-Prompt-Vorlage"; +"prompt.default.description" = "Diese Vorlage wird verwendet, wenn kein benutzerdefinierter Prompt konfiguriert ist"; +"prompt.button.edit" = "Bearbeiten"; +"prompt.button.reset" = "Zurücksetzen"; +"prompt.editor.title" = "Prompt bearbeiten"; +"prompt.editor.variables" = "Verfügbare Variablen:"; +"prompt.variable.source_language" = "Name der Ausgangssprache"; +"prompt.variable.target_language" = "Name der Zielsprache"; +"prompt.variable.text" = "Zu übersetzender Text"; + + +/* ======================================== + Übersetzungsmodi + ======================================== */ + +"translation.mode.inline" = "In-Place-Ersetzung"; +"translation.mode.inline.description" = "Originaltext durch Übersetzung ersetzen"; +"translation.mode.below" = "Unter dem Original"; +"translation.mode.below.description" = "Übersetzung unter dem Originaltext anzeigen"; + + +/* ======================================== + Übersetzungseinstellungen + ======================================== */ + +"translation.auto" = "Automatisch erkennen"; +"translation.auto.detected" = "Automatisch erkannt"; +"translation.language.follow.system" = "System folgen"; +"translation.language.source" = "Ausgangssprache"; +"translation.language.target" = "Zielsprache"; +"translation.language.source.hint" = "Die Sprache des zu übersetzenden Textes"; +"translation.language.target.hint" = "Die Sprache, in die übersetzt werden soll"; + + +/* ======================================== + Verlaufsansicht + ======================================== */ + +"history.title" = "Übersetzungsverlauf"; +"history.search.placeholder" = "Verlauf durchsuchen..."; +"history.clear.all" = "Gesamten Verlauf löschen"; +"history.empty.title" = "Kein Übersetzungsverlauf"; +"history.empty.message" = "Ihre übersetzten Screenshots erscheinen hier"; +"history.no.results.title" = "Keine Ergebnisse"; +"history.no.results.message" = "Keine Einträge entsprechen Ihrer Suche"; +"history.clear.search" = "Suche löschen"; + +"history.source" = "Original"; +"history.translation" = "Übersetzung"; +"history.truncated" = "gekürzt"; + +"history.copy.translation" = "Übersetzung kopieren"; +"history.copy.source" = "Original kopieren"; +"history.copy.both" = "Beides kopieren"; +"history.delete" = "Löschen"; + +"history.clear.alert.title" = "Verlauf löschen"; +"history.clear.alert.message" = "Möchten Sie wirklich den gesamten Übersetzungsverlauf löschen? Diese Aktion kann nicht rückgängig gemacht werden."; + + +/* ======================================== + Berechtigungsabfrage + ======================================== */ + +"permission.prompt.title" = "Bildschirmaufnahme-Berechtigung erforderlich"; +"permission.prompt.message" = "ScreenTranslate benötigt die Berechtigung, Ihren Bildschirm aufzunehmen. Dies ist für Screenshots erforderlich.\n\nNach dem Klicken auf 'Fortsetzen' wird macOS Sie auffordern, die Bildschirmaufnahme-Berechtigung zu erteilen. Sie können sie in Systemeinstellungen > Datenschutz & Sicherheit > Bildschirmaufnahme erteilen."; +"permission.prompt.continue" = "Fortfahren"; +"permission.prompt.later" = "Später"; + +/* Bedienungshilfe-Berechtigung */ +"permission.accessibility.title" = "Bedienungshilfe-Berechtigung erforderlich"; +"permission.accessibility.message" = "ScreenTranslate benötigt die Bedienungshilfe-Berechtigung, um ausgewählten Text zu erfassen und Übersetzungen einzufügen.\n\nDies ermöglicht der App:\n• Ausgewählten Text aus jeder Anwendung zu kopieren\n• Übersetzten Text in Eingabefelder einzufügen\n\nIhre Privatsphäre ist geschützt - ScreenTranslate nutzt dies nur für Textübersetzung."; +"permission.accessibility.grant" = "Berechtigung erteilen"; +"permission.accessibility.open.settings" = "Systemeinstellungen öffnen"; +"permission.accessibility.denied.title" = "Bedienungshilfe-Berechtigung erforderlich"; +"permission.accessibility.denied.message" = "Texterfassung und -einfügung erfordert die Bedienungshilfe-Berechtigung.\n\nBitte erteilen Sie die Berechtigung in Systemeinstellungen > Datenschutz & Sicherheit > Bedienungshilfe."; + +/* Eingabeüberwachungs-Berechtigung */ +"permission.input.monitoring.title" = "Eingabeüberwachungs-Berechtigung erforderlich"; +"permission.input.monitoring.message" = "ScreenTranslate benötigt die Eingabeüberwachungs-Berechtigung, um übersetzten Text in Anwendungen einzufügen.\n\nSie müssen diese in folgendem Ort aktivieren:\nSystemeinstellungen > Datenschutz & Sicherheit > Eingabeüberwachung"; +"permission.input.monitoring.open.settings" = "Systemeinstellungen öffnen"; +"permission.input.monitoring.denied.title" = "Eingabeüberwachungs-Berechtigung erforderlich"; +"permission.input.monitoring.denied.message" = "Texteinfügung erfordert die Eingabeüberwachungs-Berechtigung.\n\nBitte erteilen Sie die Berechtigung in Systemeinstellungen > Datenschutz & Sicherheit > Eingabeüberwachung."; + +/* Allgemeine Berechtigungs-Strings */ +"permission.open.settings" = "Systemeinstellungen öffnen"; + + +/* ======================================== + Einrichtung + ======================================== */ + +"onboarding.window.title" = "Willkommen bei ScreenTranslate"; + +/* Einrichtung - Willkommensschritt */ +"onboarding.welcome.title" = "Willkommen bei ScreenTranslate"; +"onboarding.welcome.message" = "Lassen Sie uns Bildschirmaufnahme und Übersetzungsfunktionen für Sie einrichten. Das dauert nur eine Minute."; + +"onboarding.feature.local.ocr.title" = "Lokale OCR"; +"onboarding.feature.local.ocr.description" = "macOS-Vision-Framework für schnelle, private Texterkennung"; +"onboarding.feature.local.translation.title" = "Lokale Übersetzung"; +"onboarding.feature.local.translation.description" = "Apple-Übersetzung für sofortige Offline-Übersetzung"; +"onboarding.feature.shortcuts.title" = "Globale Tastenkürzel"; +"onboarding.feature.shortcuts.description" = "Überall mit Tastenkürzeln aufnehmen und übersetzen"; + +/* Einrichtung - Berechtigungsschritt */ +"onboarding.permissions.title" = "Berechtigungen"; +"onboarding.permissions.message" = "ScreenTranslate benötigt einige Berechtigungen, um ordnungsgemäß zu funktionieren. Bitte erteilen Sie die folgenden Berechtigungen:"; +"onboarding.permissions.hint" = "Nach Erteilung der Berechtigungen wird der Status automatisch aktualisiert."; + +"onboarding.permission.screen.recording" = "Bildschirmaufnahme"; +"onboarding.permission.accessibility" = "Bedienungshilfe"; +"onboarding.permission.granted" = "Erteilt"; +"onboarding.permission.not.granted" = "Nicht erteilt"; +"onboarding.permission.grant" = "Berechtigung erteilen"; + +/* Einrichtung - Konfigurationsschritt */ +"onboarding.configuration.title" = "Optionale Konfiguration"; +"onboarding.configuration.message" = "Ihre lokalen OCR- und Übersetzungsfunktionen sind bereits aktiviert. Optional können Sie externe Dienste konfigurieren:"; +"onboarding.configuration.paddleocr" = "PaddleOCR-Serveradresse"; +"onboarding.configuration.paddleocr.hint" = "Leer lassen, um macOS-Vision-OCR zu verwenden"; +"onboarding.configuration.mtran" = "MTranServer-Adresse"; +"onboarding.configuration.mtran.hint" = "Leer lassen, um Apple-Übersetzung zu verwenden"; +"onboarding.configuration.placeholder" = "http://localhost:8080"; +"onboarding.configuration.placeholder.address" = "localhost"; +"onboarding.configuration.test" = "Übersetzung testen"; +"onboarding.configuration.test.button" = "Übersetzung testen"; +"onboarding.configuration.testing" = "Wird getestet..."; +"onboarding.test.success" = "Übersetzungstest erfolgreich: \"%@\" → \"%@\""; +"onboarding.test.failed" = "Übersetzungstest fehlgeschlagen: %@"; + +/* Einrichtung - Fertigstellungsstep */ +"onboarding.complete.title" = "Alles fertig!"; +"onboarding.complete.message" = "ScreenTranslate ist einsatzbereit. So können Sie loslegen:"; +"onboarding.complete.shortcuts" = "Verwenden Sie ⌘⇧F für Vollbild-Aufnahme"; +"onboarding.complete.selection" = "Verwenden Sie ⌘⇧A für Auswahl-Aufnahme und Übersetzung"; +"onboarding.complete.settings" = "Öffnen Sie die Einstellungen von der Menüleiste, um Optionen anzupassen"; +"onboarding.complete.start" = "ScreenTranslate verwenden"; + +/* Einrichtung - Navigation */ +"onboarding.back" = "Zurück"; +"onboarding.continue" = "Fortfahren"; +"onboarding.next" = "Weiter"; +"onboarding.skip" = "Überspringen"; +"onboarding.complete" = "Fertigstellen"; + +/* Einrichtung - PaddleOCR */ +"onboarding.paddleocr.title" = "PaddleOCR (Optional)"; +"onboarding.paddleocr.description" = "Verbesserte OCR-Engine für bessere Texterkennungsgenauigkeit, besonders für Chinesisch."; +"onboarding.paddleocr.installed" = "Installiert"; +"onboarding.paddleocr.not.installed" = "Nicht installiert"; +"onboarding.paddleocr.install" = "Installieren"; +"onboarding.paddleocr.installing" = "Wird installiert..."; +"onboarding.paddleocr.install.hint" = "Erfordert Python 3 und pip. Führen Sie aus: pip3 install paddleocr paddlepaddle"; +"onboarding.paddleocr.copy.command" = "Befehl kopieren"; +"onboarding.paddleocr.refresh" = "Status aktualisieren"; +"onboarding.paddleocr.version" = "Version: %@"; + +/* Einstellungen - PaddleOCR */ +"settings.paddleocr.installed" = "Installiert"; +"settings.paddleocr.not.installed" = "Nicht installiert"; +"settings.paddleocr.install" = "Installieren"; +"settings.paddleocr.installing" = "Wird installiert..."; +"settings.paddleocr.install.hint" = "Erfordert Python 3 und pip auf Ihrem System."; +"settings.paddleocr.copy.command" = "Befehl kopieren"; +"settings.paddleocr.refresh" = "Status aktualisieren"; +"settings.paddleocr.ready" = "PaddleOCR ist bereit"; +"settings.paddleocr.not.installed.message" = "PaddleOCR ist nicht installiert"; +"settings.paddleocr.description" = "PaddleOCR ist eine lokale OCR-Engine. Sie ist kostenlos, funktioniert offline und erfordert keinen API-Schlüssel."; +"settings.paddleocr.install.button" = "PaddleOCR installieren"; +"settings.paddleocr.copy.command.button" = "Installationsbefehl kopieren"; +"settings.paddleocr.mode" = "Modus"; +"settings.paddleocr.mode.fast" = "Schnell"; +"settings.paddleocr.mode.precise" = "Präzise"; +"settings.paddleocr.mode.fast.description" = "~1s, schnelle OCR mit Zeilengruppierung"; +"settings.paddleocr.mode.precise.description" = "~12s, VL-1.5-Modell mit höherer Genauigkeit"; +"settings.paddleocr.useCloud" = "Cloud-API verwenden"; +"settings.paddleocr.cloudBaseURL" = "Cloud-API-URL"; +"settings.paddleocr.cloudAPIKey" = "API-Schlüssel"; +"settings.paddleocr.cloudModelId" = "Modell-ID"; +"settings.paddleocr.localVLModelDir" = "Lokales Modellverzeichnis (vllm)"; +"settings.paddleocr.localVLModelDir.hint" = "Pfad zum lokalen PaddleOCR-VL-Modell (z. B. ~/.paddlex/official_models/PaddleOCR-VL-1.5)"; +"error.paddleocr.notInstalled" = "PaddleOCR ist nicht installiert. Installieren Sie es mit: pip3 install paddleocr paddlepaddle"; + + +/* ======================================== + VLM-Konfiguration + ======================================== */ + +"settings.vlm.title" = "VLM-Konfiguration"; +"settings.vlm.provider" = "Anbieter"; +"settings.vlm.apiKey" = "API-Schlüssel"; +"settings.vlm.apiKey.optional" = "API-Schlüssel ist optional für lokale Anbieter"; +"settings.vlm.baseURL" = "Basis-URL"; +"settings.vlm.model" = "Modellname"; +"settings.vlm.test.button" = "Verbindung testen"; +"settings.vlm.test.success" = "Verbindung erfolgreich! Modell: %@"; +"settings.vlm.test.ollama.success" = "Server läuft. Modell '%@' verfügbar"; +"settings.vlm.test.ollama.available" = "Server läuft. Verfügbar: %@"; + +"vlm.provider.openai" = "OpenAI"; +"vlm.provider.claude" = "Claude"; +"vlm.provider.glmocr" = "GLM OCR"; +"vlm.provider.ollama" = "Ollama"; +"vlm.provider.paddleocr" = "PaddleOCR"; +"vlm.provider.openai.description" = "OpenAI GPT-4 Vision API"; +"vlm.provider.claude.description" = "Anthropic Claude Vision API"; +"vlm.provider.glmocr.description" = "Zhipu GLM-OCR API zur Layoutanalyse"; +"vlm.provider.ollama.description" = "Lokaler Ollama-Server"; +"vlm.provider.paddleocr.description" = "Lokale OCR-Engine (kostenlos, offline)"; + + +/* ======================================== + Übersetzungsworkflow-Konfiguration + ======================================== */ + +"settings.translation.workflow.title" = "Übersetzungs-Engine"; +"settings.translation.preferred" = "Bevorzugte Engine"; +"settings.translation.mtran.url" = "MTransServer-URL"; +"settings.translation.mtran.test.button" = "Verbindung testen"; +"settings.translation.mtran.test.success" = "Verbindung erfolgreich"; +"settings.translation.mtran.test.failed" = "Verbindung fehlgeschlagen: %@"; +"settings.translation.fallback" = "Fallback"; +"settings.translation.fallback.description" = "Apple-Übersetzung als Fallback verwenden, wenn bevorzugte Engine fehlschlägt"; + +"translation.preferred.apple.description" = "Integrierte macOS-Übersetzung, funktioniert offline"; +"translation.preferred.mtran.description" = "Selbst gehosteter Übersetzungsserver für bessere Qualität"; + + +/* ======================================== + Barrierefreiheits-Bezeichnungen + ======================================== */ + +"accessibility.close.button" = "Schließen"; +"accessibility.settings.button" = "Einstellungen"; +"accessibility.capture.button" = "Aufnehmen"; +"accessibility.translate.button" = "Übersetzen"; + + +/* ======================================== + Zweisprachiges Ergebnisfenster + ======================================== */ + +/* ======================================== + Übersetzungsablauf + ======================================== */ + +"translationFlow.phase.idle" = "Bereit"; +"translationFlow.phase.analyzing" = "Bild wird analysiert..."; +"translationFlow.phase.translating" = "Wird übersetzt..."; +"translationFlow.phase.rendering" = "Wird gerendert..."; +"translationFlow.phase.completed" = "Abgeschlossen"; +"translationFlow.phase.failed" = "Fehlgeschlagen"; + +"translationFlow.error.title" = "Übersetzungsfehler"; +"translationFlow.error.title.analysis" = "Bilderkennung fehlgeschlagen"; +"translationFlow.error.title.translation" = "Übersetzung fehlgeschlagen"; +"translationFlow.error.title.rendering" = "Rendern fehlgeschlagen"; +"translationFlow.error.unknown" = "Ein unbekannter Fehler ist aufgetreten."; +"translationFlow.error.analysis" = "Analyse fehlgeschlagen: %@"; +"translationFlow.error.translation" = "Übersetzung fehlgeschlagen: %@"; +"translationFlow.error.rendering" = "Rendern fehlgeschlagen: %@"; +"translationFlow.error.cancelled" = "Übersetzung wurde abgebrochen."; +"translationFlow.error.noTextFound" = "Kein Text im ausgewählten Bereich gefunden."; +"translationFlow.error.translation.engine" = "Übersetzungs-Engine"; + +"translationFlow.recovery.analysis" = "Bitte versuchen Sie es erneut mit einem klareren Bild oder überprüfen Sie Ihre VLM-Anbieter-Einstellungen."; +"translationFlow.recovery.translation" = "Überprüfen Sie Ihre Übersetzungs-Engine-Einstellungen und Netzwerkverbindung und versuchen Sie es erneut."; +"translationFlow.recovery.rendering" = "Bitte versuchen Sie es erneut."; +"translationFlow.recovery.noTextFound" = "Versuchen Sie, einen Bereich mit sichtbarem Text auszuwählen."; + +"common.ok" = "OK"; + + +/* ======================================== + Zweisprachiges Ergebnisfenster + ======================================== */ + +"bilingualResult.window.title" = "Zweisprachige Übersetzung"; +"bilingualResult.loading" = "Wird übersetzt..."; +"bilingualResult.loading.analyzing" = "Bild wird analysiert..."; +"bilingualResult.loading.translating" = "Text wird übersetzt..."; +"bilingualResult.loading.rendering" = "Ergebnis wird gerendert..."; +"bilingualResult.copyImage" = "Bild kopieren"; +"bilingualResult.copyText" = "Text kopieren"; +"bilingualResult.save" = "Speichern"; +"bilingualResult.zoomIn" = "Vergrößern"; +"bilingualResult.zoomOut" = "Verkleinern"; +"bilingualResult.resetZoom" = "Zoom zurücksetzen"; +"bilingualResult.copySuccess" = "In die Zwischenablage kopiert"; +"bilingualResult.copyTextSuccess" = "Übersetzungstext kopiert"; +"bilingualResult.saveSuccess" = "Erfolgreich gespeichert"; +"bilingualResult.copyFailed" = "Fehler beim Kopieren des Bildes"; +"bilingualResult.saveFailed" = "Fehler beim Speichern des Bildes"; +"bilingualResult.noTextToCopy" = "Kein Übersetzungstext zum Kopieren"; + + +/* ======================================== + Textübersetzung (US-003 bis US-010) + ======================================== */ + +/* Textübersetzungsablauf */ +"textTranslation.phase.idle" = "Bereit"; +"textTranslation.phase.translating" = "Wird übersetzt..."; +"textTranslation.phase.completed" = "Abgeschlossen"; +"textTranslation.phase.failed" = "Fehlgeschlagen"; + +"textTranslation.error.emptyInput" = "Kein Text zum Übersetzen"; +"textTranslation.error.translationFailed" = "Übersetzung fehlgeschlagen: %@"; +"textTranslation.error.cancelled" = "Übersetzung wurde abgebrochen"; +"textTranslation.error.serviceUnavailable" = "Übersetzungsdienst nicht verfügbar"; +"textTranslation.error.insertFailed" = "Übersetzter Text konnte nicht eingefügt werden"; + +"textTranslation.recovery.emptyInput" = "Bitte wählen Sie zuerst Text aus"; +"textTranslation.recovery.translationFailed" = "Bitte versuchen Sie es erneut"; +"textTranslation.recovery.serviceUnavailable" = "Überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut"; + +"textTranslation.loading" = "Wird übersetzt..."; +"textTranslation.noSelection.title" = "Kein Text ausgewählt"; +"textTranslation.noSelection.message" = "Bitte wählen Sie Text in einer Anwendung aus und versuchen Sie es erneut."; + +/* Übersetzen und Einfügen */ +"translateAndInsert.emptyClipboard.title" = "Zwischenablage ist leer"; +"translateAndInsert.emptyClipboard.message" = "Kopieren Sie zuerst Text in die Zwischenablage, und verwenden Sie dann dieses Tastenkürzel."; +"translateAndInsert.success.title" = "Übersetzung eingefügt"; +"translateAndInsert.success.message" = "Der übersetzte Text wurde in das aktive Eingabefeld eingefügt."; + +/* Sprachanzeige */ +"language.auto" = "Automatisch erkannt"; + +/* Allgemeine Textübersetzungs-Benutzeroberfläche */ +"common.copy" = "Kopieren"; +"common.copied" = "Kopiert"; +"common.insert" = "Einfügen"; + +/* Textübersetzungsfenster */ +"textTranslation.window.title" = "Textübersetzung"; + +/* Übersetzen und Einfügen - Spracheinstellungen */ +"settings.translateAndInsert.language.section" = "Übersetzen- und Einfügen-Sprachen"; +"settings.translateAndInsert.language.source" = "Ausgangssprache"; +"settings.translateAndInsert.language.target" = "Zielsprache"; + + +/* ======================================== + Mehrere OpenAI-kompatible Engines + ======================================== */ + +/* Kompatible Engine-Konfiguration */ +"engine.compatible.new" = "Neue kompatible Engine"; +"engine.compatible.description" = "OpenAI-kompatible API-Schnittstelle"; +"engine.compatible.displayName" = "Anzeigename"; +"engine.compatible.displayName.placeholder" = "z. B. Mein LLM-Server"; +"engine.compatible.requireApiKey" = "Erfordert API-Schlüssel"; +"engine.compatible.add" = "Kompatible Engine hinzufügen"; +"engine.compatible.delete" = "Diese Engine löschen"; +"engine.compatible.useAsEngine" = "Als Übersetzungs-Engine verwenden"; +"engine.compatible.max.reached" = "Maximum von 5 kompatiblen Engines erreicht"; + +/* Prompt-Konfiguration */ +"prompt.compatible.title" = "Kompatible Engines"; + + +/* ======================================== + Info-Fenster + ======================================== */ + +"about.title" = "Über ScreenTranslate"; +"about.app.name" = "ScreenTranslate"; +"about.version.format" = "Version %@ (%@)"; +"about.copyright" = "Urheberrecht"; +"about.copyright.value" = "© 2026 Alle Rechte vorbehalten"; +"about.license" = "Lizenz"; +"about.license.value" = "MIT-Lizenz"; +"about.github.link" = "GitHub: hubo1989/ScreenTranslate"; +"about.check.for.updates" = "Nach Updates suchen"; +"about.update.checking" = "Wird überprüft..."; +"about.update.available" = "Update verfügbar"; +"about.update.uptodate" = "Aktuell"; +"about.update.failed" = "Überprüfung fehlgeschlagen"; +"about.acknowledgements" = "Danksagungen"; +"about.acknowledgements.title" = "Danksagungen"; +"about.acknowledgements.intro" = "Diese Software verwendet die folgenden Open-Source-Bibliotheken:"; +"about.acknowledgements.upstream" = "Basiert auf"; +"about.acknowledgements.author.format" = "von %@"; +"about.close" = "Schließen"; +"settings.glmocr.mode" = "Modus"; +"settings.glmocr.mode.cloud" = "Cloud"; +"settings.glmocr.mode.local" = "Lokal"; +"settings.glmocr.local.apiKey.optional" = "API-Schlüssel ist für lokale MLX-VLM-Server optional"; +"vlm.provider.glmocr.local.description" = "Lokaler MLX-VLM-Server für GLM-OCR"; diff --git a/ScreenTranslate/Resources/en.lproj/Localizable.strings b/ScreenTranslate/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..3c1d01a --- /dev/null +++ b/ScreenTranslate/Resources/en.lproj/Localizable.strings @@ -0,0 +1,823 @@ +/* + Localizable.strings (English) + ScreenTranslate +*/ + +/* ======================================== + Error Messages + ======================================== */ + +/* Permission Errors */ +"error.permission.denied" = "Screen recording permission is required to capture screenshots."; +"error.permission.denied.recovery" = "Open System Settings to grant permission."; + +/* Display Errors */ +"error.display.not.found" = "The selected display is no longer available."; +"error.display.not.found.recovery" = "Please select a different display."; +"error.display.disconnected" = "The display '%@' was disconnected during capture."; +"error.display.disconnected.recovery" = "Please reconnect the display and try again."; + +/* Capture Errors */ +"error.capture.failed" = "Failed to capture the screen."; +"error.capture.failed.recovery" = "Please try again."; + +/* Save Errors */ +"error.save.location.invalid" = "The save location is not accessible."; +"error.save.location.invalid.recovery" = "Choose a different save location in Settings."; +"error.save.location.invalid.detail" = "Cannot save to %@. The location is not accessible."; +"error.save.unknown" = "An unexpected error occurred while saving."; +"error.disk.full" = "There is not enough disk space to save the screenshot."; +"error.disk.full.recovery" = "Free up disk space and try again."; + +/* Export Errors */ +"error.export.encoding.failed" = "Failed to encode the image."; +"error.export.encoding.failed.recovery" = "Try a different format in Settings."; +"error.export.encoding.failed.detail" = "Failed to encode the image as %@."; + +/* Clipboard Errors */ +"error.clipboard.write.failed" = "Failed to copy the screenshot to clipboard."; +"error.clipboard.write.failed.recovery" = "Please try again."; + +/* Hotkey Errors */ +"error.hotkey.registration.failed" = "Failed to register the keyboard shortcut."; +"error.hotkey.registration.failed.recovery" = "The shortcut may conflict with another app. Try a different shortcut."; +"error.hotkey.conflict" = "This keyboard shortcut conflicts with another application."; +"error.hotkey.conflict.recovery" = "Choose a different keyboard shortcut."; + +/* OCR Errors */ +"error.ocr.failed" = "Text recognition failed."; +"error.ocr.failed.recovery" = "Please try again with a clearer image."; +"error.ocr.no.text" = "No text was recognized in the image."; +"error.ocr.no.text.recovery" = "Try capturing an area with visible text."; +"error.ocr.cancelled" = "Text recognition was cancelled."; +"error.ocr.server.unreachable" = "Cannot connect to OCR server."; +"error.ocr.server.unreachable.recovery" = "Check server address and network connection."; + +/* Translation Errors */ +"error.translation.in.progress" = "A translation is already in progress"; +"error.translation.in.progress.recovery" = "Please wait for the current translation to complete"; +"error.translation.empty.input" = "No text to translate"; +"error.translation.empty.input.recovery" = "Please select some text first"; +"error.translation.timeout" = "Translation timed out"; +"error.translation.timeout.recovery" = "Please try again"; +"error.translation.unsupported.pair" = "Translation from %@ to %@ is not supported"; +"error.translation.unsupported.pair.recovery" = "Please select different languages"; +"error.translation.failed" = "Translation failed"; +"error.translation.failed.recovery" = "Please try again"; +"error.translation.language.not.installed" = "Translation language '%@' is not installed"; +"error.translation.language.download.instructions" = "Go to System Settings > General > Language & Region > Translation Languages, then download the required language."; + +/* Generic Error UI */ +"error.title" = "Error"; +"error.ok" = "OK"; +"error.dismiss" = "Dismiss"; +"error.retry.capture" = "Retry"; +"error.permission.open.settings" = "Open System Settings"; + + +/* ======================================== + Menu Items + ======================================== */ + +"menu.capture.full.screen" = "Capture Full Screen"; +"menu.capture.fullscreen" = "Capture Full Screen"; +"menu.capture.selection" = "Capture Selection"; +"menu.translation.mode" = "Translation Mode"; +"menu.translation.history" = "Translation History"; +"menu.settings" = "Settings..."; +"menu.about" = "About ScreenTranslate"; +"menu.quit" = "Quit ScreenTranslate"; + + +/* ======================================== + Display Selector + ======================================== */ + +"display.selector.title" = "Select Display"; +"display.selector.header" = "Choose display to capture:"; +"display.selector.cancel" = "Cancel"; + + +/* ======================================== + Preview Window + ======================================== */ + +"preview.window.title" = "Screenshot Preview"; +"preview.title" = "Screenshot Preview"; +"preview.dimensions" = "%d x %d pixels"; +"preview.file.size" = "~%@ %@"; +"preview.screenshot" = "Screenshot"; +"preview.enter.text" = "Enter text"; +"preview.image.dimensions" = "Image dimensions"; +"preview.estimated.size" = "Estimated file size"; +"preview.edit.label" = "Edit:"; +"preview.active.tool" = "Active tool"; +"preview.crop.mode.active" = "Crop mode active"; + +/* Crop */ +"preview.crop" = "Crop"; +"preview.crop.cancel" = "Cancel"; +"preview.crop.apply" = "Apply Crop"; + +/* Recognized Text */ +"preview.recognized.text" = "Recognized Text:"; +"preview.translation" = "Translation:"; +"preview.results.panel" = "Text Results"; +"preview.copy.text" = "Copy text"; + +/* Toolbar Tooltips */ +"preview.tooltip.crop" = "Crop (C)"; +"preview.tooltip.pin" = "Pin to Screen (P)"; +"preview.tooltip.undo" = "Undo (⌘Z)"; +"preview.tooltip.redo" = "Redo (⌘⇧Z)"; +"preview.tooltip.copy" = "Copy to Clipboard (⌘C)"; +"preview.tooltip.save" = "Save (⌘S)"; +"preview.tooltip.ocr" = "Recognize Text (OCR)"; +"preview.tooltip.confirm" = "Copy to Clipboard and Close (Enter)"; +"preview.tooltip.dismiss" = "Dismiss (Escape)"; +"preview.tooltip.delete" = "Delete selected annotation"; + +/* Accessibility Labels */ +"preview.accessibility.save" = "Save screenshot"; +"preview.accessibility.saving" = "Saving screenshot"; +"preview.accessibility.confirm" = "Confirm and copy to clipboard"; +"preview.accessibility.copying" = "Copying to clipboard"; +"preview.accessibility.hint.commandS" = "Command S"; +"preview.accessibility.hint.enter" = "Enter key"; + +/* Shape Toggle */ +"preview.shape.filled" = "Filled"; +"preview.shape.hollow" = "Hollow"; +"preview.shape.toggle.hint" = "Click to toggle between filled and hollow"; + + +/* ======================================== + Annotation Tools + ======================================== */ + +"tool.rectangle" = "Rectangle"; +"tool.freehand" = "Freehand"; +"tool.text" = "Text"; +"tool.arrow" = "Arrow"; +"tool.ellipse" = "Ellipse"; +"tool.line" = "Line"; +"tool.highlight" = "Highlight"; +"tool.mosaic" = "Mosaic"; +"tool.numberLabel" = "Number Label"; + + +/* ======================================== + Colors + ======================================== */ + +"color.red" = "Red"; +"color.orange" = "Orange"; +"color.yellow" = "Yellow"; +"color.green" = "Green"; +"color.blue" = "Blue"; +"color.purple" = "Purple"; +"color.pink" = "Pink"; +"color.white" = "White"; +"color.black" = "Black"; +"color.custom" = "Custom"; + + +/* ======================================== + Actions + ======================================== */ + +"action.save" = "Save"; +"action.copy" = "Copy"; +"action.cancel" = "Cancel"; +"action.undo" = "Undo"; +"action.redo" = "Redo"; +"action.delete" = "Delete"; +"action.clear" = "Clear"; +"action.reset" = "Reset"; +"action.close" = "Close"; +"action.done" = "Done"; + +/* Buttons */ +"button.ok" = "OK"; +"button.cancel" = "Cancel"; +"button.clear" = "Clear"; +"button.reset" = "Reset"; +"button.save" = "Save"; +"button.delete" = "Delete"; +"button.confirm" = "Confirm"; + +/* Save Success */ +"save.success.title" = "Saved Successfully"; +"save.success.message" = "Saved to %@"; +"save.with.translations.message" = "Choose where to save the translated image"; + +/* No Translations Error */ +"error.no.translations" = "No translations available. Please translate the text first."; + +/* Copy Success */ +"copy.success.message" = "Copied to clipboard"; + + +/* ======================================== + Settings Window + ======================================== */ + +"settings.window.title" = "ScreenTranslate Settings"; +"settings.title" = "ScreenTranslate Settings"; + +/* Settings Tabs/Sections */ +"settings.section.permissions" = "Permissions"; +"settings.section.general" = "General"; +"settings.section.engines" = "Engines"; +"settings.section.prompts" = "Prompt Configuration"; +"settings.section.languages" = "Languages"; +"settings.section.export" = "Export"; +"settings.section.shortcuts" = "Keyboard Shortcuts"; +"settings.section.text.translation" = "Text Translation"; +"settings.section.annotations" = "Annotations"; + +/* Language Settings */ +"settings.language" = "Language"; +"settings.language.system" = "System Default"; +"settings.language.restart.hint" = "Some changes may require restart"; + +/* Permissions */ +"settings.permission.screen.recording" = "Screen Recording"; +"settings.permission.screen.recording.hint" = "Required to capture screenshots"; +"settings.permission.accessibility" = "Accessibility"; +"settings.permission.accessibility.hint" = "Required for global shortcuts"; +"settings.permission.granted" = "Granted"; +"settings.permission.not.granted" = "Not Granted"; +"settings.permission.grant" = "Grant Access"; +"settings.permission.authorization.title" = "Authorization Required"; +"settings.permission.authorization.cancel" = "Cancel"; +"settings.permission.authorization.go" = "Go Authorize"; +"settings.permission.authorization.screen.message" = "Screen recording permission is required. Click 'Go Authorize' to open System Settings and enable ScreenCapture for this app."; +"settings.permission.authorization.accessibility.message" = "Accessibility permission is required. Click 'Go Authorize' to open System Settings and add this app to the accessibility list."; + +/* Save Location */ +"settings.save.location" = "Save Location"; +"settings.save.location.choose" = "Choose..."; +"settings.save.location.select" = "Select"; +"settings.save.location.message" = "Choose the default location for saving screenshots"; +"settings.save.location.reveal" = "Show in Finder"; + +/* Export Format */ +"settings.format" = "Default Format"; +"settings.format.png" = "PNG"; +"settings.format.jpeg" = "JPEG"; +"settings.format.heic" = "HEIC"; +"settings.jpeg.quality" = "JPEG Quality"; +"settings.jpeg.quality.hint" = "Higher quality results in larger file sizes"; +"settings.heic.quality" = "HEIC Quality"; +"settings.heic.quality.hint" = "HEIC offers better compression"; + +/* Keyboard Shortcuts */ +"settings.shortcuts" = "Keyboard Shortcuts"; +"settings.shortcut.fullscreen" = "Full Screen Capture"; +"settings.shortcut.selection" = "Selection Capture"; +"settings.shortcut.translation.mode" = "Translation Mode"; +"settings.shortcut.text.selection.translation" = "Text Selection Translation"; +"settings.shortcut.translate.and.insert" = "Translate and Insert"; +"settings.shortcut.recording" = "Press keys..."; +"settings.shortcut.reset" = "Reset to default"; +"settings.shortcut.error.no.modifier" = "Shortcuts must include Command, Control, or Option"; +"settings.shortcut.error.conflict" = "This shortcut is already in use"; + +/* Annotations */ +"settings.annotations" = "Annotation Defaults"; +"settings.stroke.color" = "Stroke Color"; +"settings.stroke.width" = "Stroke Width"; +"settings.text.size" = "Text Size"; +"settings.mosaic.blockSize" = "Mosaic Block Size"; + +/* Engines */ +"settings.ocr.engine" = "OCR Engine"; +"settings.translation.engine" = "Translation Engine"; +"settings.translation.mode" = "Translation Mode"; + +/* Reset */ +"settings.reset.all" = "Reset All to Defaults"; + +/* Errors */ +"settings.error.title" = "Error"; +"settings.error.ok" = "OK"; + + +/* ======================================== + OCR Engines + ======================================== */ + +"ocr.engine.vision" = "Apple Vision"; +"ocr.engine.vision.description" = "Built-in macOS Vision framework, fast and private"; +"ocr.engine.paddleocr" = "PaddleOCR"; +"ocr.engine.paddleocr.description" = "Self-hosted OCR server for better accuracy"; + + +/* ======================================== + Translation Engines + ======================================== */ + +"translation.engine.apple" = "Apple Translation"; +"translation.engine.apple.description" = "Built-in macOS translation, no setup required"; +"translation.engine.mtran" = "MTranServer"; +"translation.engine.mtran.description" = "Self-hosted translation server"; + +/* New Translation Engines */ +"translation.engine.openai" = "OpenAI"; +"translation.engine.openai.description" = "GPT-4 translation via OpenAI API"; +"translation.engine.claude" = "Claude"; +"translation.engine.claude.description" = "Claude translation via Anthropic API"; +"translation.engine.gemini" = "Gemini"; +"translation.engine.gemini.description" = "Gemini translation via Google AI API"; +"translation.engine.ollama" = "Ollama"; +"translation.engine.ollama.description" = "Local LLM translation via Ollama"; +"translation.engine.google" = "Google Translate"; +"translation.engine.google.description" = "Google Cloud Translation API"; +"translation.engine.deepl" = "DeepL"; +"translation.engine.deepl.description" = "High-quality translation via DeepL API"; +"translation.engine.baidu" = "Baidu Translate"; +"translation.engine.baidu.description" = "Baidu Translation API"; +"translation.engine.custom" = "OpenAI Compatible"; +"translation.engine.custom.description" = "Custom OpenAI-compatible endpoint"; + +/* Engine Categories */ +"engine.category.builtin" = "Built-in"; +"engine.category.llm" = "LLM Translation"; +"engine.category.cloud" = "Cloud Services"; +"engine.category.compatible" = "Compatible"; + +/* Engine Configuration Title */ +"engine.config.title" = "Translation Engine Configuration"; + +/* Engine Selection Modes */ +"engine.selection.mode.title" = "Engine Selection Mode"; +"engine.selection.mode.primary_fallback" = "Primary/Fallback"; +"engine.selection.mode.primary_fallback.description" = "Use primary engine, fall back to secondary on failure"; +"engine.selection.mode.parallel" = "Parallel"; +"engine.selection.mode.parallel.description" = "Run multiple engines simultaneously and compare results"; +"engine.selection.mode.quick_switch" = "Quick Switch"; +"engine.selection.mode.quick_switch.description" = "Start with primary, quickly switch to other engines on demand"; +"engine.selection.mode.scene_binding" = "Scene Binding"; +"engine.selection.mode.scene_binding.description" = "Use different engines for different translation scenarios"; + +/* Mode-specific labels */ +"engine.config.primary" = "Primary"; +"engine.config.fallback" = "Fallback"; +"engine.config.switch.order" = "Switch Order"; +"engine.config.parallel.select" = "Select engines to run in parallel"; +"engine.config.replace" = "Replace engine"; +"engine.config.remove" = "Remove"; +"engine.config.add" = "Add Engine"; + +/* Translation Scenes */ +"translation.scene.screenshot" = "Screenshot Translation"; +"translation.scene.screenshot.description" = "OCR and translate captured screenshot regions"; +"translation.scene.text_selection" = "Text Selection Translation"; +"translation.scene.text_selection.description" = "Translate selected text from any application"; +"translation.scene.translate_and_insert" = "Translate and Insert"; +"translation.scene.translate_and_insert.description" = "Translate clipboard text and insert at cursor"; + +/* Engine Configuration */ +"engine.config.enabled" = "Enable this engine"; +"engine.config.apiKey" = "API Key"; +"engine.config.apiKey.placeholder" = "Enter your API key"; +"engine.config.getApiKey" = "Get API Key"; +"engine.config.baseURL" = "Base URL"; +"engine.config.model" = "Model Name"; +"engine.config.test" = "Test Connection"; +"engine.config.test.success" = "Connection successful"; +"engine.config.test.failed" = "Connection failed"; +"engine.config.baidu.credentials" = "Baidu Credentials"; +"engine.config.baidu.appID" = "App ID"; +"engine.config.baidu.secretKey" = "Secret Key"; +"engine.config.mtran.url" = "Server URL"; + +/* Engine Status */ +"engine.status.configured" = "Configured"; +"engine.status.unconfigured" = "Not configured"; +"engine.available.title" = "Available Engines"; +"engine.parallel.title" = "Parallel Engines"; +"engine.parallel.description" = "Select engines to run simultaneously in parallel mode"; +"engine.scene.binding.title" = "Scene Engine Binding"; +"engine.scene.binding.description" = "Configure which engine to use for each translation scenario"; +"engine.scene.fallback.tooltip" = "Enable fallback to other engines"; + +/* Keychain Errors */ +"keychain.error.item_not_found" = "Credentials not found in Keychain"; +"keychain.error.item_not_found.recovery" = "Please configure your API credentials in Settings"; +"keychain.error.duplicate_item" = "Credentials already exist in Keychain"; +"keychain.error.duplicate_item.recovery" = "Try deleting existing credentials first"; +"keychain.error.invalid_data" = "Invalid credential data format"; +"keychain.error.invalid_data.recovery" = "Try re-entering your credentials"; +"keychain.error.unexpected_status" = "Keychain operation failed"; +"keychain.error.unexpected_status.recovery" = "Please check your Keychain access permissions"; + +/* Multi-Engine Errors */ +"multiengine.error.all_failed" = "All translation engines failed"; +"multiengine.error.no_engines" = "No translation engines are configured"; +"multiengine.error.primary_unavailable" = "Primary engine %@ is not available"; +"multiengine.error.no_results" = "No translation results available"; + +/* Registry Errors */ +"registry.error.already_registered" = "Provider is already registered"; +"registry.error.not_registered" = "No provider registered for %@"; +"registry.error.config_missing" = "Configuration missing for %@"; +"registry.error.credentials_not_found" = "Credentials not found for %@"; + +/* Prompt Configuration */ +"prompt.engine.title" = "Engine Prompts"; +"prompt.engine.description" = "Customize translation prompts for each LLM engine"; +"prompt.scene.title" = "Scene Prompts"; +"prompt.scene.description" = "Customize translation prompts for each translation scenario"; +"prompt.default.title" = "Default Prompt Template"; +"prompt.default.description" = "This template is used when no custom prompt is configured"; +"prompt.button.edit" = "Edit"; +"prompt.button.reset" = "Reset"; +"prompt.editor.title" = "Edit Prompt"; +"prompt.editor.variables" = "Available Variables:"; +"prompt.variable.source_language" = "Source language name"; +"prompt.variable.target_language" = "Target language name"; +"prompt.variable.text" = "Text to translate"; + + +/* ======================================== + Translation Modes + ======================================== */ + +"translation.mode.inline" = "In-place Replacement"; +"translation.mode.inline.description" = "Replace original text with translation"; +"translation.mode.below" = "Below Original"; +"translation.mode.below.description" = "Show translation below original text"; + + +/* ======================================== + Translation Settings + ======================================== */ + +"translation.auto" = "Auto Detect"; +"translation.auto.detected" = "Auto Detected"; +"translation.language.follow.system" = "Follow System"; +"translation.language.source" = "Source Language"; +"translation.language.target" = "Target Language"; +"translation.language.source.hint" = "The language of the text you want to translate"; +"translation.language.target.hint" = "The language to translate the text into"; + + +/* ======================================== + History View + ======================================== */ + +"history.title" = "Translation History"; +"history.search.placeholder" = "Search history..."; +"history.clear.all" = "Clear all history"; +"history.empty.title" = "No Translation History"; +"history.empty.message" = "Your translated screenshots will appear here"; +"history.no.results.title" = "No Results"; +"history.no.results.message" = "No entries match your search"; +"history.clear.search" = "Clear Search"; + +"history.source" = "Source"; +"history.translation" = "Translation"; +"history.truncated" = "truncated"; + +"history.copy.translation" = "Copy Translation"; +"history.copy.source" = "Copy Source"; +"history.copy.both" = "Copy Both"; +"history.delete" = "Delete"; + +"history.clear.alert.title" = "Clear History"; +"history.clear.alert.message" = "Are you sure you want to delete all translation history? This action cannot be undone."; + + +/* ======================================== + Permission Prompt + ======================================== */ + +"permission.prompt.title" = "Screen Recording Permission Required"; +"permission.prompt.message" = "ScreenTranslate needs permission to capture your screen. This is required to take screenshots.\n\nAfter clicking Continue, macOS will ask you to grant Screen Recording permission. You can grant it in System Settings > Privacy & Security > Screen Recording."; +"permission.prompt.continue" = "Continue"; +"permission.prompt.later" = "Later"; + +/* Accessibility Permission */ +"permission.accessibility.title" = "Accessibility Permission Required"; +"permission.accessibility.message" = "ScreenTranslate needs accessibility permission to capture selected text and insert translations.\n\nThis allows the app to:\n• Copy selected text from any application\n• Insert translated text into input fields\n\nYour privacy is protected - ScreenTranslate only uses this for text translation."; +"permission.accessibility.grant" = "Grant Permission"; +"permission.accessibility.open.settings" = "Open System Settings"; +"permission.accessibility.denied.title" = "Accessibility Permission Required"; +"permission.accessibility.denied.message" = "Text capture and insertion requires accessibility permission.\n\nPlease grant permission in System Settings > Privacy & Security > Accessibility."; + +/* Input Monitoring Permission */ +"permission.input.monitoring.title" = "Input Monitoring Permission Required"; +"permission.input.monitoring.message" = "ScreenTranslate needs input monitoring permission to insert translated text into applications.\n\nYou'll need to enable this in:\nSystem Settings > Privacy & Security > Input Monitoring"; +"permission.input.monitoring.open.settings" = "Open System Settings"; +"permission.input.monitoring.denied.title" = "Input Monitoring Permission Required"; +"permission.input.monitoring.denied.message" = "Text insertion requires input monitoring permission.\n\nPlease grant permission in System Settings > Privacy & Security > Input Monitoring."; + +/* Common Permission Strings */ +"permission.open.settings" = "Open System Settings"; + + +/* ======================================== + Onboarding + ======================================== */ + +"onboarding.window.title" = "Welcome to ScreenTranslate"; + +/* Onboarding - Welcome Step */ +"onboarding.welcome.title" = "Welcome to ScreenTranslate"; +"onboarding.welcome.message" = "Let's get you set up with screen capture and translation features. This will only take a minute."; + +"onboarding.feature.local.ocr.title" = "Local OCR"; +"onboarding.feature.local.ocr.description" = "macOS Vision framework for fast, private text recognition"; +"onboarding.feature.local.translation.title" = "Local Translation"; +"onboarding.feature.local.translation.description" = "Apple Translation for instant, offline translation"; +"onboarding.feature.shortcuts.title" = "Global Shortcuts"; +"onboarding.feature.shortcuts.description" = "Capture and translate from anywhere with keyboard shortcuts"; + +/* Onboarding - Permissions Step */ +"onboarding.permissions.title" = "Permissions"; +"onboarding.permissions.message" = "ScreenTranslate needs a few permissions to work properly. Please grant the following permissions:"; +"onboarding.permissions.hint" = "After granting permissions, the status will update automatically."; + +"onboarding.permission.screen.recording" = "Screen Recording"; +"onboarding.permission.accessibility" = "Accessibility"; +"onboarding.permission.granted" = "Granted"; +"onboarding.permission.not.granted" = "Not Granted"; +"onboarding.permission.grant" = "Grant Permission"; + +/* Onboarding - Configuration Step */ +"onboarding.configuration.title" = "Optional Configuration"; +"onboarding.configuration.message" = "Your local OCR and translation features are already enabled. Optionally configure external services:"; +"onboarding.configuration.paddleocr" = "PaddleOCR Server Address"; +"onboarding.configuration.paddleocr.hint" = "Leave empty to use macOS Vision OCR"; +"onboarding.configuration.mtran" = "MTranServer Address"; +"onboarding.configuration.mtran.hint" = "Leave empty to use Apple Translation"; +"onboarding.configuration.placeholder" = "http://localhost:8080"; +"onboarding.configuration.placeholder.address" = "localhost"; +"onboarding.configuration.test" = "Test Translation"; +"onboarding.configuration.test.button" = "Test Translation"; +"onboarding.configuration.testing" = "Testing..."; +"onboarding.test.success" = "Translation test successful: \"%@\" → \"%@\""; +"onboarding.test.failed" = "Translation test failed: %@"; + +/* Onboarding - Complete Step */ +"onboarding.complete.title" = "You're All Set!"; +"onboarding.complete.message" = "ScreenTranslate is now ready to use. Here's how to get started:"; +"onboarding.complete.shortcuts" = "Use ⌘⇧F to capture the full screen"; +"onboarding.complete.selection" = "Use ⌘⇧A to capture a selection and translate"; +"onboarding.complete.settings" = "Open Settings from the menu bar to customize options"; +"onboarding.complete.start" = "Start Using ScreenTranslate"; + +/* Onboarding - Navigation */ +"onboarding.back" = "Back"; +"onboarding.continue" = "Continue"; +"onboarding.next" = "Next"; +"onboarding.skip" = "Skip"; +"onboarding.complete" = "Complete"; + +/* Onboarding - PaddleOCR */ +"onboarding.paddleocr.title" = "PaddleOCR (Optional)"; +"onboarding.paddleocr.description" = "Enhanced OCR engine for better text recognition accuracy, especially for Chinese."; +"onboarding.paddleocr.installed" = "Installed"; +"onboarding.paddleocr.not.installed" = "Not Installed"; +"onboarding.paddleocr.install" = "Install"; +"onboarding.paddleocr.installing" = "Installing..."; +"onboarding.paddleocr.install.hint" = "Requires Python 3 and pip. Run: pip3 install paddleocr paddlepaddle"; +"onboarding.paddleocr.copy.command" = "Copy Command"; +"onboarding.paddleocr.refresh" = "Refresh Status"; +"onboarding.paddleocr.version" = "Version: %@"; + +/* Settings - PaddleOCR */ +"settings.paddleocr.installed" = "Installed"; +"settings.paddleocr.not.installed" = "Not Installed"; +"settings.paddleocr.install" = "Install"; +"settings.paddleocr.installing" = "Installing..."; +"settings.paddleocr.install.hint" = "Requires Python 3 and pip installed on your system."; +"settings.paddleocr.copy.command" = "Copy Command"; +"settings.paddleocr.refresh" = "Refresh Status"; +"settings.paddleocr.ready" = "PaddleOCR is ready"; +"settings.paddleocr.not.installed.message" = "PaddleOCR is not installed"; +"settings.paddleocr.description" = "PaddleOCR is a local OCR engine. It's free, works offline, and doesn't require an API key."; +"settings.paddleocr.install.button" = "Install PaddleOCR"; +"settings.paddleocr.copy.command.button" = "Copy Install Command"; +"settings.paddleocr.mode" = "Mode"; +"settings.paddleocr.mode.fast" = "Fast"; +"settings.paddleocr.mode.precise" = "Precise"; +"settings.paddleocr.mode.fast.description" = "~1s, fast OCR with line grouping"; +"settings.paddleocr.mode.precise.description" = "~12s, VL-1.5 model with higher accuracy"; +"settings.paddleocr.useCloud" = "Use Cloud API"; +"settings.paddleocr.cloudBaseURL" = "Cloud API URL"; +"settings.paddleocr.cloudAPIKey" = "API Key"; +"settings.paddleocr.cloudModelId" = "Model ID"; +"settings.paddleocr.localVLModelDir" = "Local Model Directory (vllm)"; +"settings.paddleocr.localVLModelDir.hint" = "Path to local PaddleOCR-VL model (e.g. ~/.paddlex/official_models/PaddleOCR-VL-1.5)"; +"error.paddleocr.notInstalled" = "PaddleOCR is not installed. Install it using: pip3 install paddleocr paddlepaddle"; + + +/* ======================================== + VLM Configuration + ======================================== */ + +"settings.vlm.title" = "VLM Configuration"; +"settings.vlm.provider" = "Provider"; +"settings.vlm.apiKey" = "API Key"; +"settings.vlm.apiKey.optional" = "API Key is optional for local providers"; +"settings.vlm.baseURL" = "Base URL"; +"settings.vlm.model" = "Model Name"; +"settings.vlm.test.button" = "Test Connection"; +"settings.vlm.test.success" = "Connection successful! Model: %@"; +"settings.vlm.test.ollama.success" = "Server running. Model '%@' available"; +"settings.vlm.test.ollama.available" = "Server running. Available: %@"; + +"vlm.provider.openai" = "OpenAI"; +"vlm.provider.claude" = "Claude"; +"vlm.provider.glmocr" = "GLM OCR"; +"vlm.provider.ollama" = "Ollama"; +"vlm.provider.paddleocr" = "PaddleOCR"; +"vlm.provider.openai.description" = "OpenAI GPT-4 Vision API"; +"vlm.provider.claude.description" = "Anthropic Claude Vision API"; +"vlm.provider.glmocr.description" = "Zhipu GLM-OCR layout parsing API"; +"vlm.provider.ollama.description" = "Local Ollama server"; +"vlm.provider.paddleocr.description" = "Local OCR engine (free, offline)"; + + +/* ======================================== + Translation Workflow Configuration + ======================================== */ + +"settings.translation.workflow.title" = "Translation Engine"; +"settings.translation.preferred" = "Preferred Engine"; +"settings.translation.mtran.url" = "MTransServer URL"; +"settings.translation.mtran.test.button" = "Test Connection"; +"settings.translation.mtran.test.success" = "Connection successful"; +"settings.translation.mtran.test.failed" = "Connection failed: %@"; +"settings.translation.fallback" = "Fallback"; +"settings.translation.fallback.description" = "Use Apple Translation as fallback when preferred engine fails"; + +"translation.preferred.apple.description" = "Built-in macOS translation, works offline"; +"translation.preferred.mtran.description" = "Self-hosted translation server for better quality"; + + +/* ======================================== + Accessibility Labels + ======================================== */ + +"accessibility.close.button" = "Close"; +"accessibility.settings.button" = "Settings"; +"accessibility.capture.button" = "Capture"; +"accessibility.translate.button" = "Translate"; + + +/* ======================================== + Bilingual Result Window + ======================================== */ + +/* ======================================== + Translation Flow + ======================================== */ + +"translationFlow.phase.idle" = "Ready"; +"translationFlow.phase.analyzing" = "Analyzing image..."; +"translationFlow.phase.translating" = "Translating..."; +"translationFlow.phase.rendering" = "Rendering..."; +"translationFlow.phase.completed" = "Completed"; +"translationFlow.phase.failed" = "Failed"; + +"translationFlow.error.title" = "Translation Error"; +"translationFlow.error.title.analysis" = "Image Recognition Failed"; +"translationFlow.error.title.translation" = "Translation Failed"; +"translationFlow.error.title.rendering" = "Rendering Failed"; +"translationFlow.error.unknown" = "An unknown error occurred."; +"translationFlow.error.analysis" = "Analysis failed: %@"; +"translationFlow.error.translation" = "Translation failed: %@"; +"translationFlow.error.rendering" = "Rendering failed: %@"; +"translationFlow.error.cancelled" = "Translation was cancelled."; +"translationFlow.error.noTextFound" = "No text found in the selected area."; +"translationFlow.error.translation.engine" = "Translation Engine"; + +"translationFlow.recovery.analysis" = "Please try again with a clearer image, or check your VLM provider settings."; +"translationFlow.recovery.translation" = "Check your translation engine settings and network connection, then try again."; +"translationFlow.recovery.rendering" = "Please try again."; +"translationFlow.recovery.noTextFound" = "Try selecting an area with visible text."; + +"common.ok" = "OK"; + + +/* ======================================== + Bilingual Result Window + ======================================== */ + +"bilingualResult.window.title" = "Bilingual Translation"; +"bilingualResult.loading" = "Translating..."; +"bilingualResult.loading.analyzing" = "Analyzing image..."; +"bilingualResult.loading.translating" = "Translating text..."; +"bilingualResult.loading.rendering" = "Rendering result..."; +"bilingualResult.copyImage" = "Copy Image"; +"bilingualResult.copyText" = "Copy Text"; +"bilingualResult.save" = "Save"; +"bilingualResult.zoomIn" = "Zoom In"; +"bilingualResult.zoomOut" = "Zoom Out"; +"bilingualResult.resetZoom" = "Reset Zoom"; +"bilingualResult.copySuccess" = "Copied to clipboard"; +"bilingualResult.copyTextSuccess" = "Translation text copied"; +"bilingualResult.saveSuccess" = "Saved successfully"; +"bilingualResult.copyFailed" = "Failed to copy image"; +"bilingualResult.saveFailed" = "Failed to save image"; +"bilingualResult.noTextToCopy" = "No translation text to copy"; + + +/* ======================================== + Text Translation (US-003 to US-010) + ======================================== */ + +/* Text Translation Flow */ +"textTranslation.phase.idle" = "Ready"; +"textTranslation.phase.translating" = "Translating..."; +"textTranslation.phase.completed" = "Completed"; +"textTranslation.phase.failed" = "Failed"; + +"textTranslation.error.emptyInput" = "No text to translate"; +"textTranslation.error.translationFailed" = "Translation failed: %@"; +"textTranslation.error.cancelled" = "Translation was cancelled"; +"textTranslation.error.serviceUnavailable" = "Translation service is unavailable"; +"textTranslation.error.insertFailed" = "Failed to insert translated text"; + +"textTranslation.recovery.emptyInput" = "Please select some text first"; +"textTranslation.recovery.translationFailed" = "Please try again"; +"textTranslation.recovery.serviceUnavailable" = "Check your network connection and try again"; + +"textTranslation.loading" = "Translating..."; +"textTranslation.noSelection.title" = "No Text Selected"; +"textTranslation.noSelection.message" = "Please select some text in any application and try again."; + +/* Translate and Insert */ +"translateAndInsert.emptyClipboard.title" = "Clipboard is Empty"; +"translateAndInsert.emptyClipboard.message" = "Copy some text to clipboard first, then use this shortcut."; +"translateAndInsert.success.title" = "Translation Inserted"; +"translateAndInsert.success.message" = "Translated text has been inserted into the focused input field."; + +/* Language Display */ +"language.auto" = "Auto Detected"; + +/* Common Text Translation UI */ +"common.copy" = "Copy"; +"common.copied" = "Copied"; +"common.insert" = "Insert"; + +/* Text Translation Window */ +"textTranslation.window.title" = "Text Translation"; + +/* Translate and Insert Language Settings */ +"settings.translateAndInsert.language.section" = "Translate and Insert Languages"; +"settings.translateAndInsert.language.source" = "Source Language"; +"settings.translateAndInsert.language.target" = "Target Language"; + + +/* ======================================== + Multi OpenAI Compatible Engines + ======================================== */ + +/* Compatible Engine Configuration */ +"engine.compatible.new" = "New Compatible Engine"; +"engine.compatible.description" = "OpenAI-compatible API endpoint"; +"engine.compatible.displayName" = "Display Name"; +"engine.compatible.displayName.placeholder" = "e.g., My LLM Server"; +"engine.compatible.requireApiKey" = "Requires API Key"; +"engine.compatible.add" = "Add Compatible Engine"; +"engine.compatible.delete" = "Delete this engine"; +"engine.compatible.useAsEngine" = "Use as translation engine"; +"engine.compatible.max.reached" = "Maximum 5 compatible engines reached"; + +/* Prompt Configuration */ +"prompt.compatible.title" = "Compatible Engines"; + + +/* ======================================== + About Window + ======================================== */ + +"about.title" = "About ScreenTranslate"; +"about.app.name" = "ScreenTranslate"; +"about.version.format" = "Version %@ (%@)"; +"about.copyright" = "Copyright"; +"about.copyright.value" = "© 2026 All rights reserved"; +"about.license" = "License"; +"about.license.value" = "MIT License"; +"about.github.link" = "GitHub: hubo1989/ScreenTranslate"; +"about.check.for.updates" = "Check for Updates"; +"about.update.checking" = "Checking..."; +"about.update.available" = "Update available"; +"about.update.uptodate" = "You're up to date"; +"about.update.failed" = "Check failed"; +"about.acknowledgements" = "Acknowledgements"; +"about.acknowledgements.title" = "Acknowledgements"; +"about.acknowledgements.intro" = "This software uses the following open source libraries:"; +"about.acknowledgements.upstream" = "Based on"; +"about.acknowledgements.author.format" = "by %@"; +"about.close" = "Close"; +"settings.glmocr.mode" = "Mode"; +"settings.glmocr.mode.cloud" = "Cloud"; +"settings.glmocr.mode.local" = "Local"; +"settings.glmocr.local.apiKey.optional" = "API Key is optional for local MLX-VLM servers"; +"vlm.provider.glmocr.local.description" = "Local MLX-VLM server for GLM-OCR"; diff --git a/ScreenTranslate/Resources/es.lproj/Localizable.strings b/ScreenTranslate/Resources/es.lproj/Localizable.strings new file mode 100644 index 0000000..98d7e97 --- /dev/null +++ b/ScreenTranslate/Resources/es.lproj/Localizable.strings @@ -0,0 +1,823 @@ +/* + Localizable.strings (Español) + ScreenTranslate +*/ + +/* ======================================== + Mensajes de Error + ======================================== */ + +/* Errores de Permiso */ +"error.permission.denied" = "Se requiere permiso de grabación de pantalla para capturar pantallas."; +"error.permission.denied.recovery" = "Abra Configuración del Sistema para otorgar permiso."; + +/* Errores de Pantalla */ +"error.display.not.found" = "La pantalla seleccionada ya no está disponible."; +"error.display.not.found.recovery" = "Seleccione una pantalla diferente."; +"error.display.disconnected" = "La pantalla '%@' se desconectó durante la captura."; +"error.display.disconnected.recovery" = "Vuelva a conectar la pantalla e inténtelo de nuevo."; + +/* Errores de Captura */ +"error.capture.failed" = "Error al capturar la pantalla."; +"error.capture.failed.recovery" = "Inténtelo de nuevo."; + +/* Errores de Guardado */ +"error.save.location.invalid" = "La ubicación de guardado no es accesible."; +"error.save.location.invalid.recovery" = "Elija una ubicación diferente en Configuración."; +"error.save.location.invalid.detail" = "No se puede guardar en %@. La ubicación no es accesible."; +"error.save.unknown" = "Ocurrió un error inesperado al guardar."; +"error.disk.full" = "No hay suficiente espacio en disco para guardar la captura."; +"error.disk.full.recovery" = "Libere espacio en disco e inténtelo de nuevo."; + +/* Errores de Exportación */ +"error.export.encoding.failed" = "Error al codificar la imagen."; +"error.export.encoding.failed.recovery" = "Pruebe un formato diferente en Configuración."; +"error.export.encoding.failed.detail" = "Error al codificar la imagen como %@."; + +/* Errores del Portapapeles */ +"error.clipboard.write.failed" = "Error al copiar la captura al portapapeles."; +"error.clipboard.write.failed.recovery" = "Inténtelo de nuevo."; + +/* Errores de Atajos de Teclado */ +"error.hotkey.registration.failed" = "Error al registrar el atajo de teclado."; +"error.hotkey.registration.failed.recovery" = "El atajo puede entrar en conflicto con otra aplicación. Pruebe un atajo diferente."; +"error.hotkey.conflict" = "Este atajo de teclado entra en conflicto con otra aplicación."; +"error.hotkey.conflict.recovery" = "Elija un atajo de teclado diferente."; + +/* Errores de OCR */ +"error.ocr.failed" = "Error en el reconocimiento de texto."; +"error.ocr.failed.recovery" = "Inténtelo de nuevo con una imagen más clara."; +"error.ocr.no.text" = "No se reconoció texto en la imagen."; +"error.ocr.no.text.recovery" = "Intente capturar un área con texto visible."; +"error.ocr.cancelled" = "El reconocimiento de texto fue cancelado."; +"error.ocr.server.unreachable" = "No se puede conectar al servidor OCR."; +"error.ocr.server.unreachable.recovery" = "Verifique la dirección del servidor y la conexión de red."; + +/* Errores de Traducción */ +"error.translation.in.progress" = "Ya hay una traducción en progreso"; +"error.translation.in.progress.recovery" = "Espere a que se complete la traducción actual"; +"error.translation.empty.input" = "No hay texto para traducir"; +"error.translation.empty.input.recovery" = "Seleccione algo de texto primero"; +"error.translation.timeout" = "Tiempo de espera de traducción agotado"; +"error.translation.timeout.recovery" = "Inténtelo de nuevo"; +"error.translation.unsupported.pair" = "No se admite la traducción de %@ a %@"; +"error.translation.unsupported.pair.recovery" = "Seleccione idiomas diferentes"; +"error.translation.failed" = "Error en la traducción"; +"error.translation.failed.recovery" = "Inténtelo de nuevo"; +"error.translation.language.not.installed" = "El idioma de traducción '%@' no está instalado"; +"error.translation.language.download.instructions" = "Vaya a Configuración del Sistema > General > Idioma y Región > Idiomas de Traducción, luego descargue el idioma requerido."; + +/* UI de Error Genérico */ +"error.title" = "Error"; +"error.ok" = "OK"; +"error.dismiss" = "Descartar"; +"error.retry.capture" = "Reintentar"; +"error.permission.open.settings" = "Abrir Configuración del Sistema"; + + +/* ======================================== + Elementos del Menú + ======================================== */ + +"menu.capture.full.screen" = "Capturar Pantalla Completa"; +"menu.capture.fullscreen" = "Capturar Pantalla Completa"; +"menu.capture.selection" = "Capturar Selección"; +"menu.translation.mode" = "Modo de Traducción"; +"menu.translation.history" = "Historial de Traducciones"; +"menu.settings" = "Configuración..."; +"menu.about" = "Acerca de ScreenTranslate"; +"menu.quit" = "Salir de ScreenTranslate"; + + +/* ======================================== + Selector de Pantalla + ======================================== */ + +"display.selector.title" = "Seleccionar Pantalla"; +"display.selector.header" = "Elija la pantalla para capturar:"; +"display.selector.cancel" = "Cancelar"; + + +/* ======================================== + Ventana de Vista Previa + ======================================== */ + +"preview.window.title" = "Vista Previa de Captura"; +"preview.title" = "Vista Previa de Captura"; +"preview.dimensions" = "%d x %d píxeles"; +"preview.file.size" = "~%@ %@"; +"preview.screenshot" = "Captura"; +"preview.enter.text" = "Introducir texto"; +"preview.image.dimensions" = "Dimensiones de imagen"; +"preview.estimated.size" = "Tamaño estimado"; +"preview.edit.label" = "Editar:"; +"preview.active.tool" = "Herramienta activa"; +"preview.crop.mode.active" = "Modo de recorte activo"; + +/* Recortar */ +"preview.crop" = "Recortar"; +"preview.crop.cancel" = "Cancelar"; +"preview.crop.apply" = "Aplicar Recorte"; + +/* Texto Reconocido */ +"preview.recognized.text" = "Texto Reconocido:"; +"preview.translation" = "Traducción:"; +"preview.results.panel" = "Resultados de Texto"; +"preview.copy.text" = "Copiar texto"; + +/* Descripciones de Herramientas */ +"preview.tooltip.crop" = "Recortar (C)"; +"preview.tooltip.pin" = "Fijar a Pantalla (P)"; +"preview.tooltip.undo" = "Deshacer (⌘Z)"; +"preview.tooltip.redo" = "Rehacer (⌘⇧Z)"; +"preview.tooltip.copy" = "Copiar al Portapapeles (⌘C)"; +"preview.tooltip.save" = "Guardar (⌘S)"; +"preview.tooltip.ocr" = "Reconocer Texto (OCR)"; +"preview.tooltip.confirm" = "Copiar al Portapapeles y Cerrar (Enter)"; +"preview.tooltip.dismiss" = "Descartar (Escape)"; +"preview.tooltip.delete" = "Eliminar anotación seleccionada"; + +/* Etiquetas de Accesibilidad */ +"preview.accessibility.save" = "Guardar captura"; +"preview.accessibility.saving" = "Guardando captura"; +"preview.accessibility.confirm" = "Confirmar y copiar al portapapeles"; +"preview.accessibility.copying" = "Copiando al portapapeles"; +"preview.accessibility.hint.commandS" = "Comando S"; +"preview.accessibility.hint.enter" = "Tecla Enter"; + +/* Alternar Forma */ +"preview.shape.filled" = "Relleno"; +"preview.shape.hollow" = "Hueco"; +"preview.shape.toggle.hint" = "Haga clic para alternar entre relleno y hueco"; + + +/* ======================================== + Herramientas de Anotación + ======================================== */ + +"tool.rectangle" = "Rectángulo"; +"tool.freehand" = "Mano Alzada"; +"tool.text" = "Texto"; +"tool.arrow" = "Flecha"; +"tool.ellipse" = "Elipse"; +"tool.line" = "Línea"; +"tool.highlight" = "Resaltar"; +"tool.mosaic" = "Mosaico"; +"tool.numberLabel" = "Etiqueta Numérica"; + + +/* ======================================== + Colores + ======================================== */ + +"color.red" = "Rojo"; +"color.orange" = "Naranja"; +"color.yellow" = "Amarillo"; +"color.green" = "Verde"; +"color.blue" = "Azul"; +"color.purple" = "Púrpura"; +"color.pink" = "Rosa"; +"color.white" = "Blanco"; +"color.black" = "Negro"; +"color.custom" = "Personalizado"; + + +/* ======================================== + Acciones + ======================================== */ + +"action.save" = "Guardar"; +"action.copy" = "Copiar"; +"action.cancel" = "Cancelar"; +"action.undo" = "Deshacer"; +"action.redo" = "Rehacer"; +"action.delete" = "Eliminar"; +"action.clear" = "Limpiar"; +"action.reset" = "Restablecer"; +"action.close" = "Cerrar"; +"action.done" = "Hecho"; + +/* Botones */ +"button.ok" = "OK"; +"button.cancel" = "Cancelar"; +"button.clear" = "Limpiar"; +"button.reset" = "Restablecer"; +"button.save" = "Guardar"; +"button.delete" = "Eliminar"; +"button.confirm" = "Confirmar"; + +/* Guardado Exitoso */ +"save.success.title" = "Guardado Exitosamente"; +"save.success.message" = "Guardado en %@"; +"save.with.translations.message" = "Elija dónde guardar la imagen traducida"; + +/* Error Sin Traducciones */ +"error.no.translations" = "No hay traducciones disponibles. Traduzca el texto primero."; + +/* Copia Exitosa */ +"copy.success.message" = "Copiado al portapapeles"; + + +/* ======================================== + Ventana de Configuración + ======================================== */ + +"settings.window.title" = "Configuración de ScreenTranslate"; +"settings.title" = "Configuración de ScreenTranslate"; + +/* Pestañas/Secciones de Configuración */ +"settings.section.permissions" = "Permisos"; +"settings.section.general" = "General"; +"settings.section.engines" = "Motores"; +"settings.section.prompts" = "Configuración de Prompts"; +"settings.section.languages" = "Idiomas"; +"settings.section.export" = "Exportar"; +"settings.section.shortcuts" = "Atajos de Teclado"; +"settings.section.text.translation" = "Traducción de Texto"; +"settings.section.annotations" = "Anotaciones"; + +/* Configuración de Idioma */ +"settings.language" = "Idioma"; +"settings.language.system" = "Predeterminado del Sistema"; +"settings.language.restart.hint" = "Algunos cambios pueden requerir reinicio"; + +/* Permisos */ +"settings.permission.screen.recording" = "Grabación de Pantalla"; +"settings.permission.screen.recording.hint" = "Necesario para capturar pantallas"; +"settings.permission.accessibility" = "Accesibilidad"; +"settings.permission.accessibility.hint" = "Necesario para atajos globales"; +"settings.permission.granted" = "Otorgado"; +"settings.permission.not.granted" = "No Otorgado"; +"settings.permission.grant" = "Otorgar Acceso"; +"settings.permission.authorization.title" = "Autorización Requerida"; +"settings.permission.authorization.cancel" = "Cancelar"; +"settings.permission.authorization.go" = "Autorizar"; +"settings.permission.authorization.screen.message" = "Se requiere permiso de grabación de pantalla. Haga clic en 'Autorizar' para abrir Configuración del Sistema y habilitar ScreenCapture para esta aplicación."; +"settings.permission.authorization.accessibility.message" = "Se requiere permiso de accesibilidad. Haga clic en 'Autorizar' para abrir Configuración del Sistema y agregar esta aplicación a la lista de accesibilidad."; + +/* Ubicación de Guardado */ +"settings.save.location" = "Ubicación de Guardado"; +"settings.save.location.choose" = "Elegir..."; +"settings.save.location.select" = "Seleccionar"; +"settings.save.location.message" = "Elija la ubicación predeterminada para guardar capturas"; +"settings.save.location.reveal" = "Mostrar en Finder"; + +/* Formato de Exportación */ +"settings.format" = "Formato Predeterminado"; +"settings.format.png" = "PNG"; +"settings.format.jpeg" = "JPEG"; +"settings.format.heic" = "HEIC"; +"settings.jpeg.quality" = "Calidad JPEG"; +"settings.jpeg.quality.hint" = "Mayor calidad resulta en archivos más grandes"; +"settings.heic.quality" = "Calidad HEIC"; +"settings.heic.quality.hint" = "HEIC ofrece mejor compresión"; + +/* Atajos de Teclado */ +"settings.shortcuts" = "Atajos de Teclado"; +"settings.shortcut.fullscreen" = "Captura de Pantalla Completa"; +"settings.shortcut.selection" = "Captura de Selección"; +"settings.shortcut.translation.mode" = "Modo de Traducción"; +"settings.shortcut.text.selection.translation" = "Traducción de Selección de Texto"; +"settings.shortcut.translate.and.insert" = "Traducir e Insertar"; +"settings.shortcut.recording" = "Presione teclas..."; +"settings.shortcut.reset" = "Restablecer a predeterminados"; +"settings.shortcut.error.no.modifier" = "Los atajos deben incluir Comando, Control u Opción"; +"settings.shortcut.error.conflict" = "Este atajo ya está en uso"; + +/* Anotaciones */ +"settings.annotations" = "Valores Predeterminados de Anotación"; +"settings.stroke.color" = "Color del Trazo"; +"settings.stroke.width" = "Ancho del Trazo"; +"settings.text.size" = "Tamaño de Texto"; +"settings.mosaic.blockSize" = "Tamaño de Bloque de Mosaico"; + +/* Motores */ +"settings.ocr.engine" = "Motor OCR"; +"settings.translation.engine" = "Motor de Traducción"; +"settings.translation.mode" = "Modo de Traducción"; + +/* Restablecer */ +"settings.reset.all" = "Restablecer Todo a Valores Predeterminados"; + +/* Errores */ +"settings.error.title" = "Error"; +"settings.error.ok" = "OK"; + + +/* ======================================== + Motores OCR + ======================================== */ + +"ocr.engine.vision" = "Apple Vision"; +"ocr.engine.vision.description" = "Framework Vision de macOS integrado, rápido y privado"; +"ocr.engine.paddleocr" = "PaddleOCR"; +"ocr.engine.paddleocr.description" = "Servidor OCR autohospedado para mayor precisión"; + + +/* ======================================== + Motores de Traducción + ======================================== */ + +"translation.engine.apple" = "Traducción de Apple"; +"translation.engine.apple.description" = "Traducción de macOS integrada, no requiere configuración"; +"translation.engine.mtran" = "MTranServer"; +"translation.engine.mtran.description" = "Servidor de traducción autohospedado"; + +/* Nuevos Motores de Traducción */ +"translation.engine.openai" = "OpenAI"; +"translation.engine.openai.description" = "Traducción GPT-4 a través de la API de OpenAI"; +"translation.engine.claude" = "Claude"; +"translation.engine.claude.description" = "Traducción Claude a través de la API de Anthropic"; +"translation.engine.gemini" = "Gemini"; +"translation.engine.gemini.description" = "Traducción Gemini a través de la API de Google AI"; +"translation.engine.ollama" = "Ollama"; +"translation.engine.ollama.description" = "Traducción LLM local a través de Ollama"; +"translation.engine.google" = "Google Translate"; +"translation.engine.google.description" = "API de Google Cloud Translation"; +"translation.engine.deepl" = "DeepL"; +"translation.engine.deepl.description" = "Traducción de alta calidad a través de la API de DeepL"; +"translation.engine.baidu" = "Baidu Translate"; +"translation.engine.baidu.description" = "API de Traducción Baidu"; +"translation.engine.custom" = "Compatible con OpenAI"; +"translation.engine.custom.description" = "Endpoint personalizado compatible con OpenAI"; + +/* Categorías de Motores */ +"engine.category.builtin" = "Integrado"; +"engine.category.llm" = "Traducción LLM"; +"engine.category.cloud" = "Servicios en la Nube"; +"engine.category.compatible" = "Compatible"; + +/* Título de Configuración del Motor */ +"engine.config.title" = "Configuración del Motor de Traducción"; + +/* Modos de Selección del Motor */ +"engine.selection.mode.title" = "Modo de Selección del Motor"; +"engine.selection.mode.primary_fallback" = "Principal/Respaldo"; +"engine.selection.mode.primary_fallback.description" = "Usar motor principal, cambiar al secundario si falla"; +"engine.selection.mode.parallel" = "Paralelo"; +"engine.selection.mode.parallel.description" = "Ejecutar múltiples motores simultáneamente y comparar resultados"; +"engine.selection.mode.quick_switch" = "Cambio Rápido"; +"engine.selection.mode.quick_switch.description" = "Comenzar con el principal, cambiar rápidamente a otros motores bajo demanda"; +"engine.selection.mode.scene_binding" = "Vinculación de Escena"; +"engine.selection.mode.scene_binding.description" = "Usar diferentes motores para diferentes escenarios de traducción"; + +/* Etiquetas Específicas del Modo */ +"engine.config.primary" = "Principal"; +"engine.config.fallback" = "Respaldo"; +"engine.config.switch.order" = "Orden de Cambio"; +"engine.config.parallel.select" = "Seleccionar motores para ejecutar en paralelo"; +"engine.config.replace" = "Reemplazar motor"; +"engine.config.remove" = "Eliminar"; +"engine.config.add" = "Agregar Motor"; + +/* Escenas de Traducción */ +"translation.scene.screenshot" = "Traducción de Captura"; +"translation.scene.screenshot.description" = "OCR y traducción de regiones de captura"; +"translation.scene.text_selection" = "Traducción de Selección de Texto"; +"translation.scene.text_selection.description" = "Traducir texto seleccionado de cualquier aplicación"; +"translation.scene.translate_and_insert" = "Traducir e Insertar"; +"translation.scene.translate_and_insert.description" = "Traducir texto del portapapeles e insertar en el cursor"; + +/* Configuración del Motor */ +"engine.config.enabled" = "Habilitar este motor"; +"engine.config.apiKey" = "Clave API"; +"engine.config.apiKey.placeholder" = "Ingrese su clave API"; +"engine.config.getApiKey" = "Obtener Clave API"; +"engine.config.baseURL" = "URL Base"; +"engine.config.model" = "Nombre del Modelo"; +"engine.config.test" = "Probar Conexión"; +"engine.config.test.success" = "Conexión exitosa"; +"engine.config.test.failed" = "Conexión fallida"; +"engine.config.baidu.credentials" = "Credenciales Baidu"; +"engine.config.baidu.appID" = "ID de Aplicación"; +"engine.config.baidu.secretKey" = "Clave Secreta"; +"engine.config.mtran.url" = "URL del Servidor"; + +/* Estado del Motor */ +"engine.status.configured" = "Configurado"; +"engine.status.unconfigured" = "No configurado"; +"engine.available.title" = "Motores Disponibles"; +"engine.parallel.title" = "Motores Paralelos"; +"engine.parallel.description" = "Seleccionar motores para ejecutar simultáneamente en modo paralelo"; +"engine.scene.binding.title" = "Vinculación de Motor de Escena"; +"engine.scene.binding.description" = "Configurar qué motor usar para cada escenario de traducción"; +"engine.scene.fallback.tooltip" = "Habilitar respaldo a otros motores"; + +/* Errores de Keychain */ +"keychain.error.item_not_found" = "Credenciales no encontradas en el Llavero"; +"keychain.error.item_not_found.recovery" = "Configure sus credenciales API en Configuración"; +"keychain.error.duplicate_item" = "Las credenciales ya existen en el Llavero"; +"keychain.error.duplicate_item.recovery" = "Intente eliminar las credenciales existentes primero"; +"keychain.error.invalid_data" = "Formato de datos de credencial no válido"; +"keychain.error.invalid_data.recovery" = "Intente volver a ingresar sus credenciales"; +"keychain.error.unexpected_status" = "Error en la operación del Llavero"; +"keychain.error.unexpected_status.recovery" = "Verifique sus permisos de acceso al Llavero"; + +/* Errores de Multimotor */ +"multiengine.error.all_failed" = "Todos los motores de traducción fallaron"; +"multiengine.error.no_engines" = "No hay motores de traducción configurados"; +"multiengine.error.primary_unavailable" = "El motor principal %@ no está disponible"; +"multiengine.error.no_results" = "No hay resultados de traducción disponibles"; + +/* Errores del Registro */ +"registry.error.already_registered" = "El proveedor ya está registrado"; +"registry.error.not_registered" = "No hay proveedor registrado para %@"; +"registry.error.config_missing" = "Falta configuración para %@"; +"registry.error.credentials_not_found" = "Credenciales no encontradas para %@"; + +/* Configuración de Prompts */ +"prompt.engine.title" = "Prompts del Motor"; +"prompt.engine.description" = "Personalice los prompts de traducción para cada motor LLM"; +"prompt.scene.title" = "Prompts de Escena"; +"prompt.scene.description" = "Personalice los prompts de traducción para cada escenario de traducción"; +"prompt.default.title" = "Plantilla de Prompt Predeterminada"; +"prompt.default.description" = "Esta plantilla se usa cuando no hay un prompt personalizado configurado"; +"prompt.button.edit" = "Editar"; +"prompt.button.reset" = "Restablecer"; +"prompt.editor.title" = "Editar Prompt"; +"prompt.editor.variables" = "Variables Disponibles:"; +"prompt.variable.source_language" = "Nombre del idioma de origen"; +"prompt.variable.target_language" = "Nombre del idioma de destino"; +"prompt.variable.text" = "Texto para traducir"; + + +/* ======================================== + Modos de Traducción + ======================================== */ + +"translation.mode.inline" = "Reemplazo In Situ"; +"translation.mode.inline.description" = "Reemplazar el texto original con la traducción"; +"translation.mode.below" = "Debajo del Original"; +"translation.mode.below.description" = "Mostrar la traducción debajo del texto original"; + + +/* ======================================== + Configuración de Traducción + ======================================== */ + +"translation.auto" = "Detectar Automáticamente"; +"translation.auto.detected" = "Detectado Automáticamente"; +"translation.language.follow.system" = "Seguir el Sistema"; +"translation.language.source" = "Idioma de Origen"; +"translation.language.target" = "Idioma de Destino"; +"translation.language.source.hint" = "El idioma del texto que desea traducir"; +"translation.language.target.hint" = "El idioma al que desea traducir el texto"; + + +/* ======================================== + Vista del Historial + ======================================== */ + +"history.title" = "Historial de Traducciones"; +"history.search.placeholder" = "Buscar historial..."; +"history.clear.all" = "Limpiar todo el historial"; +"history.empty.title" = "Sin Historial de Traducciones"; +"history.empty.message" = "Sus capturas traducidas aparecerán aquí"; +"history.no.results.title" = "Sin Resultados"; +"history.no.results.message" = "No hay entradas que coincidan con su búsqueda"; +"history.clear.search" = "Limpiar Búsqueda"; + +"history.source" = "Origen"; +"history.translation" = "Traducción"; +"history.truncated" = "truncado"; + +"history.copy.translation" = "Copiar Traducción"; +"history.copy.source" = "Copiar Origen"; +"history.copy.both" = "Copiar Ambos"; +"history.delete" = "Eliminar"; + +"history.clear.alert.title" = "Limpiar Historial"; +"history.clear.alert.message" = "¿Está seguro de que desea eliminar todo el historial de traducciones? Esta acción no se puede deshacer."; + + +/* ======================================== + Prompt de Permiso + ======================================== */ + +"permission.prompt.title" = "Permiso de Grabación de Pantalla Requerido"; +"permission.prompt.message" = "ScreenTranslate necesita permiso para capturar su pantalla. Esto es necesario para tomar capturas.\n\nDespués de hacer clic en Continuar, macOS le pedirá que otorgue el permiso de Grabación de Pantalla. Puede otorgarlo en Configuración del Sistema > Privacidad y Seguridad > Grabación de Pantalla."; +"permission.prompt.continue" = "Continuar"; +"permission.prompt.later" = "Más tarde"; + +/* Permiso de Accesibilidad */ +"permission.accessibility.title" = "Permiso de Accesibilidad Requerido"; +"permission.accessibility.message" = "ScreenTranslate necesita permiso de accesibilidad para capturar texto seleccionado e insertar traducciones.\n\nEsto permite que la aplicación:\n• Copie texto seleccionado de cualquier aplicación\n• Inserte texto traducido en campos de entrada\n\nSu privacidad está protegida - ScreenTranslate solo usa esto para la traducción de texto."; +"permission.accessibility.grant" = "Otorgar Permiso"; +"permission.accessibility.open.settings" = "Abrir Configuración del Sistema"; +"permission.accessibility.denied.title" = "Permiso de Accesibilidad Requerido"; +"permission.accessibility.denied.message" = "La captura e inserción de texto requiere permiso de accesibilidad.\n\nOtorgue permiso en Configuración del Sistema > Privacidad y Seguridad > Accesibilidad."; + +/* Permiso de Monitoreo de Entrada */ +"permission.input.monitoring.title" = "Permiso de Monitoreo de Entrada Requerido"; +"permission.input.monitoring.message" = "ScreenTranslate necesita permiso de monitoreo de entrada para insertar texto traducido en aplicaciones.\n\nNecesitará habilitar esto en:\nConfiguración del Sistema > Privacidad y Seguridad > Monitoreo de Entrada"; +"permission.input.monitoring.open.settings" = "Abrir Configuración del Sistema"; +"permission.input.monitoring.denied.title" = "Permiso de Monitoreo de Entrada Requerido"; +"permission.input.monitoring.denied.message" = "La inserción de texto requiere permiso de monitoreo de entrada.\n\nOtorgue permiso en Configuración del Sistema > Privacidad y Seguridad > Monitoreo de Entrada."; + +/* Cadenas Comunes de Permiso */ +"permission.open.settings" = "Abrir Configuración del Sistema"; + + +/* ======================================== + Configuración Inicial + ======================================== */ + +"onboarding.window.title" = "Bienvenido a ScreenTranslate"; + +/* Configuración Inicial - Paso de Bienvenida */ +"onboarding.welcome.title" = "Bienvenido a ScreenTranslate"; +"onboarding.welcome.message" = "Configuremos las funciones de captura de pantalla y traducción. Solo tomará un minuto."; + +"onboarding.feature.local.ocr.title" = "OCR Local"; +"onboarding.feature.local.ocr.description" = "Framework Vision de macOS para reconocimiento de texto rápido y privado"; +"onboarding.feature.local.translation.title" = "Traducción Local"; +"onboarding.feature.local.translation.description" = "Traducción de Apple para traducción instantánea sin conexión"; +"onboarding.feature.shortcuts.title" = "Atajos Globales"; +"onboarding.feature.shortcuts.description" = "Capture y traduzca desde cualquier lugar con atajos de teclado"; + +/* Configuración Inicial - Paso de Permisos */ +"onboarding.permissions.title" = "Permisos"; +"onboarding.permissions.message" = "ScreenTranslate necesita algunos permisos para funcionar correctamente. Otorgue los siguientes permisos:"; +"onboarding.permissions.hint" = "Después de otorgar permisos, el estado se actualizará automáticamente."; + +"onboarding.permission.screen.recording" = "Grabación de Pantalla"; +"onboarding.permission.accessibility" = "Accesibilidad"; +"onboarding.permission.granted" = "Otorgado"; +"onboarding.permission.not.granted" = "No Otorgado"; +"onboarding.permission.grant" = "Otorgar Permiso"; + +/* Configuración Inicial - Paso de Configuración */ +"onboarding.configuration.title" = "Configuración Opcional"; +"onboarding.configuration.message" = "Sus funciones OCR y traducción local ya están habilitadas. Opcionalmente configure servicios externos:"; +"onboarding.configuration.paddleocr" = "Dirección del Servidor PaddleOCR"; +"onboarding.configuration.paddleocr.hint" = "Déjelo vacío para usar macOS Vision OCR"; +"onboarding.configuration.mtran" = "Dirección de MTranServer"; +"onboarding.configuration.mtran.hint" = "Déjelo vacío para usar Traducción de Apple"; +"onboarding.configuration.placeholder" = "http://localhost:8080"; +"onboarding.configuration.placeholder.address" = "localhost"; +"onboarding.configuration.test" = "Probar Traducción"; +"onboarding.configuration.test.button" = "Probar Traducción"; +"onboarding.configuration.testing" = "Probando..."; +"onboarding.test.success" = "Prueba de traducción exitosa: \"%@\" → \"%@\""; +"onboarding.test.failed" = "Prueba de traducción fallida: %@"; + +/* Configuración Inicial - Paso de Finalización */ +"onboarding.complete.title" = "¡Todo Listo!"; +"onboarding.complete.message" = "ScreenTranslate está listo para usar. Así es cómo comenzar:"; +"onboarding.complete.shortcuts" = "Use ⌘⇧F para capturar la pantalla completa"; +"onboarding.complete.selection" = "Use ⌘⇧A para capturar una selección y traducir"; +"onboarding.complete.settings" = "Abra Configuración desde la barra de menú para personalizar opciones"; +"onboarding.complete.start" = "Comenzar a Usar ScreenTranslate"; + +/* Configuración Inicial - Navegación */ +"onboarding.back" = "Atrás"; +"onboarding.continue" = "Continuar"; +"onboarding.next" = "Siguiente"; +"onboarding.skip" = "Omitir"; +"onboarding.complete" = "Completar"; + +/* Configuración Inicial - PaddleOCR */ +"onboarding.paddleocr.title" = "PaddleOCR (Opcional)"; +"onboarding.paddleocr.description" = "Motor OCR mejorado para mayor precisión en el reconocimiento de texto, especialmente para chino."; +"onboarding.paddleocr.installed" = "Instalado"; +"onboarding.paddleocr.not.installed" = "No Instalado"; +"onboarding.paddleocr.install" = "Instalar"; +"onboarding.paddleocr.installing" = "Instalando..."; +"onboarding.paddleocr.install.hint" = "Requiere Python 3 y pip. Ejecute: pip3 install paddleocr paddlepaddle"; +"onboarding.paddleocr.copy.command" = "Copiar Comando"; +"onboarding.paddleocr.refresh" = "Actualizar Estado"; +"onboarding.paddleocr.version" = "Versión: %@"; + +/* Configuración - PaddleOCR */ +"settings.paddleocr.installed" = "Instalado"; +"settings.paddleocr.not.installed" = "No Instalado"; +"settings.paddleocr.install" = "Instalar"; +"settings.paddleocr.installing" = "Instalando..."; +"settings.paddleocr.install.hint" = "Requiere Python 3 y pip instalado en su sistema."; +"settings.paddleocr.copy.command" = "Copiar Comando"; +"settings.paddleocr.refresh" = "Actualizar Estado"; +"settings.paddleocr.ready" = "PaddleOCR está listo"; +"settings.paddleocr.not.installed.message" = "PaddleOCR no está instalado"; +"settings.paddleocr.description" = "PaddleOCR es un motor OCR local. Es gratuito, funciona sin conexión y no requiere una clave API."; +"settings.paddleocr.install.button" = "Instalar PaddleOCR"; +"settings.paddleocr.copy.command.button" = "Copiar Comando de Instalación"; +"settings.paddleocr.mode" = "Modo"; +"settings.paddleocr.mode.fast" = "Rápido"; +"settings.paddleocr.mode.precise" = "Preciso"; +"settings.paddleocr.mode.fast.description" = "~1s, OCR rápido con agrupación de líneas"; +"settings.paddleocr.mode.precise.description" = "~12s, modelo VL-1.5 con mayor precisión"; +"settings.paddleocr.useCloud" = "Usar API en la Nube"; +"settings.paddleocr.cloudBaseURL" = "URL de API en la Nube"; +"settings.paddleocr.cloudAPIKey" = "Clave API"; +"settings.paddleocr.cloudModelId" = "ID del Modelo"; +"settings.paddleocr.localVLModelDir" = "Directorio de Modelo Local (vllm)"; +"settings.paddleocr.localVLModelDir.hint" = "Ruta al modelo PaddleOCR-VL local (ej. ~/.paddlex/official_models/PaddleOCR-VL-1.5)"; +"error.paddleocr.notInstalled" = "PaddleOCR no está instalado. Instálelo usando: pip3 install paddleocr paddlepaddle"; + + +/* ======================================== + Configuración VLM + ======================================== */ + +"settings.vlm.title" = "Configuración VLM"; +"settings.vlm.provider" = "Proveedor"; +"settings.vlm.apiKey" = "Clave API"; +"settings.vlm.apiKey.optional" = "La clave API es opcional para proveedores locales"; +"settings.vlm.baseURL" = "URL Base"; +"settings.vlm.model" = "Nombre del Modelo"; +"settings.vlm.test.button" = "Probar Conexión"; +"settings.vlm.test.success" = "¡Conexión exitosa! Modelo: %@"; +"settings.vlm.test.ollama.success" = "Servidor en ejecución. Modelo '%@' disponible"; +"settings.vlm.test.ollama.available" = "Servidor en ejecución. Disponible: %@"; + +"vlm.provider.openai" = "OpenAI"; +"vlm.provider.claude" = "Claude"; +"vlm.provider.glmocr" = "GLM OCR"; +"vlm.provider.ollama" = "Ollama"; +"vlm.provider.paddleocr" = "PaddleOCR"; +"vlm.provider.openai.description" = "API de OpenAI GPT-4 Vision"; +"vlm.provider.claude.description" = "API de Anthropic Claude Vision"; +"vlm.provider.glmocr.description" = "API de análisis de diseño Zhipu GLM-OCR"; +"vlm.provider.ollama.description" = "Servidor Ollama local"; +"vlm.provider.paddleocr.description" = "Motor OCR local (gratis, sin conexión)"; + + +/* ======================================== + Configuración del Flujo de Traducción + ======================================== */ + +"settings.translation.workflow.title" = "Motor de Traducción"; +"settings.translation.preferred" = "Motor Preferido"; +"settings.translation.mtran.url" = "URL de MTransServer"; +"settings.translation.mtran.test.button" = "Probar Conexión"; +"settings.translation.mtran.test.success" = "Conexión exitosa"; +"settings.translation.mtran.test.failed" = "Conexión fallida: %@"; +"settings.translation.fallback" = "Respaldo"; +"settings.translation.fallback.description" = "Usar Traducción de Apple como respaldo cuando falla el motor preferido"; + +"translation.preferred.apple.description" = "Traducción de macOS integrada, funciona sin conexión"; +"translation.preferred.mtran.description" = "Servidor de traducción autohospedado para mejor calidad"; + + +/* ======================================== + Etiquetas de Accesibilidad + ======================================== */ + +"accessibility.close.button" = "Cerrar"; +"accessibility.settings.button" = "Configuración"; +"accessibility.capture.button" = "Capturar"; +"accessibility.translate.button" = "Traducir"; + + +/* ======================================== + Ventana de Resultados Bilingües + ======================================== */ + +/* ======================================== + Flujo de Traducción + ======================================== */ + +"translationFlow.phase.idle" = "Listo"; +"translationFlow.phase.analyzing" = "Analizando imagen..."; +"translationFlow.phase.translating" = "Traduciendo..."; +"translationFlow.phase.rendering" = "Renderizando..."; +"translationFlow.phase.completed" = "Completado"; +"translationFlow.phase.failed" = "Fallido"; + +"translationFlow.error.title" = "Error de Traducción"; +"translationFlow.error.title.analysis" = "Reconocimiento de Imagen Fallido"; +"translationFlow.error.title.translation" = "Traducción Fallida"; +"translationFlow.error.title.rendering" = "Renderizado Fallido"; +"translationFlow.error.unknown" = "Ocurrió un error desconocido."; +"translationFlow.error.analysis" = "Análisis fallido: %@"; +"translationFlow.error.translation" = "Traducción fallida: %@"; +"translationFlow.error.rendering" = "Renderizado fallido: %@"; +"translationFlow.error.cancelled" = "La traducción fue cancelada."; +"translationFlow.error.noTextFound" = "No se encontró texto en el área seleccionada."; +"translationFlow.error.translation.engine" = "Motor de Traducción"; + +"translationFlow.recovery.analysis" = "Inténtelo de nuevo con una imagen más clara, o verifique la configuración del proveedor VLM."; +"translationFlow.recovery.translation" = "Verifique la configuración del motor de traducción y la conexión de red, luego inténtelo de nuevo."; +"translationFlow.recovery.rendering" = "Inténtelo de nuevo."; +"translationFlow.recovery.noTextFound" = "Intente seleccionar un área con texto visible."; + +"common.ok" = "OK"; + + +/* ======================================== + Ventana de Resultados Bilingües + ======================================== */ + +"bilingualResult.window.title" = "Traducción Bilingüe"; +"bilingualResult.loading" = "Traduciendo..."; +"bilingualResult.loading.analyzing" = "Analizando imagen..."; +"bilingualResult.loading.translating" = "Traduciendo texto..."; +"bilingualResult.loading.rendering" = "Renderizando resultado..."; +"bilingualResult.copyImage" = "Copiar Imagen"; +"bilingualResult.copyText" = "Copiar Texto"; +"bilingualResult.save" = "Guardar"; +"bilingualResult.zoomIn" = "Acercar"; +"bilingualResult.zoomOut" = "Alejar"; +"bilingualResult.resetZoom" = "Restablecer Zoom"; +"bilingualResult.copySuccess" = "Copiado al portapapeles"; +"bilingualResult.copyTextSuccess" = "Texto de traducción copiado"; +"bilingualResult.saveSuccess" = "Guardado exitosamente"; +"bilingualResult.copyFailed" = "Error al copiar la imagen"; +"bilingualResult.saveFailed" = "Error al guardar la imagen"; +"bilingualResult.noTextToCopy" = "No hay texto de traducción para copiar"; + + +/* ======================================== + Traducción de Texto (US-003 a US-010) + ======================================== */ + +/* Flujo de Traducción de Texto */ +"textTranslation.phase.idle" = "Listo"; +"textTranslation.phase.translating" = "Traduciendo..."; +"textTranslation.phase.completed" = "Completado"; +"textTranslation.phase.failed" = "Fallido"; + +"textTranslation.error.emptyInput" = "No hay texto para traducir"; +"textTranslation.error.translationFailed" = "Traducción fallida: %@"; +"textTranslation.error.cancelled" = "La traducción fue cancelada"; +"textTranslation.error.serviceUnavailable" = "El servicio de traducción no está disponible"; +"textTranslation.error.insertFailed" = "No se pudo insertar el texto traducido"; + +"textTranslation.recovery.emptyInput" = "Seleccione algo de texto primero"; +"textTranslation.recovery.translationFailed" = "Inténtelo de nuevo"; +"textTranslation.recovery.serviceUnavailable" = "Verifique su conexión de red e inténtelo de nuevo"; + +"textTranslation.loading" = "Traduciendo..."; +"textTranslation.noSelection.title" = "Ningún Texto Seleccionado"; +"textTranslation.noSelection.message" = "Seleccione algo de texto en cualquier aplicación e inténtelo de nuevo."; + +/* Traducir e Insertar */ +"translateAndInsert.emptyClipboard.title" = "El Portapapeles Está Vacío"; +"translateAndInsert.emptyClipboard.message" = "Copie algo de texto al portapapeles primero, luego use este atajo."; +"translateAndInsert.success.title" = "Traducción Insertada"; +"translateAndInsert.success.message" = "El texto traducido ha sido insertado en el campo de entrada enfocado."; + +/* Visualización de Idioma */ +"language.auto" = "Detectado Automáticamente"; + +/* UI Común de Traducción de Texto */ +"common.copy" = "Copiar"; +"common.copied" = "Copiado"; +"common.insert" = "Insertar"; + +/* Ventana de Traducción de Texto */ +"textTranslation.window.title" = "Traducción de Texto"; + +/* Configuración de Idioma para Traducir e Insertar */ +"settings.translateAndInsert.language.section" = "Idiomas para Traducir e Insertar"; +"settings.translateAndInsert.language.source" = "Idioma de Origen"; +"settings.translateAndInsert.language.target" = "Idioma de Destino"; + + +/* ======================================== + Múltiples Motores Compatibles con OpenAI + ======================================== */ + +/* Configuración de Motor Compatible */ +"engine.compatible.new" = "Nuevo Motor Compatible"; +"engine.compatible.description" = "Endpoint de API compatible con OpenAI"; +"engine.compatible.displayName" = "Nombre para Mostrar"; +"engine.compatible.displayName.placeholder" = "ej., Mi Servidor LLM"; +"engine.compatible.requireApiKey" = "Requiere Clave API"; +"engine.compatible.add" = "Agregar Motor Compatible"; +"engine.compatible.delete" = "Eliminar este motor"; +"engine.compatible.useAsEngine" = "Usar como motor de traducción"; +"engine.compatible.max.reached" = "Máximo de 5 motores compatibles alcanzado"; + +/* Configuración de Prompts */ +"prompt.compatible.title" = "Motores Compatibles"; + + +/* ======================================== + Ventana Acerca De + ======================================== */ + +"about.title" = "Acerca de ScreenTranslate"; +"about.app.name" = "ScreenTranslate"; +"about.version.format" = "Versión %@ (%@)"; +"about.copyright" = "Derechos de Autor"; +"about.copyright.value" = "© 2026 Todos los derechos reservados"; +"about.license" = "Licencia"; +"about.license.value" = "Licencia MIT"; +"about.github.link" = "GitHub: hubo1989/ScreenTranslate"; +"about.check.for.updates" = "Buscar Actualizaciones"; +"about.update.checking" = "Verificando..."; +"about.update.available" = "Actualización disponible"; +"about.update.uptodate" = "Está actualizado"; +"about.update.failed" = "Verificación fallida"; +"about.acknowledgements" = "Reconocimientos"; +"about.acknowledgements.title" = "Reconocimientos"; +"about.acknowledgements.intro" = "Este software utiliza las siguientes bibliotecas de código abierto:"; +"about.acknowledgements.upstream" = "Basado en"; +"about.acknowledgements.author.format" = "por %@"; +"about.close" = "Cerrar"; +"settings.glmocr.mode" = "Modo"; +"settings.glmocr.mode.cloud" = "Nube"; +"settings.glmocr.mode.local" = "Local"; +"settings.glmocr.local.apiKey.optional" = "La clave API es opcional para servidores MLX-VLM locales"; +"vlm.provider.glmocr.local.description" = "Servidor MLX-VLM local para GLM-OCR"; diff --git a/ScreenTranslate/Resources/fr.lproj/Localizable.strings b/ScreenTranslate/Resources/fr.lproj/Localizable.strings new file mode 100644 index 0000000..e2d3ec8 --- /dev/null +++ b/ScreenTranslate/Resources/fr.lproj/Localizable.strings @@ -0,0 +1,823 @@ +/* + Localizable.strings (Français) + ScreenTranslate +*/ + +/* ======================================== + Messages d'erreur + ======================================== */ + +/* Erreurs de permission */ +"error.permission.denied" = "L'autorisation d'enregistrement d'écran est requise pour capturer des captures d'écran."; +"error.permission.denied.recovery" = "Ouvrez les Réglages Système pour accorder l'autorisation."; + +/* Erreurs d'affichage */ +"error.display.not.found" = "L'écran sélectionné n'est plus disponible."; +"error.display.not.found.recovery" = "Veuillez sélectionner un autre écran."; +"error.display.disconnected" = "L'écran '%@' a été déconnecté pendant la capture."; +"error.display.disconnected.recovery" = "Veuillez reconnecter l'écran et réessayer."; + +/* Erreurs de capture */ +"error.capture.failed" = "Échec de la capture de l'écran."; +"error.capture.failed.recovery" = "Veuillez réessayer."; + +/* Erreurs d'enregistrement */ +"error.save.location.invalid" = "L'emplacement d'enregistrement n'est pas accessible."; +"error.save.location.invalid.recovery" = "Choisissez un autre emplacement dans les Réglages."; +"error.save.location.invalid.detail" = "Impossible d'enregistrer dans %@. L'emplacement n'est pas accessible."; +"error.save.unknown" = "Une erreur inattendue s'est produite lors de l'enregistrement."; +"error.disk.full" = "Il n'y a pas assez d'espace disque pour enregistrer la capture d'écran."; +"error.disk.full.recovery" = "Libérez de l'espace disque et réessayez."; + +/* Erreurs d'exportation */ +"error.export.encoding.failed" = "Échec de l'encodage de l'image."; +"error.export.encoding.failed.recovery" = "Essayez un autre format dans les Réglages."; +"error.export.encoding.failed.detail" = "Échec de l'encodage de l'image au format %@."; + +/* Erreurs du presse-papiers */ +"error.clipboard.write.failed" = "Échec de la copie de la capture d'écran dans le presse-papiers."; +"error.clipboard.write.failed.recovery" = "Veuillez réessayer."; + +/* Erreurs de raccourci clavier */ +"error.hotkey.registration.failed" = "Échec de l'enregistrement du raccourci clavier."; +"error.hotkey.registration.failed.recovery" = "Le raccourci peut entrer en conflit avec une autre application. Essayez un autre raccourci."; +"error.hotkey.conflict" = "Ce raccourci clavier entre en conflit avec une autre application."; +"error.hotkey.conflict.recovery" = "Choisissez un autre raccourci clavier."; + +/* Erreurs OCR */ +"error.ocr.failed" = "Échec de la reconnaissance de texte."; +"error.ocr.failed.recovery" = "Veuillez réessayer avec une image plus claire."; +"error.ocr.no.text" = "Aucun texte n'a été reconnu dans l'image."; +"error.ocr.no.text.recovery" = "Essayez de capturer une zone avec du texte visible."; +"error.ocr.cancelled" = "La reconnaissance de texte a été annulée."; +"error.ocr.server.unreachable" = "Impossible de se connecter au serveur OCR."; +"error.ocr.server.unreachable.recovery" = "Vérifiez l'adresse du serveur et la connexion réseau."; + +/* Erreurs de traduction */ +"error.translation.in.progress" = "Une traduction est déjà en cours"; +"error.translation.in.progress.recovery" = "Veuillez attendre que la traduction actuelle se termine"; +"error.translation.empty.input" = "Aucun texte à traduire"; +"error.translation.empty.input.recovery" = "Veuillez d'abord sélectionner du texte"; +"error.translation.timeout" = "Délai de traduction dépassé"; +"error.translation.timeout.recovery" = "Veuillez réessayer"; +"error.translation.unsupported.pair" = "La traduction de %@ vers %@ n'est pas prise en charge"; +"error.translation.unsupported.pair.recovery" = "Veuillez sélectionner d'autres langues"; +"error.translation.failed" = "Échec de la traduction"; +"error.translation.failed.recovery" = "Veuillez réessayer"; +"error.translation.language.not.installed" = "La langue de traduction '%@' n'est pas installée"; +"error.translation.language.download.instructions" = "Accédez à Réglages Système > Général > Langue et région > Langues de traduction, puis téléchargez la langue requise."; + +/* Interface utilisateur d'erreur générique */ +"error.title" = "Erreur"; +"error.ok" = "OK"; +"error.dismiss" = "Fermer"; +"error.retry.capture" = "Réessayer"; +"error.permission.open.settings" = "Ouvrir les Réglages Système"; + + +/* ======================================== + Éléments de menu + ======================================== */ + +"menu.capture.full.screen" = "Capturer l'écran entier"; +"menu.capture.fullscreen" = "Capturer l'écran entier"; +"menu.capture.selection" = "Capturer la sélection"; +"menu.translation.mode" = "Mode Traduction"; +"menu.translation.history" = "Historique des traductions"; +"menu.settings" = "Réglages..."; +"menu.about" = "À propos de ScreenTranslate"; +"menu.quit" = "Quitter ScreenTranslate"; + + +/* ======================================== + Sélecteur d'écran + ======================================== */ + +"display.selector.title" = "Sélectionner l'écran"; +"display.selector.header" = "Choisissez l'écran à capturer :"; +"display.selector.cancel" = "Annuler"; + + +/* ======================================== + Fenêtre d'aperçu + ======================================== */ + +"preview.window.title" = "Aperçu de la capture d'écran"; +"preview.title" = "Aperçu de la capture d'écran"; +"preview.dimensions" = "%d x %d pixels"; +"preview.file.size" = "~%@ %@"; +"preview.screenshot" = "Capture d'écran"; +"preview.enter.text" = "Saisir du texte"; +"preview.image.dimensions" = "Dimensions de l'image"; +"preview.estimated.size" = "Taille estimée du fichier"; +"preview.edit.label" = "Modifier :"; +"preview.active.tool" = "Outil actif"; +"preview.crop.mode.active" = "Mode recadrage actif"; + +/* Recadrage */ +"preview.crop" = "Recadrer"; +"preview.crop.cancel" = "Annuler"; +"preview.crop.apply" = "Appliquer le recadrage"; + +/* Texte reconnu */ +"preview.recognized.text" = "Texte reconnu :"; +"preview.translation" = "Traduction :"; +"preview.results.panel" = "Résultats de texte"; +"preview.copy.text" = "Copier le texte"; + +/* Info-bulles de la barre d'outils */ +"preview.tooltip.crop" = "Recadrer (C)"; +"preview.tooltip.pin" = "Épingler à l'écran (P)"; +"preview.tooltip.undo" = "Annuler (⌘Z)"; +"preview.tooltip.redo" = "Rétablir (⌘⇧Z)"; +"preview.tooltip.copy" = "Copier dans le presse-papiers (⌘C)"; +"preview.tooltip.save" = "Enregistrer (⌘S)"; +"preview.tooltip.ocr" = "Reconnaître le texte (OCR)"; +"preview.tooltip.confirm" = "Copier dans le presse-papiers et fermer (Entrée)"; +"preview.tooltip.dismiss" = "Fermer (Échap)"; +"preview.tooltip.delete" = "Supprimer l'annotation sélectionnée"; + +/* Labels d'accessibilité */ +"preview.accessibility.save" = "Enregistrer la capture d'écran"; +"preview.accessibility.saving" = "Enregistrement de la capture d'écran"; +"preview.accessibility.confirm" = "Confirmer et copier dans le presse-papiers"; +"preview.accessibility.copying" = "Copie dans le presse-papiers"; +"preview.accessibility.hint.commandS" = "Command S"; +"preview.accessibility.hint.enter" = "Touche Entrée"; + +/* Bascule de forme */ +"preview.shape.filled" = "Rempli"; +"preview.shape.hollow" = "Creux"; +"preview.shape.toggle.hint" = "Cliquez pour basculer entre rempli et creux"; + + +/* ======================================== + Outils d'annotation + ======================================== */ + +"tool.rectangle" = "Rectangle"; +"tool.freehand" = "Dessin à main levée"; +"tool.text" = "Texte"; +"tool.arrow" = "Flèche"; +"tool.ellipse" = "Ellipse"; +"tool.line" = "Ligne"; +"tool.highlight" = "Surlignage"; +"tool.mosaic" = "Mosaïque"; +"tool.numberLabel" = "Étiquette numérotée"; + + +/* ======================================== + Couleurs + ======================================== */ + +"color.red" = "Rouge"; +"color.orange" = "Orange"; +"color.yellow" = "Jaune"; +"color.green" = "Vert"; +"color.blue" = "Bleu"; +"color.purple" = "Violet"; +"color.pink" = "Rose"; +"color.white" = "Blanc"; +"color.black" = "Noir"; +"color.custom" = "Personnalisé"; + + +/* ======================================== + Actions + ======================================== */ + +"action.save" = "Enregistrer"; +"action.copy" = "Copier"; +"action.cancel" = "Annuler"; +"action.undo" = "Annuler"; +"action.redo" = "Rétablir"; +"action.delete" = "Supprimer"; +"action.clear" = "Effacer"; +"action.reset" = "Réinitialiser"; +"action.close" = "Fermer"; +"action.done" = "Terminé"; + +/* Boutons */ +"button.ok" = "OK"; +"button.cancel" = "Annuler"; +"button.clear" = "Effacer"; +"button.reset" = "Réinitialiser"; +"button.save" = "Enregistrer"; +"button.delete" = "Supprimer"; +"button.confirm" = "Confirmer"; + +/* Enregistrement réussi */ +"save.success.title" = "Enregistré avec succès"; +"save.success.message" = "Enregistré dans %@"; +"save.with.translations.message" = "Choisissez où enregistrer l'image traduite"; + +/* Erreur d'absence de traduction */ +"error.no.translations" = "Aucune traduction disponible. Veuillez d'abord traduire le texte."; + +/* Copie réussie */ +"copy.success.message" = "Copié dans le presse-papiers"; + + +/* ======================================== + Fenêtre des réglages + ======================================== */ + +"settings.window.title" = "Réglages de ScreenTranslate"; +"settings.title" = "Réglages de ScreenTranslate"; + +/* Onglets/Sections des réglages */ +"settings.section.permissions" = "Autorisations"; +"settings.section.general" = "Général"; +"settings.section.engines" = "Moteurs"; +"settings.section.prompts" = "Configuration des invites"; +"settings.section.languages" = "Langues"; +"settings.section.export" = "Exportation"; +"settings.section.shortcuts" = "Raccourcis clavier"; +"settings.section.text.translation" = "Traduction de texte"; +"settings.section.annotations" = "Annotations"; + +/* Réglages de langue */ +"settings.language" = "Langue"; +"settings.language.system" = "Défaut du système"; +"settings.language.restart.hint" = "Certains changements peuvent nécessiter un redémarrage"; + +/* Autorisations */ +"settings.permission.screen.recording" = "Enregistrement de l'écran"; +"settings.permission.screen.recording.hint" = "Requis pour capturer des captures d'écran"; +"settings.permission.accessibility" = "Accessibilité"; +"settings.permission.accessibility.hint" = "Requis pour les raccourcis globaux"; +"settings.permission.granted" = "Accordée"; +"settings.permission.not.granted" = "Non accordée"; +"settings.permission.grant" = "Accorder l'accès"; +"settings.permission.authorization.title" = "Autorisation requise"; +"settings.permission.authorization.cancel" = "Annuler"; +"settings.permission.authorization.go" = "Accorder"; +"settings.permission.authorization.screen.message" = "L'autorisation d'enregistrement de l'écran est requise. Cliquez sur « Accorder » pour ouvrir les Réglages Système et activer ScreenCapture pour cette application."; +"settings.permission.authorization.accessibility.message" = "L'autorisation d'accessibilité est requise. Cliquez sur « Accorder » pour ouvrir les Réglages Système et ajouter cette application à la liste d'accessibilité."; + +/* Emplacement d'enregistrement */ +"settings.save.location" = "Emplacement d'enregistrement"; +"settings.save.location.choose" = "Choisir..."; +"settings.save.location.select" = "Sélectionner"; +"settings.save.location.message" = "Choisissez l'emplacement par défaut pour enregistrer les captures d'écran"; +"settings.save.location.reveal" = "Afficher dans le Finder"; + +/* Format d'exportation */ +"settings.format" = "Format par défaut"; +"settings.format.png" = "PNG"; +"settings.format.jpeg" = "JPEG"; +"settings.format.heic" = "HEIC"; +"settings.jpeg.quality" = "Qualité JPEG"; +"settings.jpeg.quality.hint" = "Une qualité plus élevée donne des fichiers plus volumineux"; +"settings.heic.quality" = "Qualité HEIC"; +"settings.heic.quality.hint" = "HEIC offre une meilleure compression"; + +/* Raccourcis clavier */ +"settings.shortcuts" = "Raccourcis clavier"; +"settings.shortcut.fullscreen" = "Capture de l'écran entier"; +"settings.shortcut.selection" = "Capture de sélection"; +"settings.shortcut.translation.mode" = "Mode de traduction"; +"settings.shortcut.text.selection.translation" = "Traduction de sélection de texte"; +"settings.shortcut.translate.and.insert" = "Traduire et insérer"; +"settings.shortcut.recording" = "Appuyez sur les touches..."; +"settings.shortcut.reset" = "Réinitialiser par défaut"; +"settings.shortcut.error.no.modifier" = "Les raccourcis doivent inclure Commande, Contrôle ou Option"; +"settings.shortcut.error.conflict" = "Ce raccourci est déjà utilisé"; + +/* Annotations */ +"settings.annotations" = "Paramètres par défaut des annotations"; +"settings.stroke.color" = "Couleur du trait"; +"settings.stroke.width" = "Épaisseur du trait"; +"settings.text.size" = "Taille du texte"; +"settings.mosaic.blockSize" = "Taille du bloc de mosaïque"; + +/* Moteurs */ +"settings.ocr.engine" = "Moteur OCR"; +"settings.translation.engine" = "Moteur de traduction"; +"settings.translation.mode" = "Mode de traduction"; + +/* Réinitialiser */ +"settings.reset.all" = "Réinitialiser tout par défaut"; + +/* Erreurs */ +"settings.error.title" = "Erreur"; +"settings.error.ok" = "OK"; + + +/* ======================================== + Moteurs OCR + ======================================== */ + +"ocr.engine.vision" = "Apple Vision"; +"ocr.engine.vision.description" = "Framework Vision macOS intégré, rapide et privé"; +"ocr.engine.paddleocr" = "PaddleOCR"; +"ocr.engine.paddleocr.description" = "Serveur OCR auto-hébergé pour une meilleure précision"; + + +/* ======================================== + Moteurs de traduction + ======================================== */ + +"translation.engine.apple" = "Traduction Apple"; +"translation.engine.apple.description" = "Traduction macOS intégrée, aucune configuration requise"; +"translation.engine.mtran" = "MTranServer"; +"translation.engine.mtran.description" = "Serveur de traduction auto-hébergé"; + +/* Nouveaux moteurs de traduction */ +"translation.engine.openai" = "OpenAI"; +"translation.engine.openai.description" = "Traduction GPT-4 via l'API OpenAI"; +"translation.engine.claude" = "Claude"; +"translation.engine.claude.description" = "Traduction Claude via l'API Anthropic"; +"translation.engine.gemini" = "Gemini"; +"translation.engine.gemini.description" = "Traduction Gemini via l'API Google AI"; +"translation.engine.ollama" = "Ollama"; +"translation.engine.ollama.description" = "Traduction LLM locale via Ollama"; +"translation.engine.google" = "Traduction Google"; +"translation.engine.google.description" = "API de traduction Google Cloud"; +"translation.engine.deepl" = "DeepL"; +"translation.engine.deepl.description" = "Traduction de haute qualité via l'API DeepL"; +"translation.engine.baidu" = "Traduction Baidu"; +"translation.engine.baidu.description" = "API de traduction Baidu"; +"translation.engine.custom" = "Compatible OpenAI"; +"translation.engine.custom.description" = "Point de terminaison personnalisé compatible OpenAI"; + +/* Catégories de moteurs */ +"engine.category.builtin" = "Intégré"; +"engine.category.llm" = "Traduction LLM"; +"engine.category.cloud" = "Services cloud"; +"engine.category.compatible" = "Compatible"; + +/* Titre de la configuration du moteur */ +"engine.config.title" = "Configuration du moteur de traduction"; + +/* Modes de sélection du moteur */ +"engine.selection.mode.title" = "Mode de sélection du moteur"; +"engine.selection.mode.primary_fallback" = "Principal/Secours"; +"engine.selection.mode.primary_fallback.description" = "Utiliser le moteur principal, basculer sur le secondaire en cas d'échec"; +"engine.selection.mode.parallel" = "Parallèle"; +"engine.selection.mode.parallel.description" = "Exécuter plusieurs moteurs simultanément et comparer les résultats"; +"engine.selection.mode.quick_switch" = "Basculement rapide"; +"engine.selection.mode.quick_switch.description" = "Commencer avec le principal, basculer rapidement vers d'autres moteurs à la demande"; +"engine.selection.mode.scene_binding" = "Liaison de scènes"; +"engine.selection.mode.scene_binding.description" = "Utiliser différents moteurs pour différents scénarios de traduction"; + +/* Labels spécifiques au mode */ +"engine.config.primary" = "Principal"; +"engine.config.fallback" = "Secours"; +"engine.config.switch.order" = "Ordre de basculement"; +"engine.config.parallel.select" = "Sélectionner les moteurs à exécuter en parallèle"; +"engine.config.replace" = "Remplacer le moteur"; +"engine.config.remove" = "Supprimer"; +"engine.config.add" = "Ajouter un moteur"; + +/* Scénarios de traduction */ +"translation.scene.screenshot" = "Traduction de capture d'écran"; +"translation.scene.screenshot.description" = "OCR et traduction des zones de capture d'écran"; +"translation.scene.text_selection" = "Traduction de sélection de texte"; +"translation.scene.text_selection.description" = "Traduire le texte sélectionné de n'importe quelle application"; +"translation.scene.translate_and_insert" = "Traduire et insérer"; +"translation.scene.translate_and_insert.description" = "Traduire le texte du presse-papiers et insérer au curseur"; + +/* Configuration du moteur */ +"engine.config.enabled" = "Activer ce moteur"; +"engine.config.apiKey" = "Clé API"; +"engine.config.apiKey.placeholder" = "Entrez votre clé API"; +"engine.config.getApiKey" = "Obtenir une clé API"; +"engine.config.baseURL" = "URL de base"; +"engine.config.model" = "Nom du modèle"; +"engine.config.test" = "Tester la connexion"; +"engine.config.test.success" = "Connexion réussie"; +"engine.config.test.failed" = "Échec de la connexion"; +"engine.config.baidu.credentials" = "Identifiants Baidu"; +"engine.config.baidu.appID" = "ID d'application"; +"engine.config.baidu.secretKey" = "Clé secrète"; +"engine.config.mtran.url" = "URL du serveur"; + +/* Statut du moteur */ +"engine.status.configured" = "Configuré"; +"engine.status.unconfigured" = "Non configuré"; +"engine.available.title" = "Moteurs disponibles"; +"engine.parallel.title" = "Moteurs parallèles"; +"engine.parallel.description" = "Sélectionner les moteurs à exécuter simultanément en mode parallèle"; +"engine.scene.binding.title" = "Liaison de moteurs de scènes"; +"engine.scene.binding.description" = "Configurer quel moteur utiliser pour chaque scénario de traduction"; +"engine.scene.fallback.tooltip" = "Activer le basculement vers d'autres moteurs"; + +/* Erreurs de trousseau */ +"keychain.error.item_not_found" = "Identifiants introuvables dans le trousseau"; +"keychain.error.item_not_found.recovery" = "Veuillez configurer vos identifiants API dans les Réglages"; +"keychain.error.duplicate_item" = "Les identifiants existent déjà dans le trousseau"; +"keychain.error.duplicate_item.recovery" = "Essayez d'abord de supprimer les identifiants existants"; +"keychain.error.invalid_data" = "Format de données d'identifiants invalide"; +"keychain.error.invalid_data.recovery" = "Essayez de ressaisir vos identifiants"; +"keychain.error.unexpected_status" = "Échec de l'opération du trousseau"; +"keychain.error.unexpected_status.recovery" = "Veuillez vérifier vos autorisations d'accès au trousseau"; + +/* Erreurs multi-moteurs */ +"multiengine.error.all_failed" = "Tous les moteurs de traduction ont échoué"; +"multiengine.error.no_engines" = "Aucun moteur de traduction n'est configuré"; +"multiengine.error.primary_unavailable" = "Le moteur principal %@ n'est pas disponible"; +"multiengine.error.no_results" = "Aucun résultat de traduction disponible"; + +/* Erreurs de registre */ +"registry.error.already_registered" = "Le fournisseur est déjà enregistré"; +"registry.error.not_registered" = "Aucun fournisseur enregistré pour %@"; +"registry.error.config_missing" = "Configuration manquante pour %@"; +"registry.error.credentials_not_found" = "Identifiants introuvables pour %@"; + +/* Configuration des invites */ +"prompt.engine.title" = "Invites de moteur"; +"prompt.engine.description" = "Personnaliser les invites de traduction pour chaque moteur LLM"; +"prompt.scene.title" = "Invites de scènes"; +"prompt.scene.description" = "Personnaliser les invites de traduction pour chaque scénario de traduction"; +"prompt.default.title" = "Modèle d'invite par défaut"; +"prompt.default.description" = "Ce modèle est utilisé lorsqu'aucune invite personnalisée n'est configurée"; +"prompt.button.edit" = "Modifier"; +"prompt.button.reset" = "Réinitialiser"; +"prompt.editor.title" = "Modifier l'invite"; +"prompt.editor.variables" = "Variables disponibles :"; +"prompt.variable.source_language" = "Nom de la langue source"; +"prompt.variable.target_language" = "Nom de la langue cible"; +"prompt.variable.text" = "Texte à traduire"; + + +/* ======================================== + Modes de traduction + ======================================== */ + +"translation.mode.inline" = "Remplacement sur place"; +"translation.mode.inline.description" = "Remplacer le texte original par la traduction"; +"translation.mode.below" = "Afficher sous l'original"; +"translation.mode.below.description" = "Afficher la traduction sous le texte original"; + + +/* ======================================== + Réglages de traduction + ======================================== */ + +"translation.auto" = "Détection automatique"; +"translation.auto.detected" = "Détecté automatiquement"; +"translation.language.follow.system" = "Suivre le système"; +"translation.language.source" = "Langue source"; +"translation.language.target" = "Langue cible"; +"translation.language.source.hint" = "La langue du texte que vous souhaitez traduire"; +"translation.language.target.hint" = "La langue dans laquelle traduire le texte"; + + +/* ======================================== + Vue de l'historique + ======================================== */ + +"history.title" = "Historique des traductions"; +"history.search.placeholder" = "Rechercher dans l'historique..."; +"history.clear.all" = "Effacer tout l'historique"; +"history.empty.title" = "Aucun historique de traduction"; +"history.empty.message" = "Vos captures d'écran traduites apparaîtront ici"; +"history.no.results.title" = "Aucun résultat"; +"history.no.results.message" = "Aucune entrée ne correspond à votre recherche"; +"history.clear.search" = "Effacer la recherche"; + +"history.source" = "Source"; +"history.translation" = "Traduction"; +"history.truncated" = "tronqué"; + +"history.copy.translation" = "Copier la traduction"; +"history.copy.source" = "Copier la source"; +"history.copy.both" = "Copier les deux"; +"history.delete" = "Supprimer"; + +"history.clear.alert.title" = "Effacer l'historique"; +"history.clear.alert.message" = "Êtes-vous sûr de vouloir supprimer tout l'historique des traductions ? Cette action ne peut être annulée."; + + +/* ======================================== + Invite d'autorisation + ======================================== */ + +"permission.prompt.title" = "Autorisation d'enregistrement de l'écran requise"; +"permission.prompt.message" = "ScreenTranslate a besoin d'une autorisation pour capturer votre écran. Ceci est requis pour prendre des captures d'écran.\n\nAprès avoir cliqué sur Continuer, macOS vous demandera d'accorder l'autorisation d'enregistrement de l'écran. Vous pouvez l'accorder dans Réglages Système > Confidentialité et sécurité > Enregistrement de l'écran."; +"permission.prompt.continue" = "Continuer"; +"permission.prompt.later" = "Plus tard"; + +/* Autorisation d'accessibilité */ +"permission.accessibility.title" = "Autorisation d'accessibilité requise"; +"permission.accessibility.message" = "ScreenTranslate a besoin de l'autorisation d'accessibilité pour capturer le texte sélectionné et insérer des traductions.\n\nCela permet à l'application de :\n• Copier le texte sélectionné de n'importe quelle application\n• Insérer du texte traduit dans les champs de saisie\n\nVotre vie privée est protégée - ScreenTranslate n'utilise cela que pour la traduction de texte."; +"permission.accessibility.grant" = "Accorder l'autorisation"; +"permission.accessibility.open.settings" = "Ouvrir les Réglages Système"; +"permission.accessibility.denied.title" = "Autorisation d'accessibilité requise"; +"permission.accessibility.denied.message" = "La capture et l'insertion de texte nécessitent une autorisation d'accessibilité.\n\nVeuillez accorder l'autorisation dans Réglages Système > Confidentialité et sécurité > Accessibilité."; + +/* Autorisation de surveillance des entrées */ +"permission.input.monitoring.title" = "Autorisation de surveillance des entrées requise"; +"permission.input.monitoring.message" = "ScreenTranslate a besoin de l'autorisation de surveillance des entrées pour insérer du texte traduit dans les applications.\n\nVous devrez l'activer dans :\nRéglages Système > Confidentialité et sécurité > Surveillance des entrées"; +"permission.input.monitoring.open.settings" = "Ouvrir les Réglages Système"; +"permission.input.monitoring.denied.title" = "Autorisation de surveillance des entrées requise"; +"permission.input.monitoring.denied.message" = "L'insertion de texte nécessite une autorisation de surveillance des entrées.\n\nVeuillez accorder l'autorisation dans Réglages Système > Confidentialité et sécurité > Surveillance des entrées."; + +/* Chaînes d'autorisation communes */ +"permission.open.settings" = "Ouvrir les Réglages Système"; + + +/* ======================================== + Intégration + ======================================== */ + +"onboarding.window.title" = "Bienvenue dans ScreenTranslate"; + +/* Intégration - Étape de bienvenue */ +"onboarding.welcome.title" = "Bienvenue dans ScreenTranslate"; +"onboarding.welcome.message" = "Configurons les fonctionnalités de capture d'écran et de traduction. Cela ne prendra qu'une minute."; + +"onboarding.feature.local.ocr.title" = "OCR local"; +"onboarding.feature.local.ocr.description" = "Framework Vision macOS pour une reconnaissance de texte rapide et privée"; +"onboarding.feature.local.translation.title" = "Traduction locale"; +"onboarding.feature.local.translation.description" = "Traduction Apple pour une traduction instantanée et hors ligne"; +"onboarding.feature.shortcuts.title" = "Raccourcis globaux"; +"onboarding.feature.shortcuts.description" = "Capturez et traduisez de n'importe où avec des raccourcis clavier"; + +/* Intégration - Étape des autorisations */ +"onboarding.permissions.title" = "Autorisations"; +"onboarding.permissions.message" = "ScreenTranslate a besoin de quelques autorisations pour fonctionner correctement. Veuillez accorder les autorisations suivantes :"; +"onboarding.permissions.hint" = "Après avoir accordé les autorisations, le statut se mettra à jour automatiquement."; + +"onboarding.permission.screen.recording" = "Enregistrement de l'écran"; +"onboarding.permission.accessibility" = "Accessibilité"; +"onboarding.permission.granted" = "Accordée"; +"onboarding.permission.not.granted" = "Non accordée"; +"onboarding.permission.grant" = "Accorder l'autorisation"; + +/* Intégration - Étape de configuration */ +"onboarding.configuration.title" = "Configuration facultative"; +"onboarding.configuration.message" = "Vos fonctionnalités OCR et de traduction locales sont déjà activées. Vous pouvez éventuellement configurer des services externes :"; +"onboarding.configuration.paddleocr" = "Adresse du serveur PaddleOCR"; +"onboarding.configuration.paddleocr.hint" = "Laissez vide pour utiliser l'OCR macOS Vision"; +"onboarding.configuration.mtran" = "Adresse du serveur MTranServer"; +"onboarding.configuration.mtran.hint" = "Laissez vide pour utiliser la Traduction Apple"; +"onboarding.configuration.placeholder" = "http://localhost:8080"; +"onboarding.configuration.placeholder.address" = "localhost"; +"onboarding.configuration.test" = "Tester la traduction"; +"onboarding.configuration.test.button" = "Tester la traduction"; +"onboarding.configuration.testing" = "Test en cours..."; +"onboarding.test.success" = "Test de traduction réussi : \"%@\" → \"%@\""; +"onboarding.test.failed" = "Échec du test de traduction : %@"; + +/* Intégration - Étape de finition */ +"onboarding.complete.title" = "Tout est prêt !"; +"onboarding.complete.message" = "ScreenTranslate est maintenant prêt à être utilisé. Voici comment commencer :"; +"onboarding.complete.shortcuts" = "Utilisez ⌘⇧F pour capturer l'écran entier"; +"onboarding.complete.selection" = "Utilisez ⌘⇧A pour capturer une sélection et traduire"; +"onboarding.complete.settings" = "Ouvrez les Réglages depuis la barre des menus pour personnaliser les options"; +"onboarding.complete.start" = "Commencer à utiliser ScreenTranslate"; + +/* Intégration - Navigation */ +"onboarding.back" = "Retour"; +"onboarding.continue" = "Continuer"; +"onboarding.next" = "Suivant"; +"onboarding.skip" = "Ignorer"; +"onboarding.complete" = "Terminer"; + +/* Intégration - PaddleOCR */ +"onboarding.paddleocr.title" = "PaddleOCR (facultatif)"; +"onboarding.paddleocr.description" = "Moteur OCR amélioré pour une meilleure précision de reconnaissance de texte, en particulier pour le chinois."; +"onboarding.paddleocr.installed" = "Installé"; +"onboarding.paddleocr.not.installed" = "Non installé"; +"onboarding.paddleocr.install" = "Installer"; +"onboarding.paddleocr.installing" = "Installation en cours..."; +"onboarding.paddleocr.install.hint" = "Nécessite Python 3 et pip. Exécutez : pip3 install paddleocr paddlepaddle"; +"onboarding.paddleocr.copy.command" = "Copier la commande"; +"onboarding.paddleocr.refresh" = "Actualiser le statut"; +"onboarding.paddleocr.version" = "Version : %@"; + +/* Réglages - PaddleOCR */ +"settings.paddleocr.installed" = "Installé"; +"settings.paddleocr.not.installed" = "Non installé"; +"settings.paddleocr.install" = "Installer"; +"settings.paddleocr.installing" = "Installation en cours..."; +"settings.paddleocr.install.hint" = "Nécessite Python 3 et pip installés sur votre système."; +"settings.paddleocr.copy.command" = "Copier la commande"; +"settings.paddleocr.refresh" = "Actualiser le statut"; +"settings.paddleocr.ready" = "PaddleOCR est prêt"; +"settings.paddleocr.not.installed.message" = "PaddleOCR n'est pas installé"; +"settings.paddleocr.description" = "PaddleOCR est un moteur OCR local. Il est gratuit, fonctionne hors ligne et ne nécessite pas de clé API."; +"settings.paddleocr.install.button" = "Installer PaddleOCR"; +"settings.paddleocr.copy.command.button" = "Copier la commande d'installation"; +"settings.paddleocr.mode" = "Mode"; +"settings.paddleocr.mode.fast" = "Rapide"; +"settings.paddleocr.mode.precise" = "Précis"; +"settings.paddleocr.mode.fast.description" = "~1s, OCR rapide avec regroupement de lignes"; +"settings.paddleocr.mode.precise.description" = "~12s, modèle VL-1.5 avec une plus grande précision"; +"settings.paddleocr.useCloud" = "Utiliser l'API cloud"; +"settings.paddleocr.cloudBaseURL" = "URL de l'API cloud"; +"settings.paddleocr.cloudAPIKey" = "Clé API"; +"settings.paddleocr.cloudModelId" = "ID du modèle"; +"settings.paddleocr.localVLModelDir" = "Répertoire du modèle local (vllm)"; +"settings.paddleocr.localVLModelDir.hint" = "Chemin vers le modèle PaddleOCR-VL local (ex: ~/.paddlex/official_models/PaddleOCR-VL-1.5)"; +"error.paddleocr.notInstalled" = "PaddleOCR n'est pas installé. Installez-le en utilisant : pip3 install paddleocr paddlepaddle"; + + +/* ======================================== + Configuration VLM + ======================================== */ + +"settings.vlm.title" = "Configuration VLM"; +"settings.vlm.provider" = "Fournisseur"; +"settings.vlm.apiKey" = "Clé API"; +"settings.vlm.apiKey.optional" = "La clé API est facultative pour les fournisseurs locaux"; +"settings.vlm.baseURL" = "URL de base"; +"settings.vlm.model" = "Nom du modèle"; +"settings.vlm.test.button" = "Tester la connexion"; +"settings.vlm.test.success" = "Connexion réussie ! Modèle : %@"; +"settings.vlm.test.ollama.success" = "Serveur en cours d'exécution. Modèle '%@' disponible"; +"settings.vlm.test.ollama.available" = "Serveur en cours d'exécution. Disponible : %@"; + +"vlm.provider.openai" = "OpenAI"; +"vlm.provider.claude" = "Claude"; +"vlm.provider.glmocr" = "GLM OCR"; +"vlm.provider.ollama" = "Ollama"; +"vlm.provider.paddleocr" = "PaddleOCR"; +"vlm.provider.openai.description" = "API GPT-4 Vision d'OpenAI"; +"vlm.provider.claude.description" = "API Claude Vision d'Anthropic"; +"vlm.provider.glmocr.description" = "API d'analyse de mise en page Zhipu GLM-OCR"; +"vlm.provider.ollama.description" = "Serveur Ollama local"; +"vlm.provider.paddleocr.description" = "Moteur OCR local (gratuit, hors ligne)"; + + +/* ======================================== + Configuration du flux de travail de traduction + ======================================== */ + +"settings.translation.workflow.title" = "Moteur de traduction"; +"settings.translation.preferred" = "Moteur préféré"; +"settings.translation.mtran.url" = "URL du serveur MTransServer"; +"settings.translation.mtran.test.button" = "Tester la connexion"; +"settings.translation.mtran.test.success" = "Connexion réussie"; +"settings.translation.mtran.test.failed" = "Échec de la connexion : %@"; +"settings.translation.fallback" = "Secours"; +"settings.translation.fallback.description" = "Utiliser la Traduction Apple comme secours en cas d'échec du moteur préféré"; + +"translation.preferred.apple.description" = "Traduction macOS intégrée, fonctionne hors ligne"; +"translation.preferred.mtran.description" = "Serveur de traduction auto-hébergé pour une meilleure qualité"; + + +/* ======================================== + Labels d'accessibilité + ======================================== */ + +"accessibility.close.button" = "Fermer"; +"accessibility.settings.button" = "Réglages"; +"accessibility.capture.button" = "Capturer"; +"accessibility.translate.button" = "Traduire"; + + +/* ======================================== + Fenêtre de résultat bilingue + ======================================== */ + +/* ======================================== + Flux de traduction + ======================================== */ + +"translationFlow.phase.idle" = "Prêt"; +"translationFlow.phase.analyzing" = "Analyse de l'image..."; +"translationFlow.phase.translating" = "Traduction..."; +"translationFlow.phase.rendering" = "Rendu..."; +"translationFlow.phase.completed" = "Terminé"; +"translationFlow.phase.failed" = "Échec"; + +"translationFlow.error.title" = "Erreur de traduction"; +"translationFlow.error.title.analysis" = "Échec de la reconnaissance d'image"; +"translationFlow.error.title.translation" = "Échec de la traduction"; +"translationFlow.error.title.rendering" = "Échec du rendu"; +"translationFlow.error.unknown" = "Une erreur inconnue s'est produite."; +"translationFlow.error.analysis" = "Échec de l'analyse : %@"; +"translationFlow.error.translation" = "Échec de la traduction : %@"; +"translationFlow.error.rendering" = "Échec du rendu : %@"; +"translationFlow.error.cancelled" = "La traduction a été annulée."; +"translationFlow.error.noTextFound" = "Aucun texte trouvé dans la zone sélectionnée."; +"translationFlow.error.translation.engine" = "Moteur de traduction"; + +"translationFlow.recovery.analysis" = "Veuillez réessayer avec une image plus claire ou vérifier vos réglages du fournisseur VLM."; +"translationFlow.recovery.translation" = "Vérifiez vos réglages du moteur de traduction et votre connexion réseau, puis réessayez."; +"translationFlow.recovery.rendering" = "Veuillez réessayer."; +"translationFlow.recovery.noTextFound" = "Essayez de sélectionner une zone avec du texte visible."; + +"common.ok" = "OK"; + + +/* ======================================== + Fenêtre de résultat bilingue + ======================================== */ + +"bilingualResult.window.title" = "Traduction bilingue"; +"bilingualResult.loading" = "Traduction..."; +"bilingualResult.loading.analyzing" = "Analyse de l'image..."; +"bilingualResult.loading.translating" = "Traduction du texte..."; +"bilingualResult.loading.rendering" = "Rendu du résultat..."; +"bilingualResult.copyImage" = "Copier l'image"; +"bilingualResult.copyText" = "Copier le texte"; +"bilingualResult.save" = "Enregistrer"; +"bilingualResult.zoomIn" = "Zoom avant"; +"bilingualResult.zoomOut" = "Zoom arrière"; +"bilingualResult.resetZoom" = "Réinitialiser le zoom"; +"bilingualResult.copySuccess" = "Copié dans le presse-papiers"; +"bilingualResult.copyTextSuccess" = "Texte de la traduction copié"; +"bilingualResult.saveSuccess" = "Enregistré avec succès"; +"bilingualResult.copyFailed" = "Échec de la copie de l'image"; +"bilingualResult.saveFailed" = "Échec de l'enregistrement de l'image"; +"bilingualResult.noTextToCopy" = "Aucun texte de traduction à copier"; + + +/* ======================================== + Traduction de texte (US-003 à US-010) + ======================================== */ + +/* Flux de traduction de texte */ +"textTranslation.phase.idle" = "Prêt"; +"textTranslation.phase.translating" = "Traduction..."; +"textTranslation.phase.completed" = "Terminé"; +"textTranslation.phase.failed" = "Échec"; + +"textTranslation.error.emptyInput" = "Aucun texte à traduire"; +"textTranslation.error.translationFailed" = "Échec de la traduction : %@"; +"textTranslation.error.cancelled" = "La traduction a été annulée"; +"textTranslation.error.serviceUnavailable" = "Le service de traduction n'est pas disponible"; +"textTranslation.error.insertFailed" = "Impossible d'insérer le texte traduit"; + +"textTranslation.recovery.emptyInput" = "Veuillez d'abord sélectionner du texte"; +"textTranslation.recovery.translationFailed" = "Veuillez réessayer"; +"textTranslation.recovery.serviceUnavailable" = "Vérifiez votre connexion réseau et réessayez"; + +"textTranslation.loading" = "Traduction..."; +"textTranslation.noSelection.title" = "Aucun texte sélectionné"; +"textTranslation.noSelection.message" = "Veuillez sélectionner du texte dans n'importe quelle application et réessayer."; + +/* Traduire et insérer */ +"translateAndInsert.emptyClipboard.title" = "Presse-papiers vide"; +"translateAndInsert.emptyClipboard.message" = "Copiez d'abord du texte dans le presse-papiers, puis utilisez ce raccourci."; +"translateAndInsert.success.title" = "Traduction insérée"; +"translateAndInsert.success.message" = "Le texte traduit a été inséré dans le champ de saisie actif."; + +/* Affichage des langues */ +"language.auto" = "Détecté automatiquement"; + +/* Interface utilisateur de traduction de texte commune */ +"common.copy" = "Copier"; +"common.copied" = "Copié"; +"common.insert" = "Insérer"; + +/* Fenêtre de traduction de texte */ +"textTranslation.window.title" = "Traduction de texte"; + +/* Réglages de langue pour traduire et insérer */ +"settings.translateAndInsert.language.section" = "Langues pour traduire et insérer"; +"settings.translateAndInsert.language.source" = "Langue source"; +"settings.translateAndInsert.language.target" = "Langue cible"; + + +/* ======================================== + Moteurs compatibles OpenAI multiples + ======================================== */ + +/* Configuration du moteur compatible */ +"engine.compatible.new" = "Nouveau moteur compatible"; +"engine.compatible.description" = "Point de terminaison API compatible OpenAI"; +"engine.compatible.displayName" = "Nom d'affichage"; +"engine.compatible.displayName.placeholder" = "ex: Mon serveur LLM"; +"engine.compatible.requireApiKey" = "Nécessite une clé API"; +"engine.compatible.add" = "Ajouter un moteur compatible"; +"engine.compatible.delete" = "Supprimer ce moteur"; +"engine.compatible.useAsEngine" = "Utiliser comme moteur de traduction"; +"engine.compatible.max.reached" = "Maximum de 5 moteurs compatibles atteint"; + +/* Configuration des invites */ +"prompt.compatible.title" = "Moteurs compatibles"; + + +/* ======================================== + Fenêtre À propos + ======================================== */ + +"about.title" = "À propos de ScreenTranslate"; +"about.app.name" = "ScreenTranslate"; +"about.version.format" = "Version %@ (%@)"; +"about.copyright" = "Copyright"; +"about.copyright.value" = "© 2026 Tous droits réservés"; +"about.license" = "Licence"; +"about.license.value" = "Licence MIT"; +"about.github.link" = "GitHub : hubo1989/ScreenTranslate"; +"about.check.for.updates" = "Rechercher des mises à jour"; +"about.update.checking" = "Vérification..."; +"about.update.available" = "Mise à jour disponible"; +"about.update.uptodate" = "Vous êtes à jour"; +"about.update.failed" = "Échec de la vérification"; +"about.acknowledgements" = "Remerciements"; +"about.acknowledgements.title" = "Remerciements"; +"about.acknowledgements.intro" = "Ce logiciel utilise les bibliothèques open source suivantes :"; +"about.acknowledgements.upstream" = "Basé sur"; +"about.acknowledgements.author.format" = "par %@"; +"about.close" = "Fermer"; +"settings.glmocr.mode" = "Mode"; +"settings.glmocr.mode.cloud" = "Cloud"; +"settings.glmocr.mode.local" = "Local"; +"settings.glmocr.local.apiKey.optional" = "La clé API est facultative pour les serveurs MLX-VLM locaux"; +"vlm.provider.glmocr.local.description" = "Serveur MLX-VLM local pour GLM-OCR"; diff --git a/ScreenTranslate/Resources/it.lproj/Localizable.strings b/ScreenTranslate/Resources/it.lproj/Localizable.strings new file mode 100644 index 0000000..f849795 --- /dev/null +++ b/ScreenTranslate/Resources/it.lproj/Localizable.strings @@ -0,0 +1,824 @@ +/* + Localizable.strings (Italiano) + ScreenTranslate +*/ + +/* ======================================== + Messaggi di errore + ======================================== */ + +/* Errori di permesso */ +"error.permission.denied" = "È richiesto il permesso di registrazione dello schermo per catturare screenshot."; +"error.permission.denied.recovery" = "Apri Impostazioni di Sistema per concedere il permesso."; + +/* Errori di visualizzazione */ +"error.display.not.found" = "Il display selezionato non è più disponibile."; +"error.display.not.found.recovery" = "Seleziona un display diverso."; +"error.display.disconnected" = "Il display '%@' è stato disconnesso durante la cattura."; +"error.display.disconnected.recovery" = "Ricollega il display e riprova."; + +/* Errori di cattura */ +"error.capture.failed" = "Impossibile catturare lo schermo."; +"error.capture.failed.recovery" = "Riprova."; + +/* Errori di salvataggio */ +"error.save.location.invalid" = "La posizione di salvataggio non è accessibile."; +"error.save.location.invalid.recovery" = "Scegli una diversa posizione di salvataggio nelle Impostazioni."; +"error.save.location.invalid.detail" = "Impossibile salvare in %@. La posizione non è accessibile."; +"error.save.unknown" = "Si è verificato un errore imprevisto durante il salvataggio."; +"error.disk.full" = "Spazio su disco insufficiente per salvare lo screenshot."; +"error.disk.full.recovery" = "Libera spazio su disco e riprova."; + +/* Errori di esportazione */ +"error.export.encoding.failed" = "Codifica dell'immagine fallita."; +"error.export.encoding.failed.recovery" = "Prova un formato diverso nelle Impostazioni."; +"error.export.encoding.failed.detail" = "Codifica dell'immagine come %@ fallita."; + +/* Errori degli appunti */ +"error.clipboard.write.failed" = "Impossibile copiare lo screenshot negli appunti."; +"error.clipboard.write.failed.recovery" = "Riprova."; + +/* Errori delle scorciatoie da tastiera */ +"error.hotkey.registration.failed" = "Impossibile registrare la scorciatoia da tastiera."; +"error.hotkey.registration.failed.recovery" = "La scorciatoia potrebbe essere in conflitto con un'altra app. Prova una scorciatoia diversa."; +"error.hotkey.conflict" = "Questa scorciatoia da tastiera è in conflitto con un'altra applicazione."; +"error.hotkey.conflict.recovery" = "Scegli una diversa scorciatoia da tastiera."; + +/* Errori OCR */ +"error.ocr.failed" = "Riconoscimento testo fallito."; +"error.ocr.failed.recovery" = "Riprova con un'immagine più chiara."; +"error.ocr.no.text" = "Nessun testo riconosciuto nell'immagine."; +"error.ocr.no.text.recovery" = "Prova a catturare un'area con testo visibile."; +"error.ocr.cancelled" = "Riconoscimento testo annullato."; +"error.ocr.server.unreachable" = "Impossibile connettersi al server OCR."; +"error.ocr.server.unreachable.recovery" = "Verifica l'indirizzo del server e la connessione di rete."; + +/* Errori di traduzione */ +"error.translation.in.progress" = "Una traduzione è già in corso"; +"error.translation.in.progress.recovery" = "Attendi il completamento della traduzione corrente"; +"error.translation.empty.input" = "Nessun testo da tradurre"; +"error.translation.empty.input.recovery" = "Seleziona prima del testo"; +"error.translation.timeout" = "Timeout della traduzione"; +"error.translation.timeout.recovery" = "Riprova"; +"error.translation.unsupported.pair" = "Traduzione da %@ a %@ non supportata"; +"error.translation.unsupported.pair.recovery" = "Seleziona lingue diverse"; +"error.translation.failed" = "Traduzione fallita"; +"error.translation.failed.recovery" = "Riprova"; +"error.translation.language.not.installed" = "Lingua di traduzione '%@' non installata"; +"error.translation.language.download.instructions" = "Vai su Impostazioni di Sistema > Generali > Lingua e Regione > Lingue di traduzione, quindi scarica la lingua richiesta."; + +/* UI errori generico */ +"error.title" = "Errore"; +"error.ok" = "OK"; +"error.dismiss" = "Chiudi"; +"error.retry.capture" = "Riprova"; +"error.permission.open.settings" = "Apri Impostazioni di Sistema"; + + +/* ======================================== + Voci di menu + ======================================== */ + +"menu.capture.full.screen" = "Cattura Schermo Intero"; +"menu.capture.fullscreen" = "Cattura Schermo Intero"; +"menu.capture.selection" = "Cattura Selezione"; +"menu.translation.mode" = "Modalità Traduzione"; +"menu.translation.history" = "Cronologia Traduzioni"; +"menu.settings" = "Impostazioni..."; +"menu.about" = "Riguardo ScreenTranslate"; +"menu.quit" = "Esci da ScreenTranslate"; + + +/* ======================================== + Selettore display + ======================================== */ + +"display.selector.title" = "Seleziona Display"; +"display.selector.header" = "Scegli il display da catturare:"; +"display.selector.cancel" = "Annulla"; + + +/* ======================================== + Finestra di anteprima + ======================================== */ + +"preview.window.title" = "Anteprima Screenshot"; +"preview.title" = "Anteprima Screenshot"; +"preview.dimensions" = "%d x %d pixel"; +"preview.file.size" = "~%@ %@"; +"preview.screenshot" = "Screenshot"; +"preview.enter.text" = "Inserisci testo"; +"preview.image.dimensions" = "Dimensioni immagine"; +"preview.estimated.size" = "Dimensione file stimata"; +"preview.edit.label" = "Modifica:"; +"preview.active.tool" = "Strumento attivo"; +"preview.crop.mode.active" = "Modalità ritaglio attiva"; + +/* Ritaglio */ +"preview.crop" = "Ritaglia"; +"preview.crop.cancel" = "Annulla"; +"preview.crop.apply" = "Applica Ritaglio"; + +/* Testo riconosciuto */ +"preview.recognized.text" = "Testo Riconosciuto:"; +"preview.translation" = "Traduzione:"; +"preview.results.panel" = "Risultati Testo"; +"preview.copy.text" = "Copia testo"; + +/* Tooltip della barra degli strumenti */ +"preview.tooltip.crop" = "Ritaglia (C)"; +"preview.tooltip.pin" = "Fissa allo schermo (P)"; +"preview.tooltip.undo" = "Annulla (⌘Z)"; +"preview.tooltip.redo" = "Ripeti (⌘⇧Z)"; +"preview.tooltip.copy" = "Copia negli appunti (⌘C)"; +"preview.tooltip.save" = "Salva (⌘S)"; +"preview.tooltip.ocr" = "Riconosci Testo (OCR)"; +"preview.tooltip.confirm" = "Copia negli appunti e chiudi (Invio)"; +"preview.tooltip.dismiss" = "Chiudi (Escape)"; +"preview.tooltip.delete" = "Elimina annotazione selezionata"; + +/* Etichette di accessibilità */ +"preview.accessibility.save" = "Salva screenshot"; +"preview.accessibility.saving" = "Salvataggio screenshot in corso"; +"preview.accessibility.confirm" = "Conferma e copia negli appunti"; +"preview.accessibility.copying" = "Copia negli appunti in corso"; +"preview.accessibility.hint.commandS" = "Comando S"; +"preview.accessibility.hint.enter" = "Tasto Invio"; + +/* Toggle forma */ +"preview.shape.filled" = "Pieno"; +"preview.shape.hollow" = "Vuoto"; +"preview.shape.toggle.hint" = "Clic per alternare tra pieno e vuoto"; + + +/* ======================================== + Strumenti di annotazione + ======================================== */ + +"tool.rectangle" = "Rettangolo"; +"tool.freehand" = "Mano libera"; +"tool.text" = "Testo"; +"tool.arrow" = "Freccia"; +"tool.ellipse" = "Ellisse"; +"tool.line" = "Linea"; +"tool.highlight" = "Evidenzia"; +"tool.mosaic" = "Mosaico"; +"tool.numberLabel" = "Etichetta numerica"; + + +/* ======================================== + Colori + ======================================== */ + +"color.red" = "Rosso"; +"color.orange" = "Arancione"; +"color.yellow" = "Giallo"; +"color.green" = "Verde"; +"color.blue" = "Blu"; +"color.purple" = "Viola"; +"color.pink" = "Rosa"; +"color.white" = "Bianco"; +"color.black" = "Nero"; +"color.custom" = "Personalizzato"; + + +/* ======================================== + Azioni + ======================================== */ + +"action.save" = "Salva"; +"action.copy" = "Copia"; +"action.cancel" = "Annulla"; +"action.undo" = "Annulla"; +"action.redo" = "Ripeti"; +"action.delete" = "Elimina"; +"action.clear" = "Cancella"; +"action.reset" = "Reimposta"; +"action.close" = "Chiudi"; +"action.done" = "Fatto"; + +/* Pulsanti */ +"button.ok" = "OK"; +"button.cancel" = "Annulla"; +"button.clear" = "Cancella"; +"button.reset" = "Reimposta"; +"button.save" = "Salva"; +"button.delete" = "Elimina"; +"button.confirm" = "Conferma"; + +/* Salvataggio riuscito */ +"save.success.title" = "Salvato con successo"; +"save.success.message" = "Salvato in %@"; +"save.with.translations.message" = "Scegli dove salvare l'immagine tradotta"; + +/* Errore nessuna traduzione */ +"error.no.translations" = "Nessuna traduzione disponibile. Traduci prima il testo."; + +/* Copia riuscita */ +"copy.success.message" = "Copiato negli appunti"; + + +/* ======================================== + Finestra Impostazioni + ======================================== */ + +"settings.window.title" = "Impostazioni ScreenTranslate"; +"settings.title" = "Impostazioni ScreenTranslate"; + +/* Sezioni/Schede delle impostazioni */ +"settings.section.permissions" = "Permessi"; +"settings.section.general" = "Generali"; +"settings.section.engines" = "Motori"; +"settings.section.prompts" = "Configurazione Prompt"; +"settings.section.languages" = "Lingue"; +"settings.section.export" = "Esporta"; +"settings.section.shortcuts" = "Scorciatoie da Tastiera"; +"settings.section.text.translation" = "Traduzione Testo"; +"settings.section.annotations" = "Annotazioni"; + +/* Impostazioni lingua */ +"settings.language" = "Lingua"; +"settings.language.system" = "Predefinita di Sistema"; +"settings.language.restart.hint" = "Alcune modifiche potrebbero richiedere il riavvio"; + +/* Permessi */ +"settings.permission.screen.recording" = "Registrazione Schermo"; +"settings.permission.screen.recording.hint" = "Richiesto per catturare screenshot"; +"settings.permission.accessibility" = "Accessibilità"; +"settings.permission.accessibility.hint" = "Richiesto per le scorciatoie globali"; +"settings.permission.granted" = "Concesso"; +"settings.permission.not.granted" = "Non Concesso"; +"settings.permission.grant" = "Concedi Accesso"; +"settings.permission.authorization.title" = "Autorizzazione Richiesta"; +"settings.permission.authorization.cancel" = "Annulla"; +"settings.permission.authorization.go" = "Vai ad Autorizzare"; +"settings.permission.authorization.screen.message" = "È richiesto il permesso di registrazione dello schermo. Clicca 'Vai ad Autorizzare' per aprire Impostazioni di Sistema e abilitare ScreenCapture per questa app."; +"settings.permission.authorization.accessibility.message" = "È richiesto il permesso di accessibilità. Clicca 'Vai ad Autorizzare' per aprire Impostazioni di Sistema e aggiungere questa app alla lista accessibilità."; + +/* Posizione di salvataggio */ +"settings.save.location" = "Posizione di Salvataggio"; +"settings.save.location.choose" = "Scegli..."; +"settings.save.location.select" = "Seleziona"; +"settings.save.location.message" = "Scegli la posizione predefinita per salvare gli screenshot"; +"settings.save.location.reveal" = "Mostra nel Finder"; + +/* Formato di esportazione */ +"settings.format" = "Formato Predefinito"; +"settings.format.png" = "PNG"; +"settings.format.jpeg" = "JPEG"; +"settings.format.heic" = "HEIC"; +"settings.jpeg.quality" = "Qualità JPEG"; +"settings.jpeg.quality.hint" = "Qualità più elevata = file più grandi"; +"settings.heic.quality" = "Qualità HEIC"; +"settings.heic.quality.hint" = "HEIC offre una migliore compressione"; + +/* Scorciatoie da tastiera */ +"settings.shortcuts" = "Scorciatoie da Tastiera"; +"settings.shortcut.fullscreen" = "Cattura Schermo Intero"; +"settings.shortcut.selection" = "Cattura Selezione"; +"settings.shortcut.translation.mode" = "Modalità Traduzione"; +"settings.shortcut.text.selection.translation" = "Traduzione Selezione Testo"; +"settings.shortcut.translate.and.insert" = "Traduci e Inserisci"; +"settings.shortcut.recording" = "Premi tasti..."; +"settings.shortcut.reset" = "Ripristina predefiniti"; +"settings.shortcut.error.no.modifier" = "Le scorciatoie devono includere Command, Control o Option"; +"settings.shortcut.error.conflict" = "Questa scorciatoia è già in uso"; + +/* Annotazioni */ +"settings.annotations" = "Impostazioni Predefinite Annotazioni"; +"settings.stroke.color" = "Colore Tratto"; +"settings.stroke.width" = "Larghezza Tratto"; +"settings.text.size" = "Dimensione Testo"; +"settings.mosaic.blockSize" = "Dimensione Blocco Mosaico"; + +/* Motori */ +"settings.ocr.engine" = "Motore OCR"; +"settings.translation.engine" = "Motore di Traduzione"; +"settings.translation.mode" = "Modalità Traduzione"; + +/* Ripristino */ +"settings.reset.all" = "Ripristina Tutto ai Predefiniti"; + +/* Errori */ +"settings.error.title" = "Errore"; +"settings.error.ok" = "OK"; + + +/* ======================================== + Motori OCR + ======================================== */ + +"ocr.engine.vision" = "Apple Vision"; +"ocr.engine.vision.description" = "Framework macOS Vision integrato, veloce e privato"; +"ocr.engine.paddleocr" = "PaddleOCR"; +"ocr.engine.paddleocr.description" = "Server OCR self-hosted per maggiore precisione"; + + +/* ======================================== + Motori di traduzione + ======================================== */ + +"translation.engine.apple" = "Traduzione Apple"; +"translation.engine.apple.description" = "Traduzione macOS integrata, nessuna configurazione richiesta"; +"translation.engine.mtran" = "MTranServer"; +"translation.engine.mtran.description" = "Server di traduzione self-hosted"; + +/* Nuovi motori di traduzione */ +"translation.engine.openai" = "OpenAI"; +"translation.engine.openai.description" = "Traduzione GPT-4 tramite API OpenAI"; +"translation.engine.claude" = "Claude"; +"translation.engine.claude.description" = "Traduzione Claude tramite API Anthropic"; +"translation.engine.gemini" = "Gemini"; +"translation.engine.gemini.description" = "Traduzione Gemini tramite API Google AI"; +"translation.engine.ollama" = "Ollama"; +"translation.engine.ollama.description" = "Traduzione LLM locale tramite Ollama"; +"translation.engine.google" = "Google Translate"; +"translation.engine.google.description" = "API Google Cloud Translation"; +"translation.engine.deepl" = "DeepL"; +"translation.engine.deepl.description" = "Traduzione di alta qualità tramite API DeepL"; +"translation.engine.baidu" = "Baidu Translate"; +"translation.engine.baidu.description" = "API Baidu Translation"; +"translation.engine.custom" = "Compatibile OpenAI"; +"translation.engine.custom.description" = "Endpoint personalizzato compatibile OpenAI"; + +/* Categorie motori */ +"engine.category.builtin" = "Integrato"; +"engine.category.llm" = "Traduzione LLM"; +"engine.category.cloud" = "Servizi Cloud"; +"engine.category.compatible" = "Compatibile"; + +/* Titolo configurazione motore */ +"engine.config.title" = "Configurazione Motore di Traduzione"; + +/* Modalità di selezione motore */ +"engine.selection.mode.title" = "Modalità Selezione Motore"; +"engine.selection.mode.primary_fallback" = "Primario/Fallback"; +"engine.selection.mode.primary_fallback.description" = "Usa il motore primario, fallback sul secondario in caso di errore"; +"engine.selection.mode.parallel" = "Parallelo"; +"engine.selection.mode.parallel.description" = "Esegui più motori simultaneamente e confronta i risultati"; +"engine.selection.mode.quick_switch" = "Cambio Rapido"; +"engine.selection.mode.quick_switch.description" = "Inizia con il primario, cambia rapidamente ad altri motori su richiesta"; +"engine.selection.mode.scene_binding" = "Associazione Scena"; +"engine.selection.mode.scene_binding.description" = "Usa motori diversi per diversi scenari di traduzione"; + +/* Etichette specifiche per modalità */ +"engine.config.primary" = "Primario"; +"engine.config.fallback" = "Fallback"; +"engine.config.switch.order" = "Ordine Cambio"; +"engine.config.parallel.select" = "Seleziona motori da eseguire in parallelo"; +"engine.config.replace" = "Sostituisci motore"; +"engine.config.remove" = "Rimuovi"; +"engine.config.add" = "Aggiungi Motore"; + +/* Scene di traduzione */ +"translation.scene.screenshot" = "Traduzione Screenshot"; +"translation.scene.screenshot.description" = "OCR e traduzione di aree screenshot catturate"; +"translation.scene.text_selection" = "Traduzione Selezione Testo"; +"translation.scene.text_selection.description" = "Traduci il testo selezionato da qualsiasi applicazione"; +"translation.scene.translate_and_insert" = "Traduci e Inserisci"; +"translation.scene.translate_and_insert.description" = "Traduci il testo negli appunti e inserisci alla posizione del cursore"; + +/* Configurazione motore */ +"engine.config.enabled" = "Abilita questo motore"; +"engine.config.apiKey" = "Chiave API"; +"engine.config.apiKey.placeholder" = "Inserisci la tua chiave API"; +"engine.config.getApiKey" = "Ottieni Chiave API"; +"engine.config.baseURL" = "URL Base"; +"engine.config.model" = "Nome Modello"; +"engine.config.test" = "Test Connessione"; +"engine.config.test.success" = "Connessione riuscita"; +"engine.config.test.failed" = "Connessione fallita"; +"engine.config.baidu.credentials" = "Credenziali Baidu"; +"engine.config.baidu.appID" = "App ID"; +"engine.config.baidu.secretKey" = "Chiave Segreta"; +"engine.config.mtran.url" = "URL Server"; + +/* Stato motore */ +"engine.status.configured" = "Configurato"; +"engine.status.unconfigured" = "Non configurato"; +"engine.available.title" = "Motori Disponibili"; +"engine.parallel.title" = "Motori Paralleli"; +"engine.parallel.description" = "Seleziona motori da eseguire simultaneamente in modalità parallela"; +"engine.scene.binding.title" = "Associazione Motore Scena"; +"engine.scene.binding.description" = "Configura quale motore usare per ogni scenario di traduzione"; +"engine.scene.fallback.tooltip" = "Abilita fallback ad altri motori"; + +/* Errori Keychain */ +"keychain.error.item_not_found" = "Credenziali non trovate nel portachiavi"; +"keychain.error.item_not_found.recovery" = "Configura le tue credenziali API nelle Impostazioni"; +"keychain.error.duplicate_item" = "Credenziali già presenti nel portachiavi"; +"keychain.error.duplicate_item.recovery" = "Prova a eliminare prima le credenziali esistenti"; +"keychain.error.invalid_data" = "Formato dati credenziali non valido"; +"keychain.error.invalid_data.recovery" = "Prova a reinserire le tue credenziali"; +"keychain.error.unexpected_status" = "Operazione portachiavi fallita"; +"keychain.error.unexpected_status.recovery" = "Verifica i tuoi permessi di accesso al portachiavi"; + +/* Errori multi-motore */ +"multiengine.error.all_failed" = "Tutti i motori di traduzione sono falliti"; +"multiengine.error.no_engines" = "Nessun motore di traduzione configurato"; +"multiengine.error.primary_unavailable" = "Il motore primario %@ non è disponibile"; +"multiengine.error.no_results" = "Nessun risultato di traduzione disponibile"; + +/* Errori di registro */ +"registry.error.already_registered" = "Provider già registrato"; +"registry.error.not_registered" = "Nessun provider registrato per %@"; +"registry.error.config_missing" = "Configurazione mancante per %@"; +"registry.error.credentials_not_found" = "Credenziali non trovate per %@"; + +/* Configurazione Prompt */ +"prompt.engine.title" = "Prompt Motore"; +"prompt.engine.description" = "Personalizza i prompt di traduzione per ogni motore LLM"; +"prompt.scene.title" = "Prompt Scena"; +"prompt.scene.description" = "Personalizza i prompt di traduzione per ogni scenario di traduzione"; +"prompt.default.title" = "Modello Prompt Predefinito"; +"prompt.default.description" = "Questo modello viene usato quando non è configurato nessun prompt personalizzato"; +"prompt.button.edit" = "Modifica"; +"prompt.button.reset" = "Reimposta"; +"prompt.editor.title" = "Modifica Prompt"; +"prompt.editor.variables" = "Variabili Disponibili:"; +"prompt.variable.source_language" = "Nome lingua sorgente"; +"prompt.variable.target_language" = "Nome lingua target"; +"prompt.variable.text" = "Testo da tradurre"; + + +/* ======================================== + Modalità di traduzione + ======================================== */ + +"translation.mode.inline" = "Sostituzione In-place"; +"translation.mode.inline.description" = "Sostituisci il testo originale con la traduzione"; +"translation.mode.below" = "Sotto Originale"; +"translation.mode.below.description" = "Mostra la traduzione sotto il testo originale"; + + +/* ======================================== + Impostazioni traduzione + ======================================== */ + +"translation.auto" = "Rilevamento Automatico"; +"translation.auto.detected" = "Rilevato Automaticamente"; +"translation.language.follow.system" = "Segui Sistema"; +"translation.language.source" = "Lingua Sorgente"; +"translation.language.target" = "Lingua Target"; +"translation.language.source.hint" = "La lingua del testo da tradurre"; +"translation.language.target.hint" = "La lingua in cui tradurre il testo"; + + +/* ======================================== + Vista cronologia + ======================================== */ + +"history.title" = "Cronologia Traduzioni"; +"history.search.placeholder" = "Cerca nella cronologia..."; +"history.clear.all" = "Cancella tutta la cronologia"; +"history.empty.title" = "Nessuna Cronologia di Traduzione"; +"history.empty.message" = "I tuoi screenshot tradotti appariranno qui"; +"history.no.results.title" = "Nessun Risultato"; +"history.no.results.message" = "Nessuna voce corrisponde alla tua ricerca"; +"history.clear.search" = "Cancella Ricerca"; + +"history.source" = "Sorgente"; +"history.translation" = "Traduzione"; +"history.truncated" = "troncato"; + +"history.copy.translation" = "Copia Traduzione"; +"history.copy.source" = "Copia Sorgente"; +"history.copy.both" = "Copia Entrambi"; +"history.delete" = "Elimina"; + +"history.clear.alert.title" = "Cancella Cronologia"; +"history.clear.alert.message" = "Sei sicuro di voler eliminare tutta la cronologia delle traduzioni? Questa azione non può essere annullata."; + + +/* ======================================== + Prompt permesso + ======================================== */ + +"permission.prompt.title" = "Permesso Registrazione Schermo Richiesto"; +"permission.prompt.message" = "ScreenTranslate necessita del permesso per catturare lo schermo. Questo è richiesto per scattare screenshot.\n\nDopo aver cliccato Continua, macOS chiederà di concedere il permesso di Registrazione Schermo. Puoi concederlo in Impostazioni di Sistema > Privacy e Sicurezza > Registrazione Schermo."; +"permission.prompt.continue" = "Continua"; +"permission.prompt.later" = "Più tardi"; + +/* Permesso Accessibilità */ +"permission.accessibility.title" = "Permesso Accessibilità Richiesto"; +"permission.accessibility.message" = "ScreenTranslate necessita del permesso di accessibilità per catturare il testo selezionato e inserire le traduzioni.\n\nQuesto permette all'app di:\n• Copiare il testo selezionato da qualsiasi applicazione\n• Inserire il testo tradotto nei campi di input\n\nLa tua privacy è protetta - ScreenTranslate usa questo solo per la traduzione del testo."; +"permission.accessibility.grant" = "Concedi Permesso"; +"permission.accessibility.open.settings" = "Apri Impostazioni di Sistema"; +"permission.accessibility.denied.title" = "Permesso Accessibilità Richiesto"; +"permission.accessibility.denied.message" = "La cattura e l'inserimento del testo richiedono il permesso di accessibilità.\n\nConcedi il permesso in Impostazioni di Sistema > Privacy e Sicurezza > Accessibilità."; + +/* Permesso Monitoraggio Input */ +"permission.input.monitoring.title" = "Permesso Monitoraggio Input Richiesto"; +"permission.input.monitoring.message" = "ScreenTranslate necessita del permesso di monitoraggio input per inserire il testo tradotto nelle applicazioni.\n\nDovrai abilitarlo in:\nImpostazioni di Sistema > Privacy e Sicurezza > Monitoraggio Input"; +"permission.input.monitoring.open.settings" = "Apri Impostazioni di Sistema"; +"permission.input.monitoring.denied.title" = "Permesso Monitoraggio Input Richiesto"; +"permission.input.monitoring.denied.message" = "L'inserimento del testo richiede il permesso di monitoraggio input.\n\nConcedi il permesso in Impostazioni di Sistema > Privacy e Sicurezza > Monitoraggio Input."; + +/* Stringhe permesso comuni */ +"permission.open.settings" = "Apri Impostazioni di Sistema"; + + +/* ======================================== + Onboarding + ======================================== */ + +"onboarding.window.title" = "Benvenuto in ScreenTranslate"; + +/* Onboarding - Passo benvenuto */ +"onboarding.welcome.title" = "Benvenuto in ScreenTranslate"; +"onboarding.welcome.message" = "Configuriamo le funzionalità di cattura schermo e traduzione. Richiederà solo un minuto."; + +"onboarding.feature.local.ocr.title" = "OCR Locale"; +"onboarding.feature.local.ocr.description" = "Framework macOS Vision per riconoscimento testo veloce e privato"; +"onboarding.feature.local.translation.title" = "Traduzione Locale"; +"onboarding.feature.local.translation.description" = "Traduzione Apple per traduzione istantanea offline"; +"onboarding.feature.shortcuts.title" = "Scorciatoie Globali"; +"onboarding.feature.shortcuts.description" = "Cattura e traduci da ovunque con le scorciatoie da tastiera"; + +/* Onboarding - Passo permessi */ +"onboarding.permissions.title" = "Permessi"; +"onboarding.permissions.message" = "ScreenTranslate necessita di alcuni permessi per funzionare correttamente. Concedi i seguenti permessi:"; +"onboarding.permissions.hint" = "Dopo aver concesso i permessi, lo stato si aggiornerà automaticamente."; + +"onboarding.permission.screen.recording" = "Registrazione Schermo"; +"onboarding.permission.accessibility" = "Accessibilità"; +"onboarding.permission.granted" = "Concesso"; +"onboarding.permission.not.granted" = "Non Concesso"; +"onboarding.permission.grant" = "Concedi Permesso"; + +/* Onboarding - Passo configurazione */ +"onboarding.configuration.title" = "Configurazione Opzionale"; +"onboarding.configuration.message" = "Le tue funzionalità OCR e traduzione locali sono già abilitate. Opzionalmente configura servizi esterni:"; +"onboarding.configuration.paddleocr" = "Indirizzo Server PaddleOCR"; +"onboarding.configuration.paddleocr.hint" = "Lascia vuoto per usare macOS Vision OCR"; +"onboarding.configuration.mtran" = "Indirizzo MTranServer"; +"onboarding.configuration.mtran.hint" = "Lascia vuoto per usare Traduzione Apple"; +"onboarding.configuration.placeholder" = "http://localhost:8080"; +"onboarding.configuration.placeholder.address" = "localhost"; +"onboarding.configuration.test" = "Test Traduzione"; +"onboarding.configuration.test.button" = "Test Traduzione"; +"onboarding.configuration.testing" = "Test in corso..."; +"onboarding.test.success" = "Test traduzione riuscito: \"%@\" → \"%@\""; +"onboarding.test.failed" = "Test traduzione fallito: %@"; + +/* Onboarding - Passo completamento */ +"onboarding.complete.title" = "Tutto Pronto!"; +"onboarding.complete.message" = "ScreenTranslate è pronto per l'uso. Per iniziare:"; +"onboarding.complete.shortcuts" = "Usa ⌘⇧F per catturare lo schermo intero"; +"onboarding.complete.selection" = "Usa ⌘⇧A per catturare una selezione e tradurre"; +"onboarding.complete.settings" = "Apri Impostazioni dalla barra dei menu per personalizzare le opzioni"; +"onboarding.complete.start" = "Inizia a usare ScreenTranslate"; + +/* Onboarding - Navigazione */ +"onboarding.back" = "Indietro"; +"onboarding.continue" = "Continua"; +"onboarding.next" = "Avanti"; +"onboarding.skip" = "Salta"; +"onboarding.complete" = "Completa"; + +/* Onboarding - PaddleOCR */ +"onboarding.paddleocr.title" = "PaddleOCR (Opzionale)"; +"onboarding.paddleocr.description" = "Motore OCR avanzato per maggiore precisione nel riconoscimento testo, specialmente per il cinese."; +"onboarding.paddleocr.installed" = "Installato"; +"onboarding.paddleocr.not.installed" = "Non Installato"; +"onboarding.paddleocr.install" = "Installa"; +"onboarding.paddleocr.installing" = "Installazione in corso..."; +"onboarding.paddleocr.install.hint" = "Richiede Python 3 e pip. Esegui: pip3 install paddleocr paddlepaddle"; +"onboarding.paddleocr.copy.command" = "Copia Comando"; +"onboarding.paddleocr.refresh" = "Aggiorna Stato"; +"onboarding.paddleocr.version" = "Versione: %@"; + +/* Impostazioni - PaddleOCR */ +"settings.paddleocr.installed" = "Installato"; +"settings.paddleocr.not.installed" = "Non Installato"; +"settings.paddleocr.install" = "Installa"; +"settings.paddleocr.installing" = "Installazione in corso..."; +"settings.paddleocr.install.hint" = "Richiede Python 3 e pip installati sul tuo sistema."; +"settings.paddleocr.copy.command" = "Copia Comando"; +"settings.paddleocr.refresh" = "Aggiorna Stato"; +"settings.paddleocr.ready" = "PaddleOCR è pronto"; +"settings.paddleocr.not.installed.message" = "PaddleOCR non è installato"; +"settings.paddleocr.description" = "PaddleOCR è un motore OCR locale. È gratuito, funziona offline e non richiede una chiave API."; +"settings.paddleocr.install.button" = "Installa PaddleOCR"; +"settings.paddleocr.copy.command.button" = "Copia Comando Installazione"; +"settings.paddleocr.mode" = "Modalità"; +"settings.paddleocr.mode.fast" = "Veloce"; +"settings.paddleocr.mode.precise" = "Preciso"; +"settings.paddleocr.mode.fast.description" = "~1s, OCR veloce con raggruppamento righe"; +"settings.paddleocr.mode.precise.description" = "~12s, modello VL-1.5 con maggiore precisione"; +"settings.paddleocr.useCloud" = "Usa API Cloud"; +"settings.paddleocr.cloudBaseURL" = "URL API Cloud"; +"settings.paddleocr.cloudAPIKey" = "Chiave API"; +"settings.paddleocr.cloudModelId" = "ID Modello"; +"settings.paddleocr.localVLModelDir" = "Directory Modello Locale (vllm)"; +"settings.paddleocr.localVLModelDir.hint" = "Percorso al modello PaddleOCR-VL locale (es. ~/.paddlex/official_models/PaddleOCR-VL-1.5)"; +"error.paddleocr.notInstalled" = "PaddleOCR non è installato. Installalo usando: pip3 install paddleocr paddlepaddle"; + + +/* ======================================== + Configurazione VLM + ======================================== */ + +"settings.vlm.title" = "Configurazione VLM"; +"settings.vlm.provider" = "Provider"; +"settings.vlm.apiKey" = "Chiave API"; +"settings.vlm.apiKey.optional" = "La chiave API è opzionale per provider locali"; +"settings.vlm.baseURL" = "URL Base"; +"settings.vlm.model" = "Nome Modello"; +"settings.vlm.test.button" = "Test Connessione"; +"settings.vlm.test.success" = "Connessione riuscita! Modello: %@"; +"settings.vlm.test.ollama.success" = "Server in esecuzione. Modello '%@' disponibile"; +"settings.vlm.test.ollama.available" = "Server in esecuzione. Disponibile: %@"; + +"vlm.provider.openai" = "OpenAI"; +"vlm.provider.claude" = "Claude"; +"vlm.provider.glmocr" = "GLM OCR"; +"vlm.provider.ollama" = "Ollama"; +"vlm.provider.paddleocr" = "PaddleOCR"; +"vlm.provider.openai.description" = "API OpenAI GPT-4 Vision"; +"vlm.provider.claude.description" = "API Anthropic Claude Vision"; +"vlm.provider.glmocr.description" = "API di analisi del layout Zhipu GLM-OCR"; +"vlm.provider.ollama.description" = "Server Ollama locale"; +"vlm.provider.paddleocr.description" = "Motore OCR locale (gratuito, offline)"; + + + +/* ======================================== + Configurazione flusso di traduzione + ======================================== */ + +"settings.translation.workflow.title" = "Motore di Traduzione"; +"settings.translation.preferred" = "Motore Preferito"; +"settings.translation.mtran.url" = "URL MTransServer"; +"settings.translation.mtran.test.button" = "Test Connessione"; +"settings.translation.mtran.test.success" = "Connessione riuscita"; +"settings.translation.mtran.test.failed" = "Connessione fallita: %@"; +"settings.translation.fallback" = "Fallback"; +"settings.translation.fallback.description" = "Usa Traduzione Apple come fallback quando il motore preferito fallisce"; + +"translation.preferred.apple.description" = "Traduzione macOS integrata, funziona offline"; +"translation.preferred.mtran.description" = "Server di traduzione self-hosted per qualità migliore"; + + +/* ======================================== + Etichette accessibilità + ======================================== */ + +"accessibility.close.button" = "Chiudi"; +"accessibility.settings.button" = "Impostazioni"; +"accessibility.capture.button" = "Cattura"; +"accessibility.translate.button" = "Traduci"; + + +/* ======================================== + Finestra risultato bilingue + ======================================== */ + +/* ======================================== + Flusso di traduzione + ======================================== */ + +"translationFlow.phase.idle" = "Pronto"; +"translationFlow.phase.analyzing" = "Analisi immagine in corso..."; +"translationFlow.phase.translating" = "Traduzione in corso..."; +"translationFlow.phase.rendering" = "Rendering in corso..."; +"translationFlow.phase.completed" = "Completato"; +"translationFlow.phase.failed" = "Fallito"; + +"translationFlow.error.title" = "Errore di Traduzione"; +"translationFlow.error.title.analysis" = "Riconoscimento Immagine Fallito"; +"translationFlow.error.title.translation" = "Traduzione Fallita"; +"translationFlow.error.title.rendering" = "Rendering Fallito"; +"translationFlow.error.unknown" = "Si è verificato un errore sconosciuto."; +"translationFlow.error.analysis" = "Analisi fallita: %@"; +"translationFlow.error.translation" = "Traduzione fallita: %@"; +"translationFlow.error.rendering" = "Rendering fallito: %@"; +"translationFlow.error.cancelled" = "Traduzione annullata."; +"translationFlow.error.noTextFound" = "Nessun testo trovato nell'area selezionata."; +"translationFlow.error.translation.engine" = "Motore di Traduzione"; + +"translationFlow.recovery.analysis" = "Riprova con un'immagine più chiara, o verifica le impostazioni del provider VLM."; +"translationFlow.recovery.translation" = "Verifica le impostazioni del motore di traduzione e la connessione di rete, quindi riprova."; +"translationFlow.recovery.rendering" = "Riprova."; +"translationFlow.recovery.noTextFound" = "Prova a selezionare un'area con testo visibile."; + +"common.ok" = "OK"; + + +/* ======================================== + Finestra risultato bilingue + ======================================== */ + +"bilingualResult.window.title" = "Traduzione Bilingue"; +"bilingualResult.loading" = "Traduzione in corso..."; +"bilingualResult.loading.analyzing" = "Analisi immagine in corso..."; +"bilingualResult.loading.translating" = "Traduzione testo in corso..."; +"bilingualResult.loading.rendering" = "Rendering risultato in corso..."; +"bilingualResult.copyImage" = "Copia Immagine"; +"bilingualResult.copyText" = "Copia Testo"; +"bilingualResult.save" = "Salva"; +"bilingualResult.zoomIn" = "Ingrandisci"; +"bilingualResult.zoomOut" = "Rimpicciolisci"; +"bilingualResult.resetZoom" = "Reimposta Zoom"; +"bilingualResult.copySuccess" = "Copiato negli appunti"; +"bilingualResult.copyTextSuccess" = "Testo traduzione copiato"; +"bilingualResult.saveSuccess" = "Salvato con successo"; +"bilingualResult.copyFailed" = "Copia immagine fallita"; +"bilingualResult.saveFailed" = "Salvataggio immagine fallito"; +"bilingualResult.noTextToCopy" = "Nessun testo di traduzione da copiare"; + + +/* ======================================== + Traduzione testo (US-003 to US-010) + ======================================== */ + +/* Flusso traduzione testo */ +"textTranslation.phase.idle" = "Pronto"; +"textTranslation.phase.translating" = "Traduzione in corso..."; +"textTranslation.phase.completed" = "Completato"; +"textTranslation.phase.failed" = "Fallito"; + +"textTranslation.error.emptyInput" = "Nessun testo da tradurre"; +"textTranslation.error.translationFailed" = "Traduzione fallita: %@"; +"textTranslation.error.cancelled" = "Traduzione annullata"; +"textTranslation.error.serviceUnavailable" = "Servizio di traduzione non disponibile"; +"textTranslation.error.insertFailed" = "Impossibile inserire il testo tradotto"; + +"textTranslation.recovery.emptyInput" = "Seleziona prima del testo"; +"textTranslation.recovery.translationFailed" = "Riprova"; +"textTranslation.recovery.serviceUnavailable" = "Verifica la connessione di rete e riprova"; + +"textTranslation.loading" = "Traduzione in corso..."; +"textTranslation.noSelection.title" = "Nessun Testo Selezionato"; +"textTranslation.noSelection.message" = "Seleziona del testo in qualsiasi applicazione e riprova."; + +/* Traduci e inserisci */ +"translateAndInsert.emptyClipboard.title" = "Appunti Vuoti"; +"translateAndInsert.emptyClipboard.message" = "Copia prima del testo negli appunti, poi usa questa scorciatoia."; +"translateAndInsert.success.title" = "Traduzione Inserita"; +"translateAndInsert.success.message" = "Il testo tradotto è stato inserito nel campo di input focalizzato."; + +/* Visualizzazione lingua */ +"language.auto" = "Rilevato Automaticamente"; + +/* UI traduzione testo comune */ +"common.copy" = "Copia"; +"common.copied" = "Copiato"; +"common.insert" = "Inserisci"; + +/* Finestra traduzione testo */ +"textTranslation.window.title" = "Traduzione Testo"; + +/* Impostazioni lingua Traduci e Inserisci */ +"settings.translateAndInsert.language.section" = "Lingue Traduci e Inserisci"; +"settings.translateAndInsert.language.source" = "Lingua Sorgente"; +"settings.translateAndInsert.language.target" = "Lingua Target"; + + +/* ======================================== + Multipli motori compatibili OpenAI + ======================================== */ + +/* Configurazione motore compatibile */ +"engine.compatible.new" = "Nuovo Motore Compatibile"; +"engine.compatible.description" = "Endpoint API compatibile OpenAI"; +"engine.compatible.displayName" = "Nome Visualizzato"; +"engine.compatible.displayName.placeholder" = "es. Il Mio Server LLM"; +"engine.compatible.requireApiKey" = "Richiede Chiave API"; +"engine.compatible.add" = "Aggiungi Motore Compatibile"; +"engine.compatible.delete" = "Elimina questo motore"; +"engine.compatible.useAsEngine" = "Usa come motore di traduzione"; +"engine.compatible.max.reached" = "Raggiunto il massimo di 5 motori compatibili"; + +/* Configurazione Prompt */ +"prompt.compatible.title" = "Motori Compatibili"; + + +/* ======================================== + Finestra Informazioni + ======================================== */ + +"about.title" = "Riguardo ScreenTranslate"; +"about.app.name" = "ScreenTranslate"; +"about.version.format" = "Versione %@ (%@)"; +"about.copyright" = "Copyright"; +"about.copyright.value" = "© 2026 Tutti i diritti riservati"; +"about.license" = "Licenza"; +"about.license.value" = "Licenza MIT"; +"about.github.link" = "GitHub: hubo1989/ScreenTranslate"; +"about.check.for.updates" = "Controlla Aggiornamenti"; +"about.update.checking" = "Controllo in corso..."; +"about.update.available" = "Aggiornamento disponibile"; +"about.update.uptodate" = "Sei aggiornato"; +"about.update.failed" = "Controllo fallito"; +"about.acknowledgements" = "Riconoscimenti"; +"about.acknowledgements.title" = "Riconoscimenti"; +"about.acknowledgements.intro" = "Questo software utilizza le seguenti librerie open source:"; +"about.acknowledgements.upstream" = "Basato su"; +"about.acknowledgements.author.format" = "di %@"; +"about.close" = "Chiudi"; +"settings.glmocr.mode" = "Modalità"; +"settings.glmocr.mode.cloud" = "Cloud"; +"settings.glmocr.mode.local" = "Locale"; +"settings.glmocr.local.apiKey.optional" = "La chiave API è opzionale per i server MLX-VLM locali"; +"vlm.provider.glmocr.local.description" = "Server MLX-VLM locale per GLM-OCR"; diff --git a/ScreenTranslate/Resources/ja.lproj/Localizable.strings b/ScreenTranslate/Resources/ja.lproj/Localizable.strings new file mode 100644 index 0000000..a18d79f --- /dev/null +++ b/ScreenTranslate/Resources/ja.lproj/Localizable.strings @@ -0,0 +1,823 @@ +/* + Localizable.strings (日本語) + ScreenTranslate +*/ + +/* ======================================== + エラーメッセージ + ======================================== */ + +/* 権限エラー */ +"error.permission.denied" = "スクリーンショットを撮影するにはスクリーン録画の権限が必要です。"; +"error.permission.denied.recovery" = "システム設定を開いて権限を許可してください。"; + +/* ディスプレイエラー */ +"error.display.not.found" = "選択したディスプレイは利用できません。"; +"error.display.not.found.recovery" = "別のディスプレイを選択してください。"; +"error.display.disconnected" = "キャプチャ中にディスプレイ '%@' の接続が切断されました。"; +"error.display.disconnected.recovery" = "ディスプレイを再接続してから再試行してください。"; + +/* キャプチャエラー */ +"error.capture.failed" = "スクリーンショットのキャプチャに失敗しました。"; +"error.capture.failed.recovery" = "もう一度お試しください。"; + +/* 保存エラー */ +"error.save.location.invalid" = "保存場所にアクセスできません。"; +"error.save.location.invalid.recovery" = "設定で別の保存場所を選択してください。"; +"error.save.location.invalid.detail" = "%@ に保存できません。この場所にはアクセスできません。"; +"error.save.unknown" = "保存中に不明なエラーが発生しました。"; +"error.disk.full" = "ディスクの空き容量が不足しているため、スクリーンショットを保存できません。"; +"error.disk.full.recovery" = "ディスクの容量を確保してから再試行してください。"; + +/* エクスポートエラー */ +"error.export.encoding.failed" = "画像のエンコードに失敗しました。"; +"error.export.encoding.failed.recovery" = "設定で別のフォーマットをお試しください。"; +"error.export.encoding.failed.detail" = "画像を %@ フォーマットでエンコードできませんでした。"; + +/* クリップボードエラー */ +"error.clipboard.write.failed" = "スクリーンショットをクリップボードにコピーできませんでした。"; +"error.clipboard.write.failed.recovery" = "もう一度お試しください。"; + +/* ホットキーエラー */ +"error.hotkey.registration.failed" = "キーボードショートカットの登録に失敗しました。"; +"error.hotkey.registration.failed.recovery" = "このショートカットは他のアプリと競合している可能性があります。別のショートカットをお試しください。"; +"error.hotkey.conflict" = "このキーボードショートカットは他のアプリケーションと競合しています。"; +"error.hotkey.conflict.recovery" = "別のキーボードショートカットを選択してください。"; + +/* OCR エラー */ +"error.ocr.failed" = "文字認識に失敗しました。"; +"error.ocr.failed.recovery" = "より鮮明な画像でもう一度お試しください。"; +"error.ocr.no.text" = "画像内でテキストが認識されませんでした。"; +"error.ocr.no.text.recovery" = "テキストが表示されている範囲をキャプチャしてお試しください。"; +"error.ocr.cancelled" = "文字認識がキャンセルされました。"; +"error.ocr.server.unreachable" = "OCR サーバーに接続できません。"; +"error.ocr.server.unreachable.recovery" = "サーバーアドレスとネットワーク接続を確認してください。"; + +/* 翻訳エラー */ +"error.translation.in.progress" = "翻訳が進行中です"; +"error.translation.in.progress.recovery" = "現在の翻訳が完了するまでお待ちください"; +"error.translation.empty.input" = "翻訳するテキストがありません"; +"error.translation.empty.input.recovery" = "最初にテキストを選択してください"; +"error.translation.timeout" = "翻訳がタイムアウトしました"; +"error.translation.timeout.recovery" = "もう一度お試しください"; +"error.translation.unsupported.pair" = "%@ から %@ への翻訳はサポートされていません"; +"error.translation.unsupported.pair.recovery" = "別の言語を選択してください"; +"error.translation.failed" = "翻訳に失敗しました"; +"error.translation.failed.recovery" = "もう一度お試しください"; +"error.translation.language.not.installed" = "翻訳言語 '%@' がインストールされていません"; +"error.translation.language.download.instructions" = "システム設定 > 一般 > 言語と地域 > 翻訳言語 から、必要な言語をダウンロードしてください。"; + +/* 一般的なエラー UI */ +"error.title" = "エラー"; +"error.ok" = "OK"; +"error.dismiss" = "閉じる"; +"error.retry.capture" = "再試行"; +"error.permission.open.settings" = "システム設定を開く"; + + +/* ======================================== + メニュー項目 + ======================================== */ + +"menu.capture.full.screen" = "フルスクリーンキャプチャ"; +"menu.capture.fullscreen" = "フルスクリーンキャプチャ"; +"menu.capture.selection" = "範囲キャプチャ"; +"menu.translation.mode" = "翻訳モード"; +"menu.translation.history" = "翻訳履歴"; +"menu.settings" = "設定..."; +"menu.about" = "ScreenTranslate について"; +"menu.quit" = "ScreenTranslate を終了"; + + +/* ======================================== + ディスプレイ選択 + ======================================== */ + +"display.selector.title" = "ディスプレイを選択"; +"display.selector.header" = "キャプチャするディスプレイを選択:"; +"display.selector.cancel" = "キャンセル"; + + +/* ======================================== + プレビューウィンドウ + ======================================== */ + +"preview.window.title" = "スクリーンショットプレビュー"; +"preview.title" = "スクリーンショットプレビュー"; +"preview.dimensions" = "%d × %d ピクセル"; +"preview.file.size" = "約 %@ %@"; +"preview.screenshot" = "スクリーンショット"; +"preview.enter.text" = "テキストを入力"; +"preview.image.dimensions" = "画像サイズ"; +"preview.estimated.size" = "推定ファイルサイズ"; +"preview.edit.label" = "編集:"; +"preview.active.tool" = "現在のツール"; +"preview.crop.mode.active" = "クロップモードが有効"; + +/* クロップ */ +"preview.crop" = "クロップ"; +"preview.crop.cancel" = "キャンセル"; +"preview.crop.apply" = "クロップを適用"; + +/* テキスト認識 */ +"preview.recognized.text" = "認識されたテキスト:"; +"preview.translation" = "翻訳結果:"; +"preview.results.panel" = "テキスト結果"; +"preview.copy.text" = "テキストをコピー"; + +/* ツールバーヒント */ +"preview.tooltip.crop" = "クロップ (C)"; +"preview.tooltip.pin" = "ピン留め (P)"; +"preview.tooltip.undo" = "取り消し (⌘Z)"; +"preview.tooltip.redo" = "やり直し (⌘⇧Z)"; +"preview.tooltip.copy" = "クリップボードにコピー (⌘C)"; +"preview.tooltip.save" = "保存 (⌘S)"; +"preview.tooltip.ocr" = "文字認識 (OCR)"; +"preview.tooltip.confirm" = "クリップボードにコピーして閉じる (Enter)"; +"preview.tooltip.dismiss" = "閉じる (Escape)"; +"preview.tooltip.delete" = "選択した注釈を削除"; + +/* アクセシビリティラベル */ +"preview.accessibility.save" = "スクリーンショットを保存"; +"preview.accessibility.saving" = "スクリーンショットを保存中"; +"preview.accessibility.confirm" = "確定してクリップボードにコピー"; +"preview.accessibility.copying" = "クリップボードにコピー中"; +"preview.accessibility.hint.commandS" = "Command S"; +"preview.accessibility.hint.enter" = "Enter キー"; + +/* 図形の切り替え */ +"preview.shape.filled" = "塗りつぶし"; +"preview.shape.hollow" = "枠線"; +"preview.shape.toggle.hint" = "クリックして塗りつぶし/枠線を切り替え"; + + +/* ======================================== + 注釈ツール + ======================================== */ + +"tool.rectangle" = "四角形"; +"tool.freehand" = "フリーハンド"; +"tool.text" = "テキスト"; +"tool.arrow" = "矢印"; +"tool.ellipse" = "楕円"; +"tool.line" = "直線"; +"tool.highlight" = "蛍光ペン"; +"tool.mosaic" = "モザイク"; +"tool.numberLabel" = "数字ラベル"; + + +/* ======================================== + 色 + ======================================== */ + +"color.red" = "赤"; +"color.orange" = "オレンジ"; +"color.yellow" = "黄色"; +"color.green" = "緑"; +"color.blue" = "青"; +"color.purple" = "紫"; +"color.pink" = "ピンク"; +"color.white" = "白"; +"color.black" = "黒"; +"color.custom" = "カスタム"; + + +/* ======================================== + アクション + ======================================== */ + +"action.save" = "保存"; +"action.copy" = "コピー"; +"action.cancel" = "キャンセル"; +"action.undo" = "取り消し"; +"action.redo" = "やり直し"; +"action.delete" = "削除"; +"action.clear" = "クリア"; +"action.reset" = "リセット"; +"action.close" = "閉じる"; +"action.done" = "完了"; + +/* ボタン */ +"button.ok" = "OK"; +"button.cancel" = "キャンセル"; +"button.clear" = "クリア"; +"button.reset" = "リセット"; +"button.save" = "保存"; +"button.delete" = "削除"; +"button.confirm" = "確認"; + +/* 保存成功 */ +"save.success.title" = "保存しました"; +"save.success.message" = "%@ に保存しました"; +"save.with.translations.message" = "翻訳付き画像を保存する場所を選択してください"; + +/* 翻訳なしエラー */ +"error.no.translations" = "利用可能な翻訳がありません。最初にテキストを翻訳してください。"; + +/* コピー成功 */ +"copy.success.message" = "クリップボードにコピーしました"; + + +/* ======================================== + 設定ウィンドウ + ======================================== */ + +"settings.window.title" = "ScreenTranslate 設定"; +"settings.title" = "ScreenTranslate 設定"; + +/* 設定タブ/セクション */ +"settings.section.permissions" = "権限"; +"settings.section.general" = "一般"; +"settings.section.engines" = "エンジン"; +"settings.section.prompts" = "プロンプト設定"; +"settings.section.languages" = "言語"; +"settings.section.export" = "エクスポート"; +"settings.section.shortcuts" = "キーボードショートカット"; +"settings.section.text.translation" = "テキスト翻訳"; +"settings.section.annotations" = "注釈"; + +/* 言語設定 */ +"settings.language" = "言語"; +"settings.language.system" = "システムに従う"; +"settings.language.restart.hint" = "一部の変更はアプリの再起動が必要です"; + +/* 権限 */ +"settings.permission.screen.recording" = "スクリーン録画"; +"settings.permission.screen.recording.hint" = "スクリーンショットキャプチャに必要"; +"settings.permission.accessibility" = "アクセシビリティ"; +"settings.permission.accessibility.hint" = "グローバルショートカットに必要"; +"settings.permission.granted" = "許可済み"; +"settings.permission.not.granted" = "未許可"; +"settings.permission.grant" = "アクセスを許可"; +"settings.permission.authorization.title" = "権限の許可が必要です"; +"settings.permission.authorization.cancel" = "キャンセル"; +"settings.permission.authorization.go" = "許可する"; +"settings.permission.authorization.screen.message" = "スクリーン録画の権限が必要です。「許可する」をクリックしてシステム設定を開き、このアプリのスクリーン録画を有効にしてください。"; +"settings.permission.authorization.accessibility.message" = "アクセシビリティの権限が必要です。「許可する」をクリックしてシステム設定を開き、このアプリをアクセシビリティリストに追加してください。"; + +/* 保存場所 */ +"settings.save.location" = "保存場所"; +"settings.save.location.choose" = "選択..."; +"settings.save.location.select" = "選択"; +"settings.save.location.message" = "スクリーンショットのデフォルト保存場所を選択してください"; +"settings.save.location.reveal" = "Finder で表示"; + +/* エクスポートフォーマット */ +"settings.format" = "デフォルトフォーマット"; +"settings.format.png" = "PNG"; +"settings.format.jpeg" = "JPEG"; +"settings.format.heic" = "HEIC"; +"settings.jpeg.quality" = "JPEG 品質"; +"settings.jpeg.quality.hint" = "品質が高いほどファイルサイズが大きくなります"; +"settings.heic.quality" = "HEIC 品質"; +"settings.heic.quality.hint" = "HEIC はより高い圧縮率を提供します"; + +/* キーボードショートカット */ +"settings.shortcuts" = "キーボードショートカット"; +"settings.shortcut.fullscreen" = "フルスクリーンキャプチャ"; +"settings.shortcut.selection" = "範囲キャプチャ"; +"settings.shortcut.translation.mode" = "翻訳モード"; +"settings.shortcut.text.selection.translation" = "テキスト選択翻訳"; +"settings.shortcut.translate.and.insert" = "翻訳して挿入"; +"settings.shortcut.recording" = "キーを押してください..."; +"settings.shortcut.reset" = "デフォルトに戻す"; +"settings.shortcut.error.no.modifier" = "ショートカットには Command、Control、または Option を含める必要があります"; +"settings.shortcut.error.conflict" = "このショートカットは既に使用されています"; + +/* 注釈 */ +"settings.annotations" = "注釈のデフォルト設定"; +"settings.stroke.color" = "線の色"; +"settings.stroke.width" = "線の幅"; +"settings.text.size" = "文字サイズ"; +"settings.mosaic.blockSize" = "モザイクブロックサイズ"; + +/* エンジン */ +"settings.ocr.engine" = "OCR エンジン"; +"settings.translation.engine" = "翻訳エンジン"; +"settings.translation.mode" = "翻訳モード"; + +/* リセット */ +"settings.reset.all" = "すべてデフォルトに戻す"; + +/* エラー */ +"settings.error.title" = "エラー"; +"settings.error.ok" = "OK"; + + +/* ======================================== + OCR エンジン + ======================================== */ + +"ocr.engine.vision" = "Apple Vision"; +"ocr.engine.vision.description" = "組み込みの macOS Vision フレームワーク、高速でプライベート"; +"ocr.engine.paddleocr" = "PaddleOCR"; +"ocr.engine.paddleocr.description" = "セルフホスト OCR サーバー、より高い認識精度"; + + +/* ======================================== + 翻訳エンジン + ======================================== */ + +"translation.engine.apple" = "Apple 翻訳"; +"translation.engine.apple.description" = "組み込みの macOS 翻訳、設定不要"; +"translation.engine.mtran" = "MTranServer"; +"translation.engine.mtran.description" = "セルフホスト翻訳サーバー"; + +/* 新しい翻訳エンジン */ +"translation.engine.openai" = "OpenAI"; +"translation.engine.openai.description" = "OpenAI API による GPT-4 翻訳"; +"translation.engine.claude" = "Claude"; +"translation.engine.claude.description" = "Anthropic API による Claude 翻訳"; +"translation.engine.gemini" = "Gemini"; +"translation.engine.gemini.description" = "Google AI API による Gemini 翻訳"; +"translation.engine.ollama" = "Ollama"; +"translation.engine.ollama.description" = "Ollama によるローカル LLM 翻訳"; +"translation.engine.google" = "Google 翻訳"; +"translation.engine.google.description" = "Google Cloud Translation API"; +"translation.engine.deepl" = "DeepL"; +"translation.engine.deepl.description" = "DeepL API による高品質翻訳"; +"translation.engine.baidu" = "百度翻訳"; +"translation.engine.baidu.description" = "百度翻訳 API"; +"translation.engine.custom" = "OpenAI 互換"; +"translation.engine.custom.description" = "カスタム OpenAI 互換エンドポイント"; + +/* エンジンカテゴリ */ +"engine.category.builtin" = "組み込み"; +"engine.category.llm" = "LLM 翻訳"; +"engine.category.cloud" = "クラウドサービス"; +"engine.category.compatible" = "互換インターフェース"; + +/* エンジン設定タイトル */ +"engine.config.title" = "翻訳エンジン設定"; + +/* エンジン選択モード */ +"engine.selection.mode.title" = "エンジン選択モード"; +"engine.selection.mode.primary_fallback" = "プライマリ/フォールバック"; +"engine.selection.mode.primary_fallback.description" = "プライマリエンジンを使用し、失敗時はセカンダリに切り替え"; +"engine.selection.mode.parallel" = "並列"; +"engine.selection.mode.parallel.description" = "複数のエンジンを同時に実行して結果を比較"; +"engine.selection.mode.quick_switch" = "クイックスイッチ"; +"engine.selection.mode.quick_switch.description" = "プライマリから開始し、必要に応じて他のエンジンに素早く切り替え"; +"engine.selection.mode.scene_binding" = "シーン連動"; +"engine.selection.mode.scene_binding.description" = "翻訳シーンごとに異なるエンジンを使用"; + +/* モード固有ラベル */ +"engine.config.primary" = "プライマリ"; +"engine.config.fallback" = "フォールバック"; +"engine.config.switch.order" = "切り替え順序"; +"engine.config.parallel.select" = "並列実行するエンジンを選択"; +"engine.config.replace" = "エンジンを置換"; +"engine.config.remove" = "削除"; +"engine.config.add" = "エンジンを追加"; + +/* 翻訳シーン */ +"translation.scene.screenshot" = "スクリーンショット翻訳"; +"translation.scene.screenshot.description" = "OCR でキャプチャ範囲を認識して翻訳"; +"translation.scene.text_selection" = "テキスト選択翻訳"; +"translation.scene.text_selection.description" = "任意のアプリで選択したテキストを翻訳"; +"translation.scene.translate_and_insert" = "翻訳して挿入"; +"translation.scene.translate_and_insert.description" = "クリップボードのテキストを翻訳してカーソル位置に挿入"; + +/* エンジン設定 */ +"engine.config.enabled" = "このエンジンを有効にする"; +"engine.config.apiKey" = "API キー"; +"engine.config.apiKey.placeholder" = "API キーを入力してください"; +"engine.config.getApiKey" = "API キーを取得"; +"engine.config.baseURL" = "Base URL"; +"engine.config.model" = "モデル名"; +"engine.config.test" = "接続をテスト"; +"engine.config.test.success" = "接続成功"; +"engine.config.test.failed" = "接続失敗"; +"engine.config.baidu.credentials" = "百度翻訳の認証情報"; +"engine.config.baidu.appID" = "アプリ ID"; +"engine.config.baidu.secretKey" = "シークレットキー"; +"engine.config.mtran.url" = "サーバー URL"; + +/* エンジンステータス */ +"engine.status.configured" = "設定済み"; +"engine.status.unconfigured" = "未設定"; +"engine.available.title" = "利用可能なエンジン"; +"engine.parallel.title" = "並列エンジン"; +"engine.parallel.description" = "並列モードで同時に実行するエンジンを選択してください"; +"engine.scene.binding.title" = "シーンエンジン連動"; +"engine.scene.binding.description" = "各翻訳シーンで使用するエンジンを設定してください"; +"engine.scene.fallback.tooltip" = "他のエンジンへのフォールバックを有効にする"; + +/* Keychain エラー */ +"keychain.error.item_not_found" = "キーチェーンに認証情報が見つかりません"; +"keychain.error.item_not_found.recovery" = "設定で API 認証情報を設定してください"; +"keychain.error.duplicate_item" = "キーチェーンに認証情報が既に存在します"; +"keychain.error.duplicate_item.recovery" = "最初に既存の認証情報を削除してください"; +"keychain.error.invalid_data" = "無効な認証情報データ形式"; +"keychain.error.invalid_data.recovery" = "認証情報を再入力してください"; +"keychain.error.unexpected_status" = "キーチェーン操作が失敗しました"; +"keychain.error.unexpected_status.recovery" = "キーチェーンのアクセス権限を確認してください"; + +/* マルチエンジンエラー */ +"multiengine.error.all_failed" = "すべての翻訳エンジンが失敗しました"; +"multiengine.error.no_engines" = "翻訳エンジンが設定されていません"; +"multiengine.error.primary_unavailable" = "プライマリエンジン %@ は利用できません"; +"multiengine.error.no_results" = "翻訳結果がありません"; + +/* レジストリエラー */ +"registry.error.already_registered" = "プロバイダーは既に登録されています"; +"registry.error.not_registered" = "%@ のプロバイダーが登録されていません"; +"registry.error.config_missing" = "%@ の設定がありません"; +"registry.error.credentials_not_found" = "%@ の認証情報が見つかりません"; + +/* プロンプト設定 */ +"prompt.engine.title" = "エンジンプロンプト"; +"prompt.engine.description" = "各 LLM エンジンの翻訳プロンプトをカスタマイズ"; +"prompt.scene.title" = "シーンプロンプト"; +"prompt.scene.description" = "各翻訳シーンの翻訳プロンプトをカスタマイズ"; +"prompt.default.title" = "デフォルトプロンプトテンプレート"; +"prompt.default.description" = "カスタムプロンプトが設定されていない場合に使用されます"; +"prompt.button.edit" = "編集"; +"prompt.button.reset" = "リセット"; +"prompt.editor.title" = "プロンプトを編集"; +"prompt.editor.variables" = "利用可能な変数:"; +"prompt.variable.source_language" = "元の言語名"; +"prompt.variable.target_language" = "ターゲット言語名"; +"prompt.variable.text" = "翻訳するテキスト"; + + +/* ======================================== + 翻訳モード + ======================================== */ + +"translation.mode.inline" = "インプレース置換"; +"translation.mode.inline.description" = "元のテキストを翻訳で置換"; +"translation.mode.below" = "元のテキストの下に表示"; +"translation.mode.below.description" = "元のテキストの下に翻訳を表示"; + + +/* ======================================== + 翻訳設定 + ======================================== */ + +"translation.auto" = "自動検出"; +"translation.auto.detected" = "自動検出"; +"translation.language.follow.system" = "システムに従う"; +"translation.language.source" = "元の言語"; +"translation.language.target" = "ターゲット言語"; +"translation.language.source.hint" = "翻訳するテキストの言語"; +"translation.language.target.hint" = "テキストを翻訳する言語"; + + +/* ======================================== + 履歴ビュー + ======================================== */ + +"history.title" = "翻訳履歴"; +"history.search.placeholder" = "履歴を検索..."; +"history.clear.all" = "すべての履歴をクリア"; +"history.empty.title" = "翻訳履歴がありません"; +"history.empty.message" = "翻訳したスクリーンショットがここに表示されます"; +"history.no.results.title" = "結果がありません"; +"history.no.results.message" = "検索に一致する項目がありません"; +"history.clear.search" = "検索をクリア"; + +"history.source" = "元のテキスト"; +"history.translation" = "翻訳"; +"history.truncated" = "省略されました"; + +"history.copy.translation" = "翻訳をコピー"; +"history.copy.source" = "元のテキストをコピー"; +"history.copy.both" = "両方をコピー"; +"history.delete" = "削除"; + +"history.clear.alert.title" = "履歴をクリア"; +"history.clear.alert.message" = "すべての翻訳履歴を削除してもよろしいですか?この操作は元に戻せません。"; + + +/* ======================================== + 権限プロンプト + ======================================== */ + +"permission.prompt.title" = "スクリーン録画の権限が必要です"; +"permission.prompt.message" = "ScreenTranslate はスクリーンショットを撮影するために権限が必要です。これはスクリーンショット機能に必須です。\n\n続行をクリックすると、macOS がスクリーン録画の権限を求めます。システム設定 > プライバシーとセキュリティ > スクリーン録画で権限を許可できます。"; +"permission.prompt.continue" = "続行"; +"permission.prompt.later" = "後で"; + +/* アクセシビリティ権限 */ +"permission.accessibility.title" = "アクセシビリティ権限が必要です"; +"permission.accessibility.message" = "ScreenTranslate は選択したテキストをキャプチャし、翻訳を挿入するためにアクセシビリティ権限が必要です。\n\nこの権限により、アプリは以下のことができます:\n• 任意のアプリから選択したテキストをコピー\n• 入力フィールドに翻訳テキストを挿入\n\nプライバシーは保護されています - ScreenTranslate はテキスト翻訳のみに使用します。"; +"permission.accessibility.grant" = "権限を許可"; +"permission.accessibility.open.settings" = "システム設定を開く"; +"permission.accessibility.denied.title" = "アクセシビリティ権限が必要です"; +"permission.accessibility.denied.message" = "テキストのキャプチャと挿入にはアクセシビリティ権限が必要です。\n\nシステム設定 > プライバシーとセキュリティ > アクセシビリティで権限を許可してください。"; + +/* 入力モニタリング権限 */ +"permission.input.monitoring.title" = "入力モニタリング権限が必要です"; +"permission.input.monitoring.message" = "ScreenTranslate は翻訳テキストをアプリケーションに挿入するために入力モニタリング権限が必要です。\n\n以下の場所で有効にする必要があります:\nシステム設定 > プライバシーとセキュリティ > 入力モニタリング"; +"permission.input.monitoring.open.settings" = "システム設定を開く"; +"permission.input.monitoring.denied.title" = "入力モニタリング権限が必要です"; +"permission.input.monitoring.denied.message" = "テキスト挿入には入力モニタリング権限が必要です。\n\nシステム設定 > プライバシーとセキュリティ > 入力モニタリングで権限を許可してください。"; + +/* 一般的な権限文字列 */ +"permission.open.settings" = "システム設定を開く"; + + +/* ======================================== + オンボーディング + ======================================== */ + +"onboarding.window.title" = "ScreenTranslate へようこそ"; + +/* オンボーディング - ウェルカムステップ */ +"onboarding.welcome.title" = "ScreenTranslate へようこそ"; +"onboarding.welcome.message" = "スクリーンショットと翻訳機能をセットアップしましょう。1 分ほどで完了します。"; + +"onboarding.feature.local.ocr.title" = "ローカル OCR"; +"onboarding.feature.local.ocr.description" = "macOS Vision フレームワークによる高速でプライベートな文字認識"; +"onboarding.feature.local.translation.title" = "ローカル翻訳"; +"onboarding.feature.local.translation.description" = "Apple 翻訳による瞬時のオフライン翻訳"; +"onboarding.feature.shortcuts.title" = "グローバルショートカット"; +"onboarding.feature.shortcuts.description" = "キーボードショートカットでどこからでもキャプチャと翻訳"; + +/* オンボーディング - 権限ステップ */ +"onboarding.permissions.title" = "権限"; +"onboarding.permissions.message" = "ScreenTranslate を正常に動作させるにはいくつかの権限が必要です。以下の権限を許可してください:"; +"onboarding.permissions.hint" = "権限を許可すると、ステータスが自動的に更新されます。"; + +"onboarding.permission.screen.recording" = "スクリーン録画"; +"onboarding.permission.accessibility" = "アクセシビリティ"; +"onboarding.permission.granted" = "許可済み"; +"onboarding.permission.not.granted" = "未許可"; +"onboarding.permission.grant" = "権限を許可"; + +/* オンボーディング - 設定ステップ */ +"onboarding.configuration.title" = "オプション設定"; +"onboarding.configuration.message" = "ローカル OCR と翻訳機能は既に有効です。外部サービスを設定することもできます:"; +"onboarding.configuration.paddleocr" = "PaddleOCR サーバーアドレス"; +"onboarding.configuration.paddleocr.hint" = "空欄の場合は macOS Vision OCR を使用"; +"onboarding.configuration.mtran" = "MTranServer アドレス"; +"onboarding.configuration.mtran.hint" = "空欄の場合は Apple 翻訳を使用"; +"onboarding.configuration.placeholder" = "http://localhost:8080"; +"onboarding.configuration.placeholder.address" = "localhost"; +"onboarding.configuration.test" = "翻訳をテスト"; +"onboarding.configuration.test.button" = "翻訳をテスト"; +"onboarding.configuration.testing" = "テスト中..."; +"onboarding.test.success" = "翻訳テスト成功:\"%@\" → \"%@\""; +"onboarding.test.failed" = "翻訳テスト失敗:%@"; + +/* オンボーディング - 完了ステップ */ +"onboarding.complete.title" = "セットアップ完了!"; +"onboarding.complete.message" = "ScreenTranslate の準備ができました。使い方:"; +"onboarding.complete.shortcuts" = "⌘⇧F でフルスクリーンキャプチャ"; +"onboarding.complete.selection" = "⌘⇧A で範囲キャプチャと翻訳"; +"onboarding.complete.settings" = "メニューバーから設定を開いてオプションをカスタマイズ"; +"onboarding.complete.start" = "ScreenTranslate の使用を開始"; + +/* オンボーディング - ナビゲーション */ +"onboarding.back" = "戻る"; +"onboarding.continue" = "続行"; +"onboarding.next" = "次へ"; +"onboarding.skip" = "スキップ"; +"onboarding.complete" = "完了"; + +/* オンボーディング - PaddleOCR */ +"onboarding.paddleocr.title" = "PaddleOCR(オプション)"; +"onboarding.paddleocr.description" = "強化された OCR エンジン、より高い文字認識精度、特に中国語に最適。"; +"onboarding.paddleocr.installed" = "インストール済み"; +"onboarding.paddleocr.not.installed" = "未インストール"; +"onboarding.paddleocr.install" = "インストール"; +"onboarding.paddleocr.installing" = "インストール中..."; +"onboarding.paddleocr.install.hint" = "Python 3 と pip が必要です。コマンド実行:pip3 install paddleocr paddlepaddle"; +"onboarding.paddleocr.copy.command" = "コマンドをコピー"; +"onboarding.paddleocr.refresh" = "ステータスを更新"; +"onboarding.paddleocr.version" = "バージョン:%@"; + +/* 設定 - PaddleOCR */ +"settings.paddleocr.installed" = "インストール済み"; +"settings.paddleocr.not.installed" = "未インストール"; +"settings.paddleocr.install" = "インストール"; +"settings.paddleocr.installing" = "インストール中..."; +"settings.paddleocr.install.hint" = "システムに Python 3 と pip がインストールされている必要があります。"; +"settings.paddleocr.copy.command" = "コマンドをコピー"; +"settings.paddleocr.refresh" = "ステータスを更新"; +"settings.paddleocr.ready" = "PaddleOCR の準備ができました"; +"settings.paddleocr.not.installed.message" = "PaddleOCR がインストールされていません"; +"settings.paddleocr.description" = "PaddleOCR はローカル OCR エンジンです。無料、オフラインで使用可能、API キーは不要です。"; +"settings.paddleocr.install.button" = "PaddleOCR をインストール"; +"settings.paddleocr.copy.command.button" = "インストールコマンドをコピー"; +"settings.paddleocr.mode" = "モード"; +"settings.paddleocr.mode.fast" = "高速"; +"settings.paddleocr.mode.precise" = "高精度"; +"settings.paddleocr.mode.fast.description" = "約1秒、高速 OCR と行の結合"; +"settings.paddleocr.mode.precise.description" = "約12秒、VL-1.5 モデル、より高い精度"; +"settings.paddleocr.useCloud" = "クラウド API を使用"; +"settings.paddleocr.cloudBaseURL" = "クラウド API URL"; +"settings.paddleocr.cloudAPIKey" = "API キー"; +"settings.paddleocr.cloudModelId" = "モデル ID"; +"settings.paddleocr.localVLModelDir" = "ローカルモデルディレクトリ (vllm)"; +"settings.paddleocr.localVLModelDir.hint" = "ローカル PaddleOCR-VL モデルのパス(例:~/.paddlex/official_models/PaddleOCR-VL-1.5)"; +"error.paddleocr.notInstalled" = "PaddleOCR がインストールされていません。以下のコマンドでインストールしてください:pip3 install paddleocr paddlepaddle"; + + +/* ======================================== + VLM 設定 + ======================================== */ + +"settings.vlm.title" = "VLM 設定"; +"settings.vlm.provider" = "プロバイダー"; +"settings.vlm.apiKey" = "API キー"; +"settings.vlm.apiKey.optional" = "ローカルプロバイダーの場合は API キーは不要です"; +"settings.vlm.baseURL" = "Base URL"; +"settings.vlm.model" = "モデル名"; +"settings.vlm.test.button" = "接続をテスト"; +"settings.vlm.test.success" = "接続成功!モデル:%@"; +"settings.vlm.test.ollama.success" = "サーバーが実行中です。モデル '%@' が利用可能"; +"settings.vlm.test.ollama.available" = "サーバーが実行中です。利用可能:%@"; + +"vlm.provider.openai" = "OpenAI"; +"vlm.provider.claude" = "Claude"; +"vlm.provider.glmocr" = "GLM OCR"; +"vlm.provider.ollama" = "Ollama"; +"vlm.provider.paddleocr" = "PaddleOCR"; +"vlm.provider.openai.description" = "OpenAI GPT-4 Vision API"; +"vlm.provider.claude.description" = "Anthropic Claude Vision API"; +"vlm.provider.glmocr.description" = "Zhipu GLM-OCR レイアウト解析 API"; +"vlm.provider.ollama.description" = "ローカル Ollama サーバー"; +"vlm.provider.paddleocr.description" = "ローカル OCR エンジン(無料、オフライン)"; + + +/* ======================================== + 翻訳ワークフロー設定 + ======================================== */ + +"settings.translation.workflow.title" = "翻訳エンジン"; +"settings.translation.preferred" = "優先エンジン"; +"settings.translation.mtran.url" = "MTransServer URL"; +"settings.translation.mtran.test.button" = "接続をテスト"; +"settings.translation.mtran.test.success" = "接続成功"; +"settings.translation.mtran.test.failed" = "接続失敗:%@"; +"settings.translation.fallback" = "フォールバック"; +"settings.translation.fallback.description" = "優先エンジンが失敗した場合、Apple 翻訳をフォールバックとして使用"; + +"translation.preferred.apple.description" = "組み込み macOS 翻訳、オフラインで動作"; +"translation.preferred.mtran.description" = "セルフホスト翻訳サーバー、より高い品質"; + + +/* ======================================== + アクセシビリティラベル + ======================================== */ + +"accessibility.close.button" = "閉じる"; +"accessibility.settings.button" = "設定"; +"accessibility.capture.button" = "キャプチャ"; +"accessibility.translate.button" = "翻訳"; + + +/* ======================================== + バイリンガル結果ウィンドウ + ======================================== */ + +/* ======================================== + 翻訳フロー + ======================================== */ + +"translationFlow.phase.idle" = "準備完了"; +"translationFlow.phase.analyzing" = "画像を解析中..."; +"translationFlow.phase.translating" = "翻訳中..."; +"translationFlow.phase.rendering" = "レンダリング中..."; +"translationFlow.phase.completed" = "完了"; +"translationFlow.phase.failed" = "失敗"; + +"translationFlow.error.title" = "翻訳エラー"; +"translationFlow.error.title.analysis" = "画像認識失敗"; +"translationFlow.error.title.translation" = "翻訳失敗"; +"translationFlow.error.title.rendering" = "レンダリング失敗"; +"translationFlow.error.unknown" = "不明なエラーが発生しました。"; +"translationFlow.error.analysis" = "解析失敗:%@"; +"translationFlow.error.translation" = "翻訳失敗:%@"; +"translationFlow.error.rendering" = "レンダリング失敗:%@"; +"translationFlow.error.cancelled" = "翻訳がキャンセルされました。"; +"translationFlow.error.noTextFound" = "選択範囲にテキストが見つかりませんでした。"; +"translationFlow.error.translation.engine" = "翻訳エンジン"; + +"translationFlow.recovery.analysis" = "より鮮明な画像でもう一度お試しください。または、VLM プロバイダー設定を確認してください。"; +"translationFlow.recovery.translation" = "翻訳エンジン設定とネットワーク接続を確認してから再試行してください。"; +"translationFlow.recovery.rendering" = "もう一度お試しください。"; +"translationFlow.recovery.noTextFound" = "テキストが表示されている範囲を選択してお試しください。"; + +"common.ok" = "OK"; + + +/* ======================================== + バイリンガル結果ウィンドウ + ======================================== */ + +"bilingualResult.window.title" = "バイリンガル翻訳"; +"bilingualResult.loading" = "翻訳中..."; +"bilingualResult.loading.analyzing" = "画像を解析中..."; +"bilingualResult.loading.translating" = "テキストを翻訳中..."; +"bilingualResult.loading.rendering" = "結果をレンダリング中..."; +"bilingualResult.copyImage" = "画像をコピー"; +"bilingualResult.copyText" = "テキストをコピー"; +"bilingualResult.save" = "保存"; +"bilingualResult.zoomIn" = "拡大"; +"bilingualResult.zoomOut" = "縮小"; +"bilingualResult.resetZoom" = "ズームをリセット"; +"bilingualResult.copySuccess" = "クリップボードにコピーしました"; +"bilingualResult.copyTextSuccess" = "翻訳テキストをコピーしました"; +"bilingualResult.saveSuccess" = "保存しました"; +"bilingualResult.copyFailed" = "画像のコピーに失敗しました"; +"bilingualResult.saveFailed" = "画像の保存に失敗しました"; +"bilingualResult.noTextToCopy" = "コピーする翻訳テキストがありません"; + + +/* ======================================== + テキスト翻訳 (US-003 から US-010) + ======================================== */ + +/* テキスト翻訳フロー */ +"textTranslation.phase.idle" = "準備完了"; +"textTranslation.phase.translating" = "翻訳中..."; +"textTranslation.phase.completed" = "完了"; +"textTranslation.phase.failed" = "失敗"; + +"textTranslation.error.emptyInput" = "翻訳するテキストがありません"; +"textTranslation.error.translationFailed" = "翻訳失敗:%@"; +"textTranslation.error.cancelled" = "翻訳がキャンセルされました"; +"textTranslation.error.serviceUnavailable" = "翻訳サービスが利用できません"; +"textTranslation.error.insertFailed" = "翻訳テキストの挿入に失敗しました"; + +"textTranslation.recovery.emptyInput" = "最初にテキストを選択してください"; +"textTranslation.recovery.translationFailed" = "もう一度お試しください"; +"textTranslation.recovery.serviceUnavailable" = "ネットワーク接続を確認してから再試行してください"; + +"textTranslation.loading" = "翻訳中..."; +"textTranslation.noSelection.title" = "テキストが選択されていません"; +"textTranslation.noSelection.message" = "任意のアプリケーションでテキストを選択してから再試行してください。"; + +/* 翻訳して挿入 */ +"translateAndInsert.emptyClipboard.title" = "クリップボードが空です"; +"translateAndInsert.emptyClipboard.message" = "最初にテキストをクリップボードにコピーしてから、このショートカットを使用してください。"; +"translateAndInsert.success.title" = "翻訳が挿入されました"; +"translateAndInsert.success.message" = "翻訳されたテキストがフォーカスされた入力フィールドに挿入されました。"; + +/* 言語表示 */ +"language.auto" = "自動検出"; + +/* 一般的なテキスト翻訳 UI */ +"common.copy" = "コピー"; +"common.copied" = "コピーしました"; +"common.insert" = "挿入"; + +/* テキスト翻訳ウィンドウ */ +"textTranslation.window.title" = "テキスト翻訳"; + +/* 翻訳して挿入言語設定 */ +"settings.translateAndInsert.language.section" = "翻訳して挿入の言語"; +"settings.translateAndInsert.language.source" = "元の言語"; +"settings.translateAndInsert.language.target" = "ターゲット言語"; + + +/* ======================================== + マルチ OpenAI 互換エンジン設定 + ======================================== */ + +/* 互換エンジン設定 */ +"engine.compatible.new" = "新しい互換エンジン"; +"engine.compatible.description" = "OpenAI 互換 API エンドポイント"; +"engine.compatible.displayName" = "表示名"; +"engine.compatible.displayName.placeholder" = "例:マイ LLM サーバー"; +"engine.compatible.requireApiKey" = "API キーが必要"; +"engine.compatible.add" = "互換エンジンを追加"; +"engine.compatible.delete" = "このエンジンを削除"; +"engine.compatible.useAsEngine" = "翻訳エンジンとして使用"; +"engine.compatible.max.reached" = "最大 5 つの互換エンジンに達しました"; + +/* プロンプト設定 */ +"prompt.compatible.title" = "互換エンジン"; + + +/* ======================================== + アバウトウィンドウ + ======================================== */ + +"about.title" = "ScreenTranslate について"; +"about.app.name" = "ScreenTranslate"; +"about.version.format" = "バージョン %@ (%@)"; +"about.copyright" = "著作権"; +"about.copyright.value" = "© 2026 全著作権所有"; +"about.license" = "ライセンス"; +"about.license.value" = "MIT ライセンス"; +"about.github.link" = "GitHub: hubo1989/ScreenTranslate"; +"about.check.for.updates" = "更新を確認"; +"about.update.checking" = "確認中..."; +"about.update.available" = "更新が利用可能"; +"about.update.uptodate" = "最新版です"; +"about.update.failed" = "確認失敗"; +"about.acknowledgements" = "謝辞"; +"about.acknowledgements.title" = "謝辞"; +"about.acknowledgements.intro" = "本ソフトウェアは以下のオープンソースライブラリを使用しています:"; +"about.acknowledgements.upstream" = "ベース"; +"about.acknowledgements.author.format" = "作者: %@"; +"about.close" = "閉じる"; +"settings.glmocr.mode" = "モード"; +"settings.glmocr.mode.cloud" = "クラウド"; +"settings.glmocr.mode.local" = "ローカル"; +"settings.glmocr.local.apiKey.optional" = "ローカル MLX-VLM サーバーでは API キーは不要です"; +"vlm.provider.glmocr.local.description" = "GLM-OCR 用ローカル MLX-VLM サーバー"; diff --git a/ScreenTranslate/Resources/ko.lproj/Localizable.strings b/ScreenTranslate/Resources/ko.lproj/Localizable.strings new file mode 100644 index 0000000..73cb064 --- /dev/null +++ b/ScreenTranslate/Resources/ko.lproj/Localizable.strings @@ -0,0 +1,823 @@ +/* + Localizable.strings (한국어) + ScreenTranslate +*/ + +/* ======================================== + 오류 메시지 + ======================================== */ + +/* 권한 오류 */ +"error.permission.denied" = "스크린샷을 캡처하려면 화면 녹화 권한이 필요합니다."; +"error.permission.denied.recovery" = "시스템 설정에서 권한을 부여하세요."; + +/* 디스플레이 오류 */ +"error.display.not.found" = "선택한 디스플레이를 사용할 수 없습니다."; +"error.display.not.found.recovery" = "다른 디스플레이를 선택하세요."; +"error.display.disconnected" = "캡처 중 '%@' 디스플레이 연결이 끊어졌습니다."; +"error.display.disconnected.recovery" = "디스플레이를 다시 연결한 후 다시 시도하세요."; + +/* 캡처 오류 */ +"error.capture.failed" = "화면 캡처에 실패했습니다."; +"error.capture.failed.recovery" = "다시 시도하세요."; + +/* 저장 오류 */ +"error.save.location.invalid" = "저장 위치에 액세스할 수 없습니다."; +"error.save.location.invalid.recovery" = "설정에서 다른 저장 위치를 선택하세요."; +"error.save.location.invalid.detail" = "%@에 저장할 수 없습니다. 위치에 액세스할 수 없습니다."; +"error.save.unknown" = "저장 중 알 수 없는 오류가 발생했습니다."; +"error.disk.full" = "스크린샷을 저장할 디스크 공간이 부족합니다."; +"error.disk.full.recovery" = "디스크 공간을 확보한 후 다시 시도하세요."; + +/* 내보내기 오류 */ +"error.export.encoding.failed" = "이미지 인코딩에 실패했습니다."; +"error.export.encoding.failed.recovery" = "설정에서 다른 형식을 시도하세요."; +"error.export.encoding.failed.detail" = "%@ 형식으로 이미지를 인코딩하지 못했습니다."; + +/* 클립보드 오류 */ +"error.clipboard.write.failed" = "스크린샷을 클립보드에 복사하지 못했습니다."; +"error.clipboard.write.failed.recovery" = "다시 시도하세요."; + +/* 단축키 오류 */ +"error.hotkey.registration.failed" = "키보드 단축키 등록에 실패했습니다."; +"error.hotkey.registration.failed.recovery" = "단축키가 다른 앱과 충돌할 수 있습니다. 다른 단축키를 시도하세요."; +"error.hotkey.conflict" = "이 키보드 단축키가 다른 애플리케이션과 충돌합니다."; +"error.hotkey.conflict.recovery" = "다른 키보드 단축키를 선택하세요."; + +/* OCR 오류 */ +"error.ocr.failed" = "텍스트 인식에 실패했습니다."; +"error.ocr.failed.recovery" = "더 선명한 이미지로 다시 시도하세요."; +"error.ocr.no.text" = "이미지에서 텍스트를 인식하지 못했습니다."; +"error.ocr.no.text.recovery" = "보이는 텍스트가 있는 영역을 캡처해 보세요."; +"error.ocr.cancelled" = "텍스트 인식이 취소되었습니다."; +"error.ocr.server.unreachable" = "OCR 서버에 연결할 수 없습니다."; +"error.ocr.server.unreachable.recovery" = "서버 주소와 네트워크 연결을 확인하세요."; + +/* 번역 오류 */ +"error.translation.in.progress" = "이미 번역이 진행 중입니다"; +"error.translation.in.progress.recovery" = "현재 번역이 완료될 때까지 기다려주세요"; +"error.translation.empty.input" = "번역할 텍스트가 없습니다"; +"error.translation.empty.input.recovery" = "먼저 텍스트를 선택하세요"; +"error.translation.timeout" = "번역 시간 초과"; +"error.translation.timeout.recovery" = "다시 시도하세요"; +"error.translation.unsupported.pair" = "%@에서 %@로의 번역은 지원되지 않습니다"; +"error.translation.unsupported.pair.recovery" = "다른 언어를 선택하세요"; +"error.translation.failed" = "번역에 실패했습니다"; +"error.translation.failed.recovery" = "다시 시도하세요"; +"error.translation.language.not.installed" = "번역 언어 '%@'이(가) 설치되지 않았습니다"; +"error.translation.language.download.instructions" = "시스템 설정 > 일반 > 언어 및 지역 > 번역 언어로 이동하여 필요한 언어를 다운로드하세요."; + +/* 일반 오류 UI */ +"error.title" = "오류"; +"error.ok" = "확인"; +"error.dismiss" = "닫기"; +"error.retry.capture" = "재시도"; +"error.permission.open.settings" = "시스템 설정 열기"; + + +/* ======================================== + 메뉴 항목 + ======================================== */ + +"menu.capture.full.screen" = "전체 화면 캡처"; +"menu.capture.fullscreen" = "전체 화면 캡처"; +"menu.capture.selection" = "선택 영역 캡처"; +"menu.translation.mode" = "번역 모드"; +"menu.translation.history" = "번역 기록"; +"menu.settings" = "설정..."; +"menu.about" = "ScreenTranslate 정보"; +"menu.quit" = "ScreenTranslate 종료"; + + +/* ======================================== + 디스플레이 선택기 + ======================================== */ + +"display.selector.title" = "디스플레이 선택"; +"display.selector.header" = "캡처할 디스플레이 선택:"; +"display.selector.cancel" = "취소"; + + +/* ======================================== + 미리보기 창 + ======================================== */ + +"preview.window.title" = "스크린샷 미리보기"; +"preview.title" = "스크린샷 미리보기"; +"preview.dimensions" = "%d x %d 픽셀"; +"preview.file.size" = "~%@ %@"; +"preview.screenshot" = "스크린샷"; +"preview.enter.text" = "텍스트 입력"; +"preview.image.dimensions" = "이미지 크기"; +"preview.estimated.size" = "예상 파일 크기"; +"preview.edit.label" = "편집:"; +"preview.active.tool" = "활성 도구"; +"preview.crop.mode.active" = "자르기 모드 활성화"; + +/* 자르기 */ +"preview.crop" = "자르기"; +"preview.crop.cancel" = "취소"; +"preview.crop.apply" = "자르기 적용"; + +/* 인식된 텍스트 */ +"preview.recognized.text" = "인식된 텍스트:"; +"preview.translation" = "번역:"; +"preview.results.panel" = "텍스트 결과"; +"preview.copy.text" = "텍스트 복사"; + +/* 도구 모음 도구 설명 */ +"preview.tooltip.crop" = "자르기 (C)"; +"preview.tooltip.pin" = "화면에 고정 (P)"; +"preview.tooltip.undo" = "실행 취소 (⌘Z)"; +"preview.tooltip.redo" = "다시 실행 (⌘⇧Z)"; +"preview.tooltip.copy" = "클립보드에 복사 (⌘C)"; +"preview.tooltip.save" = "저장 (⌘S)"; +"preview.tooltip.ocr" = "텍스트 인식 (OCR)"; +"preview.tooltip.confirm" = "클립보드에 복사하고 닫기 (Enter)"; +"preview.tooltip.dismiss" = "닫기 (Escape)"; +"preview.tooltip.delete" = "선택한 주석 삭제"; + +/* 접근성 라벨 */ +"preview.accessibility.save" = "스크린샷 저장"; +"preview.accessibility.saving" = "스크린샷 저장 중"; +"preview.accessibility.confirm" = "확인하고 클립보드에 복사"; +"preview.accessibility.copying" = "클립보드에 복사 중"; +"preview.accessibility.hint.commandS" = "Command S"; +"preview.accessibility.hint.enter" = "Enter 키"; + +/* 모양 전환 */ +"preview.shape.filled" = "채우기"; +"preview.shape.hollow" = "속 빈"; +"preview.shape.toggle.hint" = "클릭하여 채우기/속 빈 전환"; + + +/* ======================================== + 주석 도구 + ======================================== */ + +"tool.rectangle" = "직사각형"; +"tool.freehand" = "자유형"; +"tool.text" = "텍스트"; +"tool.arrow" = "화살표"; +"tool.ellipse" = "타원"; +"tool.line" = "선"; +"tool.highlight" = "형광펜"; +"tool.mosaic" = "모자이크"; +"tool.numberLabel" = "번호 라벨"; + + +/* ======================================== + 색상 + ======================================== */ + +"color.red" = "빨간색"; +"color.orange" = "주황색"; +"color.yellow" = "노란색"; +"color.green" = "녹색"; +"color.blue" = "파란색"; +"color.purple" = "보라색"; +"color.pink" = "분홍색"; +"color.white" = "흰색"; +"color.black" = "검은색"; +"color.custom" = "사용자 정의"; + + +/* ======================================== + 작업 + ======================================== */ + +"action.save" = "저장"; +"action.copy" = "복사"; +"action.cancel" = "취소"; +"action.undo" = "실행 취소"; +"action.redo" = "다시 실행"; +"action.delete" = "삭제"; +"action.clear" = "지우기"; +"action.reset" = "재설정"; +"action.close" = "닫기"; +"action.done" = "완료"; + +/* 버튼 */ +"button.ok" = "확인"; +"button.cancel" = "취소"; +"button.clear" = "지우기"; +"button.reset" = "재설정"; +"button.save" = "저장"; +"button.delete" = "삭제"; +"button.confirm" = "확인"; + +/* 저장 성공 */ +"save.success.title" = "저장 완료"; +"save.success.message" = "%@에 저장했습니다"; +"save.with.translations.message" = "번역된 이미지를 저장할 위치 선택"; + +/* 번역 없음 오류 */ +"error.no.translations" = "사용 가능한 번역이 없습니다. 먼저 텍스트를 번역하세요."; + +/* 복사 성공 */ +"copy.success.message" = "클립보드에 복사했습니다"; + + +/* ======================================== + 설정 창 + ======================================== */ + +"settings.window.title" = "ScreenTranslate 설정"; +"settings.title" = "ScreenTranslate 설정"; + +/* 설정 탭/섹션 */ +"settings.section.permissions" = "권한"; +"settings.section.general" = "일반"; +"settings.section.engines" = "엔진"; +"settings.section.prompts" = "프롬프트 구성"; +"settings.section.languages" = "언어"; +"settings.section.export" = "내보내기"; +"settings.section.shortcuts" = "키보드 단축키"; +"settings.section.text.translation" = "텍스트 번역"; +"settings.section.annotations" = "주석"; + +/* 언어 설정 */ +"settings.language" = "언어"; +"settings.language.system" = "시스템 기본값"; +"settings.language.restart.hint" = "일부 변경사항을 적용하려면 앱을 다시 시작해야 할 수 있습니다"; + +/* 권한 */ +"settings.permission.screen.recording" = "화면 녹화"; +"settings.permission.screen.recording.hint" = "스크린샷 캡처에 필요"; +"settings.permission.accessibility" = "접근성"; +"settings.permission.accessibility.hint" = "전역 단축키에 필요"; +"settings.permission.granted" = "권한 부여됨"; +"settings.permission.not.granted" = "권한 없음"; +"settings.permission.grant" = "권한 부여"; +"settings.permission.authorization.title" = "권한 부여 필요"; +"settings.permission.authorization.cancel" = "취소"; +"settings.permission.authorization.go" = "권한 부여하러 가기"; +"settings.permission.authorization.screen.message" = "화면 녹화 권한이 필요합니다. '권한 부여하러 가기'를 클릭하여 시스템 설정을 열고 이 앱에 대한 화면 캡처를 활성화하세요."; +"settings.permission.authorization.accessibility.message" = "접근성 권한이 필요합니다. '권한 부여하러 가기'를 클릭하여 시스템 설정을 열고 이 앱을 접근성 목록에 추가하세요."; + +/* 저장 위치 */ +"settings.save.location" = "저장 위치"; +"settings.save.location.choose" = "선택..."; +"settings.save.location.select" = "선택"; +"settings.save.location.message" = "스크린샷 저장의 기본 위치 선택"; +"settings.save.location.reveal" = "Finder에서 표시"; + +/* 내보내기 형식 */ +"settings.format" = "기본 형식"; +"settings.format.png" = "PNG"; +"settings.format.jpeg" = "JPEG"; +"settings.format.heic" = "HEIC"; +"settings.jpeg.quality" = "JPEG 품질"; +"settings.jpeg.quality.hint" = "품질이 높을수록 파일 크기가 커집니다"; +"settings.heic.quality" = "HEIC 품질"; +"settings.heic.quality.hint" = "HEIC은 더 나은 압축률을 제공합니다"; + +/* 키보드 단축키 */ +"settings.shortcuts" = "키보드 단축키"; +"settings.shortcut.fullscreen" = "전체 화면 캡처"; +"settings.shortcut.selection" = "선택 영역 캡처"; +"settings.shortcut.translation.mode" = "번역 모드"; +"settings.shortcut.text.selection.translation" = "텍스트 선택 번역"; +"settings.shortcut.translate.and.insert" = "번역하고 삽입"; +"settings.shortcut.recording" = "키를 누르세요..."; +"settings.shortcut.reset" = "기본값으로 재설정"; +"settings.shortcut.error.no.modifier" = "단축키에는 Command, Control 또는 Option이 포함되어야 합니다"; +"settings.shortcut.error.conflict" = "이 단축키는 이미 사용 중입니다"; + +/* 주석 */ +"settings.annotations" = "주석 기본값"; +"settings.stroke.color" = "선 색상"; +"settings.stroke.width" = "선 너비"; +"settings.text.size" = "텍스트 크기"; +"settings.mosaic.blockSize" = "모자이크 블록 크기"; + +/* 엔진 */ +"settings.ocr.engine" = "OCR 엔진"; +"settings.translation.engine" = "번역 엔진"; +"settings.translation.mode" = "번역 모드"; + +/* 재설정 */ +"settings.reset.all" = "모두 기본값으로 재설정"; + +/* 오류 */ +"settings.error.title" = "오류"; +"settings.error.ok" = "확인"; + + +/* ======================================== + OCR 엔진 + ======================================== */ + +"ocr.engine.vision" = "Apple Vision"; +"ocr.engine.vision.description" = "내장 macOS Vision 프레임워크, 빠르고 프라이빗합니다"; +"ocr.engine.paddleocr" = "PaddleOCR"; +"ocr.engine.paddleocr.description" = "더 나은 정확도를 위한 자체 호스팅 OCR 서버"; + + +/* ======================================== + 번역 엔진 + ======================================== */ + +"translation.engine.apple" = "Apple 번역"; +"translation.engine.apple.description" = "내장 macOS 번역, 설정 불필요"; +"translation.engine.mtran" = "MTranServer"; +"translation.engine.mtran.description" = "자체 호스팅 번역 서버"; + +/* 새 번역 엔진 */ +"translation.engine.openai" = "OpenAI"; +"translation.engine.openai.description" = "OpenAI API를 통한 GPT-4 번역"; +"translation.engine.claude" = "Claude"; +"translation.engine.claude.description" = "Anthropic API를 통한 Claude 번역"; +"translation.engine.gemini" = "Gemini"; +"translation.engine.gemini.description" = "Google AI API를 통한 Gemini 번역"; +"translation.engine.ollama" = "Ollama"; +"translation.engine.ollama.description" = "Ollama를 통한 로컬 LLM 번역"; +"translation.engine.google" = "Google 번역"; +"translation.engine.google.description" = "Google Cloud Translation API"; +"translation.engine.deepl" = "DeepL"; +"translation.engine.deepl.description" = "DeepL API를 통한 고품질 번역"; +"translation.engine.baidu" = "Baidu 번역"; +"translation.engine.baidu.description" = "Baidu Translation API"; +"translation.engine.custom" = "OpenAI 호환"; +"translation.engine.custom.description" = "사용자 정의 OpenAI 호환 엔드포인트"; + +/* 엔진 카테고리 */ +"engine.category.builtin" = "내장"; +"engine.category.llm" = "LLM 번역"; +"engine.category.cloud" = "클라우드 서비스"; +"engine.category.compatible" = "호환"; + +/* 엔진 구성 제목 */ +"engine.config.title" = "번역 엔진 구성"; + +/* 엔진 선택 모드 */ +"engine.selection.mode.title" = "엔진 선택 모드"; +"engine.selection.mode.primary_fallback" = "주/보조"; +"engine.selection.mode.primary_fallback.description" = "주 엔진을 사용하고 실패 시 보조 엔진으로 전환"; +"engine.selection.mode.parallel" = "병렬"; +"engine.selection.mode.parallel.description" = "여러 엔진을 동시에 실행하고 결과 비교"; +"engine.selection.mode.quick_switch" = "빠른 전환"; +"engine.selection.mode.quick_switch.description" = "주 엔진으로 시작하고 필요에 따라 다른 엔진으로 빠르게 전환"; +"engine.selection.mode.scene_binding" = "시나리오"; +"engine.selection.mode.scene_binding.description" = "다른 번역 시나리오에 다른 엔진 사용"; + +/* 모드별 라벨 */ +"engine.config.primary" = "주 엔진"; +"engine.config.fallback" = "보조 엔진"; +"engine.config.switch.order" = "전환 순서"; +"engine.config.parallel.select" = "병렬로 실행할 엔진 선택"; +"engine.config.replace" = "엔진 교체"; +"engine.config.remove" = "제거"; +"engine.config.add" = "엔진 추가"; + +/* 번역 시나리오 */ +"translation.scene.screenshot" = "스크린샷 번역"; +"translation.scene.screenshot.description" = "캡처된 스크린샷 영역을 OCR하고 번역"; +"translation.scene.text_selection" = "텍스트 선택 번역"; +"translation.scene.text_selection.description" = "모든 애플리케이션에서 선택한 텍스트 번역"; +"translation.scene.translate_and_insert" = "번역하고 삽입"; +"translation.scene.translate_and_insert.description" = "클립보드 텍스트를 번역하여 커서 위치에 삽입"; + +/* 엔진 구성 */ +"engine.config.enabled" = "이 엔진 활성화"; +"engine.config.apiKey" = "API 키"; +"engine.config.apiKey.placeholder" = "API 키 입력"; +"engine.config.getApiKey" = "API 키 가져오기"; +"engine.config.baseURL" = "기본 URL"; +"engine.config.model" = "모델 이름"; +"engine.config.test" = "연결 테스트"; +"engine.config.test.success" = "연결 성공"; +"engine.config.test.failed" = "연결 실패"; +"engine.config.baidu.credentials" = "Baidu 자격 증명"; +"engine.config.baidu.appID" = "앱 ID"; +"engine.config.baidu.secretKey" = "시크릿 키"; +"engine.config.mtran.url" = "서버 URL"; + +/* 엔진 상태 */ +"engine.status.configured" = "구성됨"; +"engine.status.unconfigured" = "구성되지 않음"; +"engine.available.title" = "사용 가능한 엔진"; +"engine.parallel.title" = "병렬 엔진"; +"engine.parallel.description" = "병렬 모드에서 동시에 실행할 엔진 선택"; +"engine.scene.binding.title" = "시나리오 엔진 바인딩"; +"engine.scene.binding.description" = "각 번역 시나리오에 사용할 엔진 구성"; +"engine.scene.fallback.tooltip" = "다른 엔진으로의 대체 활성화"; + +/* Keychain 오류 */ +"keychain.error.item_not_found" = "키체인에서 자격 증명을 찾을 수 없습니다"; +"keychain.error.item_not_found.recovery" = "설정에서 API 자격 증명을 구성하세요"; +"keychain.error.duplicate_item" = "키체인에 자격 증명이 이미 존재합니다"; +"keychain.error.duplicate_item.recovery" = "먼저 기존 자격 증명을 삭제해 보세요"; +"keychain.error.invalid_data" = "잘못된 자격 증명 데이터 형식"; +"keychain.error.invalid_data.recovery" = "자격 증명을 다시 입력해 보세요"; +"keychain.error.unexpected_status" = "키체인 작업 실패"; +"keychain.error.unexpected_status.recovery" = "키체인 액세스 권한을 확인하세요"; + +/* 다중 엔진 오류 */ +"multiengine.error.all_failed" = "모든 번역 엔진이 실패했습니다"; +"multiengine.error.no_engines" = "구성된 번역 엔진이 없습니다"; +"multiengine.error.primary_unavailable" = "주 엔진 %@을(를) 사용할 수 없습니다"; +"multiengine.error.no_results" = "번역 결과가 없습니다"; + +/* 레지스트리 오류 */ +"registry.error.already_registered" = "공급자가 이미 등록되었습니다"; +"registry.error.not_registered" = "%@에 대해 등록된 공급자가 없습니다"; +"registry.error.config_missing" = "%@에 대한 구성이 누락되었습니다"; +"registry.error.credentials_not_found" = "%@에 대한 자격 증명을 찾을 수 없습니다"; + +/* 프롬프트 구성 */ +"prompt.engine.title" = "엔진 프롬프트"; +"prompt.engine.description" = "각 LLM 엔진에 대한 번역 프롬프트 사용자 정의"; +"prompt.scene.title" = "시나리오 프롬프트"; +"prompt.scene.description" = "각 번역 시나리오에 대한 번역 프롬프트 사용자 정의"; +"prompt.default.title" = "기본 프롬프트 템플릿"; +"prompt.default.description" = "사용자 정의 프롬프트가 구성되지 않은 경우 이 템플릿이 사용됩니다"; +"prompt.button.edit" = "편집"; +"prompt.button.reset" = "재설정"; +"prompt.editor.title" = "프롬프트 편집"; +"prompt.editor.variables" = "사용 가능한 변수:"; +"prompt.variable.source_language" = "소스 언어 이름"; +"prompt.variable.target_language" = "대상 언어 이름"; +"prompt.variable.text" = "번역할 텍스트"; + + +/* ======================================== + 번역 모드 + ======================================== */ + +"translation.mode.inline" = "제자리 교체"; +"translation.mode.inline.description" = "원본 텍스트를 번역으로 교체"; +"translation.mode.below" = "원본 아래 표시"; +"translation.mode.below.description" = "원본 텍스트 아래에 번역 표시"; + + +/* ======================================== + 번역 설정 + ======================================== */ + +"translation.auto" = "자동 감지"; +"translation.auto.detected" = "자동 감지됨"; +"translation.language.follow.system" = "시스템 따르기"; +"translation.language.source" = "소스 언어"; +"translation.language.target" = "대상 언어"; +"translation.language.source.hint" = "번역할 텍스트의 언어"; +"translation.language.target.hint" = "텍스트를 번역할 언어"; + + +/* ======================================== + 기록 보기 + ======================================== */ + +"history.title" = "번역 기록"; +"history.search.placeholder" = "기록 검색..."; +"history.clear.all" = "모든 기록 지우기"; +"history.empty.title" = "번역 기록 없음"; +"history.empty.message" = "번역된 스크린샷이 여기에 표시됩니다"; +"history.no.results.title" = "결과 없음"; +"history.no.results.message" = "검색과 일치하는 항목이 없습니다"; +"history.clear.search" = "검색 지우기"; + +"history.source" = "원본"; +"history.translation" = "번역"; +"history.truncated" = "잘림"; + +"history.copy.translation" = "번역 복사"; +"history.copy.source" = "원본 복사"; +"history.copy.both" = "모두 복사"; +"history.delete" = "삭제"; + +"history.clear.alert.title" = "기록 지우기"; +"history.clear.alert.message" = "모든 번역 기록을 삭제하시겠습니까? 이 작업은 실행 취소할 수 없습니다."; + + +/* ======================================== + 권한 프롬프트 + ======================================== */ + +"permission.prompt.title" = "화면 녹화 권한 필요"; +"permission.prompt.message" = "ScreenTranslate는 화면을 캡처할 권한이 필요합니다. 스크린샷을 찍는 데 필요합니다.\n\n계속을 클릭한 후 macOS에서 화면 녹화 권한을 부여하도록 요청합니다. 시스템 설정 > 개인 정보 및 보안 > 화면 녹화에서 권한을 부여할 수 있습니다."; +"permission.prompt.continue" = "계속"; +"permission.prompt.later" = "나중에"; + +/* 접근성 권한 */ +"permission.accessibility.title" = "접근성 권한 필요"; +"permission.accessibility.message" = "ScreenTranslate는 선택한 텍스트를 캡처하고 번역을 삽입하려면 접근성 권한이 필요합니다.\n\n이 권한은 앱이 다음을 수행할 수 있게 합니다:\n• 모든 애플리케이션에서 선택한 텍스트 복사\n• 번역된 텍스트를 입력 필드에 삽입\n\n개인정보는 보호됩니다 - ScreenTranslate는 텍스트 번역에만 이를 사용합니다."; +"permission.accessibility.grant" = "권한 부여"; +"permission.accessibility.open.settings" = "시스템 설정 열기"; +"permission.accessibility.denied.title" = "접근성 권한 필요"; +"permission.accessibility.denied.message" = "텍스트 캡처 및 삽입에는 접근성 권한이 필요합니다.\n\n시스템 설정 > 개인 정보 및 보안 > 접근성에서 권한을 부여하세요."; + +/* 입력 모니터링 권한 */ +"permission.input.monitoring.title" = "입력 모니터링 권한 필요"; +"permission.input.monitoring.message" = "ScreenTranslate는 번역된 텍스트를 애플리케이션에 삽입하려면 입력 모니터링 권한이 필요합니다.\n\n다음 위치에서 활성화해야 합니다:\n시스템 설정 > 개인 정보 및 보안 > 입력 모니터링"; +"permission.input.monitoring.open.settings" = "시스템 설정 열기"; +"permission.input.monitoring.denied.title" = "입력 모니터링 권한 필요"; +"permission.input.monitoring.denied.message" = "텍스트 삽입에는 입력 모니터링 권한이 필요합니다.\n\n시스템 설정 > 개인 정보 및 보안 > 입력 모니터링에서 권한을 부여하세요."; + +/* 일반 권한 문자열 */ +"permission.open.settings" = "시스템 설정 열기"; + + +/* ======================================== + 온보딩 + ======================================== */ + +"onboarding.window.title" = "ScreenTranslate에 오신 것을 환영합니다"; + +/* 온보딩 - 환영 단계 */ +"onboarding.welcome.title" = "ScreenTranslate에 오신 것을 환영합니다"; +"onboarding.welcome.message" = "화면 캡처 및 번역 기능을 설정하겠습니다. 1분 정도 걸립니다."; + +"onboarding.feature.local.ocr.title" = "로컬 OCR"; +"onboarding.feature.local.ocr.description" = "빠르고 프라이빗한 텍스트 인식을 위한 macOS Vision 프레임워크"; +"onboarding.feature.local.translation.title" = "로컬 번역"; +"onboarding.feature.local.translation.description" = "즉각적인 오프라인 번역을 위한 Apple 번역"; +"onboarding.feature.shortcuts.title" = "전역 단축키"; +"onboarding.feature.shortcuts.description" = "키보드 단축키로 어디서든 캡처 및 번역"; + +/* 온보딩 - 권한 단계 */ +"onboarding.permissions.title" = "권한"; +"onboarding.permissions.message" = "ScreenTranslate가 제대로 작동하려면 몇 가지 권한이 필요합니다. 다음 권한을 부여하세요:"; +"onboarding.permissions.hint" = "권한을 부여한 후 상태가 자동으로 업데이트됩니다."; + +"onboarding.permission.screen.recording" = "화면 녹화"; +"onboarding.permission.accessibility" = "접근성"; +"onboarding.permission.granted" = "권한 부여됨"; +"onboarding.permission.not.granted" = "권한 없음"; +"onboarding.permission.grant" = "권한 부여"; + +/* 온보딩 - 구성 단계 */ +"onboarding.configuration.title" = "선택적 구성"; +"onboarding.configuration.message" = "로컬 OCR 및 번역 기능이 이미 활성화되어 있습니다. 외부 서비스를 선택적으로 구성하세요:"; +"onboarding.configuration.paddleocr" = "PaddleOCR 서버 주소"; +"onboarding.configuration.paddleocr.hint" = "비워두면 macOS Vision OCR 사용"; +"onboarding.configuration.mtran" = "MTranServer 주소"; +"onboarding.configuration.mtran.hint" = "비워두면 Apple 번역 사용"; +"onboarding.configuration.placeholder" = "http://localhost:8080"; +"onboarding.configuration.placeholder.address" = "localhost"; +"onboarding.configuration.test" = "번역 테스트"; +"onboarding.configuration.test.button" = "번역 테스트"; +"onboarding.configuration.testing" = "테스트 중..."; +"onboarding.test.success" = "번역 테스트 성공: \"%@\" → \"%@\""; +"onboarding.test.failed" = "번역 테스트 실패: %@"; + +/* 온보딩 - 완료 단계 */ +"onboarding.complete.title" = "설정 완료!"; +"onboarding.complete.message" = "ScreenTranslate를 사용할 준비가 되었습니다. 시작하는 방법은 다음과 같습니다:"; +"onboarding.complete.shortcuts" = "⌘⇧F를 사용하여 전체 화면 캡처"; +"onboarding.complete.selection" = "⌘⇧A를 사용하여 선택 영역을 캡처하고 번역"; +"onboarding.complete.settings" = "메뉴 표시줄에서 설정을 열어 옵션 사용자 정의"; +"onboarding.complete.start" = "ScreenTranslate 사용 시작"; + +/* 온보딩 - 내비게이션 */ +"onboarding.back" = "뒤로"; +"onboarding.continue" = "계속"; +"onboarding.next" = "다음"; +"onboarding.skip" = "건너뛰기"; +"onboarding.complete" = "완료"; + +/* 온보딩 - PaddleOCR */ +"onboarding.paddleocr.title" = "PaddleOCR (선택 사항)"; +"onboarding.paddleocr.description" = "더 나은 텍스트 인식 정확도를 위한 향상된 OCR 엔진, 특히 중국어에 적합합니다."; +"onboarding.paddleocr.installed" = "설치됨"; +"onboarding.paddleocr.not.installed" = "설치되지 않음"; +"onboarding.paddleocr.install" = "설치"; +"onboarding.paddleocr.installing" = "설치 중..."; +"onboarding.paddleocr.install.hint" = "Python 3과 pip가 필요합니다. 다음을 실행하세요: pip3 install paddleocr paddlepaddle"; +"onboarding.paddleocr.copy.command" = "명령어 복사"; +"onboarding.paddleocr.refresh" = "상태 새로고침"; +"onboarding.paddleocr.version" = "버전: %@"; + +/* 설정 - PaddleOCR */ +"settings.paddleocr.installed" = "설치됨"; +"settings.paddleocr.not.installed" = "설치되지 않음"; +"settings.paddleocr.install" = "설치"; +"settings.paddleocr.installing" = "설치 중..."; +"settings.paddleocr.install.hint" = "시스템에 Python 3과 pip가 설치되어 있어야 합니다."; +"settings.paddleocr.copy.command" = "명령어 복사"; +"settings.paddleocr.refresh" = "상태 새로고침"; +"settings.paddleocr.ready" = "PaddleOCR이 준비되었습니다"; +"settings.paddleocr.not.installed.message" = "PaddleOCR이 설치되지 않았습니다"; +"settings.paddleocr.description" = "PaddleOCR은 로컬 OCR 엔진입니다. 무료이며 오프라인에서 작동하고 API 키가 필요하지 않습니다."; +"settings.paddleocr.install.button" = "PaddleOCR 설치"; +"settings.paddleocr.copy.command.button" = "설치 명령어 복사"; +"settings.paddleocr.mode" = "모드"; +"settings.paddleocr.mode.fast" = "빠름"; +"settings.paddleocr.mode.precise" = "정밀"; +"settings.paddleocr.mode.fast.description" = "~1초, 줄 그룹화가 포함된 빠른 OCR"; +"settings.paddleocr.mode.precise.description" = "~12초, 더 높은 정확도를 가진 VL-1.5 모델"; +"settings.paddleocr.useCloud" = "클라우드 API 사용"; +"settings.paddleocr.cloudBaseURL" = "클라우드 API URL"; +"settings.paddleocr.cloudAPIKey" = "API 키"; +"settings.paddleocr.cloudModelId" = "모델 ID"; +"settings.paddleocr.localVLModelDir" = "로컬 모델 디렉토리 (vllm)"; +"settings.paddleocr.localVLModelDir.hint" = "로컬 PaddleOCR-VL 모델 경로 (예: ~/.paddlex/official_models/PaddleOCR-VL-1.5)"; +"error.paddleocr.notInstalled" = "PaddleOCR이 설치되지 않았습니다. 다음을 사용하여 설치하세요: pip3 install paddleocr paddlepaddle"; + + +/* ======================================== + VLM 구성 + ======================================== */ + +"settings.vlm.title" = "VLM 구성"; +"settings.vlm.provider" = "공급자"; +"settings.vlm.apiKey" = "API 키"; +"settings.vlm.apiKey.optional" = "로컬 공급자의 경우 API 키는 선택 사항입니다"; +"settings.vlm.baseURL" = "기본 URL"; +"settings.vlm.model" = "모델 이름"; +"settings.vlm.test.button" = "연결 테스트"; +"settings.vlm.test.success" = "연결 성공! 모델: %@"; +"settings.vlm.test.ollama.success" = "서버 실행 중. 모델 '%@' 사용 가능"; +"settings.vlm.test.ollama.available" = "서버 실행 중. 사용 가능: %@"; + +"vlm.provider.openai" = "OpenAI"; +"vlm.provider.claude" = "Claude"; +"vlm.provider.glmocr" = "GLM OCR"; +"vlm.provider.ollama" = "Ollama"; +"vlm.provider.paddleocr" = "PaddleOCR"; +"vlm.provider.openai.description" = "OpenAI GPT-4 Vision API"; +"vlm.provider.claude.description" = "Anthropic Claude Vision API"; +"vlm.provider.glmocr.description" = "Zhipu GLM-OCR 레이아웃 파싱 API"; +"vlm.provider.ollama.description" = "로컬 Ollama 서버"; +"vlm.provider.paddleocr.description" = "로컬 OCR 엔진 (무료, 오프라인)"; + + +/* ======================================== + 번역 워크플로우 구성 + ======================================== */ + +"settings.translation.workflow.title" = "번역 엔진"; +"settings.translation.preferred" = "선호 엔진"; +"settings.translation.mtran.url" = "MTransServer URL"; +"settings.translation.mtran.test.button" = "연결 테스트"; +"settings.translation.mtran.test.success" = "연결 성공"; +"settings.translation.mtran.test.failed" = "연결 실패: %@"; +"settings.translation.fallback" = "대체"; +"settings.translation.fallback.description" = "선호 엔진이 실패할 경우 Apple 번역을 대체로 사용"; + +"translation.preferred.apple.description" = "내장 macOS 번역, 오프라인 작동"; +"translation.preferred.mtran.description" = "더 나은 품질을 위한 자체 호스팅 번역 서버"; + + +/* ======================================== + 접근성 라벨 + ======================================== */ + +"accessibility.close.button" = "닫기"; +"accessibility.settings.button" = "설정"; +"accessibility.capture.button" = "캡처"; +"accessibility.translate.button" = "번역"; + + +/* ======================================== + 이중 언어 결과 창 + ======================================== */ + +/* ======================================== + 번역 흐름 + ======================================== */ + +"translationFlow.phase.idle" = "준비됨"; +"translationFlow.phase.analyzing" = "이미지 분석 중..."; +"translationFlow.phase.translating" = "번역 중..."; +"translationFlow.phase.rendering" = "렌더링 중..."; +"translationFlow.phase.completed" = "완료됨"; +"translationFlow.phase.failed" = "실패함"; + +"translationFlow.error.title" = "번역 오류"; +"translationFlow.error.title.analysis" = "이미지 인식 실패"; +"translationFlow.error.title.translation" = "번역 실패"; +"translationFlow.error.title.rendering" = "렌더링 실패"; +"translationFlow.error.unknown" = "알 수 없는 오류가 발생했습니다."; +"translationFlow.error.analysis" = "분석 실패: %@"; +"translationFlow.error.translation" = "번역 실패: %@"; +"translationFlow.error.rendering" = "렌더링 실패: %@"; +"translationFlow.error.cancelled" = "번역이 취소되었습니다."; +"translationFlow.error.noTextFound" = "선택한 영역에서 텍스트를 찾을 수 없습니다."; +"translationFlow.error.translation.engine" = "번역 엔진"; + +"translationFlow.recovery.analysis" = "더 선명한 이미지로 다시 시도하거나 VLM 공급자 설정을 확인하세요."; +"translationFlow.recovery.translation" = "번역 엔진 설정과 네트워크 연결을 확인한 후 다시 시도하세요."; +"translationFlow.recovery.rendering" = "다시 시도하세요."; +"translationFlow.recovery.noTextFound" = "보이는 텍스트가 있는 영역을 선택해 보세요."; + +"common.ok" = "확인"; + + +/* ======================================== + 이중 언어 결과 창 + ======================================== */ + +"bilingualResult.window.title" = "이중 언어 번역"; +"bilingualResult.loading" = "번역 중..."; +"bilingualResult.loading.analyzing" = "이미지 분석 중..."; +"bilingualResult.loading.translating" = "텍스트 번역 중..."; +"bilingualResult.loading.rendering" = "결과 렌더링 중..."; +"bilingualResult.copyImage" = "이미지 복사"; +"bilingualResult.copyText" = "텍스트 복사"; +"bilingualResult.save" = "저장"; +"bilingualResult.zoomIn" = "확대"; +"bilingualResult.zoomOut" = "축소"; +"bilingualResult.resetZoom" = "확대/축소 재설정"; +"bilingualResult.copySuccess" = "클립보드에 복사했습니다"; +"bilingualResult.copyTextSuccess" = "번역 텍스트가 복사되었습니다"; +"bilingualResult.saveSuccess" = "저장 완료"; +"bilingualResult.copyFailed" = "이미지 복사 실패"; +"bilingualResult.saveFailed" = "이미지 저장 실패"; +"bilingualResult.noTextToCopy" = "복사할 번역 텍스트가 없습니다"; + + +/* ======================================== + 텍스트 번역 (US-003 ~ US-010) + ======================================== */ + +/* 텍스트 번역 흐름 */ +"textTranslation.phase.idle" = "준비됨"; +"textTranslation.phase.translating" = "번역 중..."; +"textTranslation.phase.completed" = "완료됨"; +"textTranslation.phase.failed" = "실패함"; + +"textTranslation.error.emptyInput" = "번역할 텍스트가 없습니다"; +"textTranslation.error.translationFailed" = "번역 실패: %@"; +"textTranslation.error.cancelled" = "번역이 취소되었습니다"; +"textTranslation.error.serviceUnavailable" = "번역 서비스를 사용할 수 없습니다"; +"textTranslation.error.insertFailed" = "번역된 텍스트를 삽입하지 못했습니다"; + +"textTranslation.recovery.emptyInput" = "먼저 텍스트를 선택하세요"; +"textTranslation.recovery.translationFailed" = "다시 시도하세요"; +"textTranslation.recovery.serviceUnavailable" = "네트워크 연결을 확인하고 다시 시도하세요"; + +"textTranslation.loading" = "번역 중..."; +"textTranslation.noSelection.title" = "선택한 텍스트 없음"; +"textTranslation.noSelection.message" = "모든 애플리케이션에서 텍스트를 선택한 후 다시 시도하세요."; + +/* 번역하고 삽입 */ +"translateAndInsert.emptyClipboard.title" = "클립보드가 비어있습니다"; +"translateAndInsert.emptyClipboard.message" = "먼저 텍스트를 클립보드에 복사한 다음 이 단축키를 사용하세요."; +"translateAndInsert.success.title" = "번역이 삽입되었습니다"; +"translateAndInsert.success.message" = "번역된 텍스트가 포커스된 입력 필드에 삽입되었습니다."; + +/* 언어 표시 */ +"language.auto" = "자동 감지"; + +/* 일반 텍스트 번역 UI */ +"common.copy" = "복사"; +"common.copied" = "복사됨"; +"common.insert" = "삽입"; + +/* 텍스트 번역 창 */ +"textTranslation.window.title" = "텍스트 번역"; + +/* 번역하고 삽입 언어 설정 */ +"settings.translateAndInsert.language.section" = "번역하고 삽입 언어"; +"settings.translateAndInsert.language.source" = "소스 언어"; +"settings.translateAndInsert.language.target" = "대상 언어"; + + +/* ======================================== + 다중 OpenAI 호환 엔진 구성 + ======================================== */ + +/* 호환 엔진 구성 */ +"engine.compatible.new" = "새 호환 엔진"; +"engine.compatible.description" = "OpenAI 호환 API 엔드포인트"; +"engine.compatible.displayName" = "표시 이름"; +"engine.compatible.displayName.placeholder" = "예: 내 LLM 서버"; +"engine.compatible.requireApiKey" = "API 키 필요"; +"engine.compatible.add" = "호환 엔진 추가"; +"engine.compatible.delete" = "이 엔진 삭제"; +"engine.compatible.useAsEngine" = "번역 엔진으로 사용"; +"engine.compatible.max.reached" = "최대 5개 호환 엔진에 도달했습니다"; + +/* 프롬프트 구성 */ +"prompt.compatible.title" = "호환 엔진"; + + +/* ======================================== + 정보 창 + ======================================== */ + +"about.title" = "ScreenTranslate 정보"; +"about.app.name" = "ScreenTranslate"; +"about.version.format" = "버전 %@ (%@)"; +"about.copyright" = "저작권"; +"about.copyright.value" = "© 2026 모든 권리 보유"; +"about.license" = "라이선스"; +"about.license.value" = "MIT 라이선스"; +"about.github.link" = "GitHub: hubo1989/ScreenTranslate"; +"about.check.for.updates" = "업데이트 확인"; +"about.update.checking" = "확인 중..."; +"about.update.available" = "업데이트 사용 가능"; +"about.update.uptodate" = "최신 버전입니다"; +"about.update.failed" = "확인 실패"; +"about.acknowledgements" = "감사의 말"; +"about.acknowledgements.title" = "감사의 말"; +"about.acknowledgements.intro" = "이 소프트웨어는 다음 오픈 소스 라이브러리를 사용합니다:"; +"about.acknowledgements.upstream" = "기반"; +"about.acknowledgements.author.format" = "%@ 작성"; +"about.close" = "닫기"; +"settings.glmocr.mode" = "모드"; +"settings.glmocr.mode.cloud" = "클라우드"; +"settings.glmocr.mode.local" = "로컬"; +"settings.glmocr.local.apiKey.optional" = "로컬 MLX-VLM 서버에서는 API 키가 선택 사항입니다"; +"vlm.provider.glmocr.local.description" = "GLM-OCR용 로컬 MLX-VLM 서버"; diff --git a/ScreenTranslate/Resources/pt.lproj/Localizable.strings b/ScreenTranslate/Resources/pt.lproj/Localizable.strings new file mode 100644 index 0000000..f8d1794 --- /dev/null +++ b/ScreenTranslate/Resources/pt.lproj/Localizable.strings @@ -0,0 +1,838 @@ +/* + Localizable.strings (Português) + ScreenTranslate +*/ + +/* ======================================== + Error Messages + ======================================== */ + +/* Permission Errors */ +"error.permission.denied" = "É necessária permissão de gravação de tela para capturar screenshots."; +"error.permission.denied.recovery" = "Abra as Configurações do Sistema para conceder permissão."; + +/* Display Errors */ +"error.display.not.found" = "A tela selecionada não está mais disponível."; +"error.display.not.found.recovery" = "Selecione uma tela diferente."; +"error.display.disconnected" = "A tela '%@' foi desconectada durante a captura."; +"error.display.disconnected.recovery" = "Reconecte a tela e tente novamente."; + +/* Capture Errors */ +"error.capture.failed" = "Falha ao capturar a tela."; +"error.capture.failed.recovery" = "Tente novamente."; + +/* Save Errors */ +"error.save.location.invalid" = "O local de salvamento não está acessível."; +"error.save.location.invalid.recovery" = "Escolha um local diferente nas Configurações."; +"error.save.location.invalid.detail" = "Não é possível salvar em %@. O local não está acessível."; +"error.save.unknown" = "Ocorreu um erro inesperado ao salvar."; +"error.disk.full" = "Não há espaço suficiente em disco para salvar o screenshot."; +"error.disk.full.recovery" = "Libere espaço em disco e tente novamente."; + +/* Export Errors */ +"error.export.encoding.failed" = "Falha ao codificar a imagem."; +"error.export.encoding.failed.recovery" = "Tente um formato diferente nas Configurações."; +"error.export.encoding.failed.detail" = "Falha ao codificar a imagem como %@."; + +/* Clipboard Errors */ +"error.clipboard.write.failed" = "Falha ao copiar o screenshot para a área de transferência."; +"error.clipboard.write.failed.recovery" = "Tente novamente."; + +/* Hotkey Errors */ +"error.hotkey.registration.failed" = "Falha ao registrar o atalho de teclado."; +"error.hotkey.registration.failed.recovery" = "O atalho pode entrar em conflito com outro app. Tente um atalho diferente."; +"error.hotkey.conflict" = "Este atalho de teclado entra em conflito com outro aplicativo."; +"error.hotkey.conflict.recovery" = "Escolha um atalho de teclado diferente."; + +/* OCR Errors */ +"error.ocr.failed" = "Falha no reconhecimento de texto."; +"error.ocr.failed.recovery" = "Tente novamente com uma imagem mais clara."; +"error.ocr.no.text" = "Nenhum texto foi reconhecido na imagem."; +"error.ocr.no.text.recovery" = "Tente capturar uma área com texto visível."; +"error.ocr.cancelled" = "O reconhecimento de texto foi cancelado."; +"error.ocr.server.unreachable" = "Não é possível conectar ao servidor OCR."; +"error.ocr.server.unreachable.recovery" = "Verifique o endereço do servidor e a conexão de rede."; + +/* Translation Errors */ +"error.translation.in.progress" = "Uma tradução já está em andamento"; +"error.translation.in.progress.recovery" = "Aguarde a tradução atual ser concluída"; +"error.translation.empty.input" = "Nenhum texto para traduzir"; +"error.translation.empty.input.recovery" = "Selecione algum texto primeiro"; +"error.translation.timeout" = "Tempo limite de tradução esgotado"; +"error.translation.timeout.recovery" = "Tente novamente"; +"error.translation.unsupported.pair" = "Tradução de %@ para %@ não é suportada"; +"error.translation.unsupported.pair.recovery" = "Selecione idiomas diferentes"; +"error.translation.failed" = "Falha na tradução"; +"error.translation.failed.recovery" = "Tente novamente"; +"error.translation.language.not.installed" = "O idioma de tradução '%@' não está instalado"; +"error.translation.language.download.instructions" = "Vá para Configurações do Sistema > Geral > Idioma e Região > Idiomas de Tradução, depois baixe o idioma necessário."; + +/* Generic Error UI */ +"error.title" = "Erro"; +"error.ok" = "OK"; +"error.dismiss" = "Descartar"; +"error.retry.capture" = "Tentar Novamente"; +"error.permission.open.settings" = "Abrir Configurações do Sistema"; + + +/* ======================================== + Menu Items + ======================================== */ + +"menu.capture.full.screen" = "Capturar Tela Cheia"; +"menu.capture.fullscreen" = "Capturar Tela Cheia"; +"menu.capture.selection" = "Capturar Seleção"; +"menu.translation.mode" = "Modo de Tradução"; +"menu.translation.history" = "Histórico de Tradução"; +"menu.settings" = "Configurações..."; +"menu.about" = "Sobre o ScreenTranslate"; +"menu.quit" = "Sair do ScreenTranslate"; + + +/* ======================================== + Display Selector + ======================================== */ + +"display.selector.title" = "Selecionar Tela"; +"display.selector.header" = "Escolha a tela para capturar:"; +"display.selector.cancel" = "Cancelar"; + + +/* ======================================== + Preview Window + ======================================== */ + +"preview.window.title" = "Visualização de Screenshot"; +"preview.title" = "Visualização de Screenshot"; +"preview.dimensions" = "%d x %d pixels"; +"preview.file.size" = "~%@ %@"; +"preview.screenshot" = "Screenshot"; +"preview.enter.text" = "Inserir texto"; +"preview.image.dimensions" = "Dimensões da imagem"; +"preview.estimated.size" = "Tamanho estimado do arquivo"; +"preview.edit.label" = "Editar:"; +"preview.active.tool" = "Ferramenta ativa"; +"preview.crop.mode.active" = "Modo de corte ativo"; + +/* Crop */ +"preview.crop" = "Cortar"; +"preview.crop.cancel" = "Cancelar"; +"preview.crop.apply" = "Aplicar Corte"; + +/* Recognized Text */ +"preview.recognized.text" = "Texto Reconhecido:"; +"preview.translation" = "Tradução:"; +"preview.results.panel" = "Resultados de Texto"; +"preview.copy.text" = "Copiar texto"; + +/* Toolbar Tooltips */ +"preview.tooltip.crop" = "Cortar (C)"; +"preview.tooltip.pin" = "Fixar na Tela (P)"; +"preview.tooltip.undo" = "Desfazer (⌘Z)"; +"preview.tooltip.redo" = "Refazer (⌘⇧Z)"; +"preview.tooltip.copy" = "Copiar para a Área de Transferência (⌘C)"; +"preview.tooltip.save" = "Salvar (⌘S)"; +"preview.tooltip.ocr" = "Reconhecer Texto (OCR)"; +"preview.tooltip.confirm" = "Copiar para a Área de Transferência e Fechar (Enter)"; +"preview.tooltip.dismiss" = "Descartar (Escape)"; +"preview.tooltip.delete" = "Excluir anotação selecionada"; + +/* Accessibility Labels */ +"preview.accessibility.save" = "Salvar screenshot"; +"preview.accessibility.saving" = "Salvando screenshot"; +"preview.accessibility.confirm" = "Confirmar e copiar para a área de transferência"; +"preview.accessibility.copying" = "Copiando para a área de transferência"; +"preview.accessibility.hint.commandS" = "Command S"; +"preview.accessibility.hint.enter" = "Tecla Enter"; + +/* Shape Toggle */ +"preview.shape.filled" = "Preenchido"; +"preview.shape.hollow" = "Vazado"; +"preview.shape.toggle.hint" = "Clique para alternar entre preenchido e vazado"; + + +/* ======================================== + Annotation Tools + ======================================== */ + +"tool.rectangle" = "Retângulo"; +"tool.freehand" = "Mão Livre"; +"tool.text" = "Texto"; +"tool.arrow" = "Seta"; +"tool.ellipse" = "Elipse"; +"tool.line" = "Linha"; +"tool.highlight" = "Destaque"; +"tool.mosaic" = "Mosaico"; +"tool.numberLabel" = "Rótulo de Número"; + + +/* ======================================== + Colors + ======================================== */ + +"color.red" = "Vermelho"; +"color.orange" = "Laranja"; +"color.yellow" = "Amarelo"; +"color.green" = "Verde"; +"color.blue" = "Azul"; +"color.purple" = "Roxo"; +"color.pink" = "Rosa"; +"color.white" = "Branco"; +"color.black" = "Preto"; +"color.custom" = "Personalizado"; + + +/* ======================================== + Actions + ======================================== */ + +"action.save" = "Salvar"; +"action.copy" = "Copiar"; +"action.cancel" = "Cancelar"; +"action.undo" = "Desfazer"; +"action.redo" = "Refazer"; +"action.delete" = "Excluir"; +"action.clear" = "Limpar"; +"action.reset" = "Redefinir"; +"action.close" = "Fechar"; +"action.done" = "Concluído"; + +/* Buttons */ +"button.ok" = "OK"; +"button.cancel" = "Cancelar"; +"button.clear" = "Limpar"; +"button.reset" = "Redefinir"; +"button.save" = "Salvar"; +"button.delete" = "Excluir"; +"button.confirm" = "Confirmar"; + +/* Save Success */ +"save.success.title" = "Salvo com Sucesso"; +"save.success.message" = "Salvo em %@"; +"save.with.translations.message" = "Escolha onde salvar a imagem traduzida"; + +/* No Translations Error */ +"error.no.translations" = "Nenhuma tradução disponível. Traduza o texto primeiro."; + +/* Copy Success */ +"copy.success.message" = "Copiado para a área de transferência"; + + +/* ======================================== + Settings Window + ======================================== */ + +"settings.window.title" = "Configurações do ScreenTranslate"; +"settings.title" = "Configurações do ScreenTranslate"; + +/* Settings Tabs/Sections */ +"settings.section.permissions" = "Permissões"; +"settings.section.general" = "Geral"; +"settings.section.engines" = "Motores"; +"settings.section.prompts" = "Configuração de Prompt"; +"settings.section.languages" = "Idiomas"; +"settings.section.export" = "Exportar"; +"settings.section.shortcuts" = "Atalhos de Teclado"; +"settings.section.text.translation" = "Tradução de Texto"; +"settings.section.annotations" = "Anotações"; + +/* Language Settings */ +"settings.language" = "Idioma"; +"settings.language.system" = "Padrão do Sistema"; +"settings.language.restart.hint" = "Algumas alterações podem exigir reinicialização"; + +/* Permissions */ +"settings.permission.screen.recording" = "Gravação de Tela"; +"settings.permission.screen.recording.hint" = "Necessário para capturar screenshots"; +"settings.permission.accessibility" = "Acessibilidade"; +"settings.permission.accessibility.hint" = "Necessário para atalhos globais"; +"settings.permission.granted" = "Concedida"; +"settings.permission.not.granted" = "Não Concedida"; +"settings.permission.grant" = "Conceder Acesso"; +"settings.permission.authorization.title" = "Autorização Necessária"; +"settings.permission.authorization.cancel" = "Cancelar"; +"settings.permission.authorization.go" = "Autorizar"; +"settings.permission.authorization.screen.message" = "Permissão de gravação de tela é necessária. Clique em 'Autorizar' para abrir as Configurações do Sistema e ativar a ScreenCapture para este aplicativo."; +"settings.permission.authorization.accessibility.message" = "Permissão de acessibilidade é necessária. Clique em 'Autorizar' para abrir as Configurações do Sistema e adicionar este aplicativo à lista de acessibilidade."; + +/* Save Location */ +"settings.save.location" = "Local de Salvamento"; +"settings.save.location.choose" = "Escolher..."; +"settings.save.location.select" = "Selecionar"; +"settings.save.location.message" = "Escolha o local padrão para salvar screenshots"; +"settings.save.location.reveal" = "Mostrar no Finder"; + +/* Export Format */ +"settings.format" = "Formato Padrão"; +"settings.format.png" = "PNG"; +"settings.format.jpeg" = "JPEG"; +"settings.format.heic" = "HEIC"; +"settings.jpeg.quality" = "Qualidade JPEG"; +"settings.jpeg.quality.hint" = "Maior qualidade resulta em arquivos maiores"; +"settings.heic.quality" = "Qualidade HEIC"; +"settings.heic.quality.hint" = "HEIC oferece melhor compressão"; + +/* Keyboard Shortcuts */ +"settings.shortcuts" = "Atalhos de Teclado"; +"settings.shortcut.fullscreen" = "Captura de Tela Cheia"; +"settings.shortcut.selection" = "Captura de Seleção"; +"settings.shortcut.translation.mode" = "Modo de Tradução"; +"settings.shortcut.text.selection.translation" = "Tradução de Seleção de Texto"; +"settings.shortcut.translate.and.insert" = "Traduzir e Inserir"; +"settings.shortcut.recording" = "Pressione as teclas..."; +"settings.shortcut.reset" = "Restaurar padrão"; +"settings.shortcut.error.no.modifier" = "Atalhos devem incluir Command, Control ou Option"; +"settings.shortcut.error.conflict" = "Este atalho já está em uso"; + +/* Annotations */ +"settings.annotations" = "Padrões de Anotação"; +"settings.stroke.color" = "Cor do Traço"; +"settings.stroke.width" = "Largura do Traço"; +"settings.text.size" = "Tamanho do Texto"; +"settings.mosaic.blockSize" = "Tamanho do Bloco de Mosaico"; + +/* Engines */ +"settings.ocr.engine" = "Motor OCR"; +"settings.translation.engine" = "Motor de Tradução"; +"settings.translation.mode" = "Modo de Tradução"; + +/* Reset */ +"settings.reset.all" = "Restaurar Tudo para o Padrão"; + +/* Errors */ +"settings.error.title" = "Erro"; +"settings.error.ok" = "OK"; + + +/* ======================================== + OCR Engines + ======================================== */ + +"ocr.engine.vision" = "Apple Vision"; +"ocr.engine.vision.description" = "Framework Vision macOS integrado, rápido e privado"; +"ocr.engine.paddleocr" = "PaddleOCR"; +"ocr.engine.paddleocr.description" = "Servidor OCR auto-hospedado para melhor precisão"; + + +/* ======================================== + Translation Engines + ======================================== */ + +"translation.engine.apple" = "Tradução Apple"; +"translation.engine.apple.description" = "Tradução macOS integrada, sem configuração necessária"; +"translation.engine.mtran" = "MTranServer"; +"translation.engine.mtran.description" = "Servidor de tradução auto-hospedado"; + +/* New Translation Engines */ +"translation.engine.openai" = "OpenAI"; +"translation.engine.openai.description" = "Tradução GPT-4 via API OpenAI"; +"translation.engine.claude" = "Claude"; +"translation.engine.claude.description" = "Tradução Claude via API Anthropic"; +"translation.engine.gemini" = "Gemini"; +"translation.engine.gemini.description" = "Tradução Gemini via API Google AI"; +"translation.engine.ollama" = "Ollama"; +"translation.engine.ollama.description" = "Tradução LLM local via Ollama"; +"translation.engine.google" = "Google Translate"; +"translation.engine.google.description" = "API de Tradução Google Cloud"; +"translation.engine.deepl" = "DeepL"; +"translation.engine.deepl.description" = "Tradução de alta qualidade via API DeepL"; +"translation.engine.baidu" = "Baidu Translate"; +"translation.engine.baidu.description" = "API de Tradução Baidu"; +"translation.engine.custom" = "Compatível com OpenAI"; +"translation.engine.custom.description" = "Endpoint personalizado compatível com OpenAI"; + +/* Engine Categories */ +"engine.category.builtin" = "Integrado"; +"engine.category.llm" = "Tradução LLM"; +"engine.category.cloud" = "Serviços em Nuvem"; +"engine.category.compatible" = "Compatível"; + +/* Engine Configuration Title */ +"engine.config.title" = "Configuração do Motor de Tradução"; + +/* Engine Selection Modes */ +"engine.selection.mode.title" = "Modo de Seleção do Motor"; +"engine.selection.mode.primary_fallback" = "Primário/Reserva"; +"engine.selection.mode.primary_fallback.description" = "Usar motor primário, voltar para secundário em caso de falha"; +"engine.selection.mode.parallel" = "Paralelo"; +"engine.selection.mode.parallel.description" = "Executar múltiplos motores simultaneamente e comparar resultados"; +"engine.selection.mode.quick_switch" = "Troca Rápida"; +"engine.selection.mode.quick_switch.description" = "Começar com primário, trocar rapidamente para outros motores sob demanda"; +"engine.selection.mode.scene_binding" = "Vinculação de Cena"; +"engine.selection.mode.scene_binding.description" = "Usar diferentes motores para diferentes cenários de tradução"; + +/* Mode-specific labels */ +"engine.config.primary" = "Primário"; +"engine.config.fallback" = "Reserva"; +"engine.config.switch.order" = "Ordem de Troca"; +"engine.config.parallel.select" = "Selecione motores para executar em paralelo"; +"engine.config.replace" = "Substituir motor"; +"engine.config.remove" = "Remover"; +"engine.config.add" = "Adicionar Motor"; + +/* Translation Scenes */ +"translation.scene.screenshot" = "Tradução de Screenshot"; +"translation.scene.screenshot.description" = "OCR e traduzir regiões de screenshot capturadas"; +"translation.scene.text_selection" = "Tradução de Seleção de Texto"; +"translation.scene.text_selection.description" = "Traduzir texto selecionado de qualquer aplicativo"; +"translation.scene.translate_and_insert" = "Traduzir e Inserir"; +"translation.scene.translate_and_insert.description" = "Traduzir texto da área de transferência e inserir no cursor"; + +/* Engine Configuration */ +"engine.config.enabled" = "Ativar este motor"; +"engine.config.apiKey" = "Chave API"; +"engine.config.apiKey.placeholder" = "Digite sua chave API"; +"engine.config.getApiKey" = "Obter Chave API"; +"engine.config.baseURL" = "URL Base"; +"engine.config.model" = "Nome do Modelo"; +"engine.config.test" = "Testar Conexão"; +"engine.config.test.success" = "Conexão bem-sucedida"; +"engine.config.test.failed" = "Falha na conexão"; +"engine.config.baidu.credentials" = "Credenciais Baidu"; +"engine.config.baidu.appID" = "ID do App"; +"engine.config.baidu.secretKey" = "Chave Secreta"; +"engine.config.mtran.url" = "URL do Servidor"; + +/* Engine Status */ +"engine.status.configured" = "Configurado"; +"engine.status.unconfigured" = "Não configurado"; +"engine.available.title" = "Motores Disponíveis"; +"engine.parallel.title" = "Motores Paralelos"; +"engine.parallel.description" = "Selecione motores para executar simultaneamente no modo paralelo"; +"engine.scene.binding.title" = "Vinculação de Motor de Cena"; +"engine.scene.binding.description" = "Configure qual motor usar para cada cenário de tradução"; +"engine.scene.fallback.tooltip" = "Ativar reserva para outros motores"; + +/* Keychain Errors */ +"keychain.error.item_not_found" = "Credenciais não encontradas no Keychain"; +"keychain.error.item_not_found.recovery" = "Configure suas credenciais API nas Configurações"; +"keychain.error.duplicate_item" = "Credenciais já existem no Keychain"; +"keychain.error.duplicate_item.recovery" = "Tente excluir as credenciais existentes primeiro"; +"keychain.error.invalid_data" = "Formato de dados de credencial inválido"; +"keychain.error.invalid_data.recovery" = "Tente reentrar suas credenciais"; +"keychain.error.unexpected_status" = "Operação do Keychain falhou"; +"keychain.error.unexpected_status.recovery" = "Verifique suas permissões de acesso ao Keychain"; + +/* Multi-Engine Errors */ +"multiengine.error.all_failed" = "Todos os motores de tradução falharam"; +"multiengine.error.no_engines" = "Nenhum motor de tradução configurado"; +"multiengine.error.primary_unavailable" = "Motor primário %@ não está disponível"; +"multiengine.error.no_results" = "Nenhum resultado de tradução disponível"; + +/* Registry Errors */ +"registry.error.already_registered" = "Provedor já registrado"; +"registry.error.not_registered" = "Nenhum provedor registrado para %@"; +"registry.error.config_missing" = "Configuração ausente para %@"; +"registry.error.credentials_not_found" = "Credenciais não encontradas para %@"; + +/* Prompt Configuration */ +"prompt.engine.title" = "Prompts do Motor"; +"prompt.engine.description" = "Personalize prompts de tradução para cada motor LLM"; +"prompt.scene.title" = "Prompts de Cena"; +"prompt.scene.description" = "Personalize prompts de tradução para cada cenário de tradução"; +"prompt.default.title" = "Modelo de Prompt Padrão"; +"prompt.default.description" = "Este modelo é usado quando nenhum prompt personalizado está configurado"; +"prompt.button.edit" = "Editar"; +"prompt.button.reset" = "Redefinir"; +"prompt.editor.title" = "Editar Prompt"; +"prompt.editor.variables" = "Variáveis Disponíveis:"; +"prompt.variable.source_language" = "Nome do idioma de origem"; +"prompt.variable.target_language" = "Nome do idioma alvo"; +"prompt.variable.text" = "Texto para traduzir"; + + +/* ======================================== + Translation Modes + ======================================== */ + +"translation.mode.inline" = "Substituição no Local"; +"translation.mode.inline.description" = "Substituir texto original pela tradução"; +"translation.mode.below" = "Abaixo do Original"; +"translation.mode.below.description" = "Mostrar tradução abaixo do texto original"; + + +/* ======================================== + Translation Settings + ======================================== */ + +"translation.auto" = "Detecção Automática"; +"translation.auto.detected" = "Detectado Automaticamente"; +"translation.language.follow.system" = "Seguir Sistema"; +"translation.language.source" = "Idioma de Origem"; +"translation.language.target" = "Idioma Alvo"; +"translation.language.source.hint" = "O idioma do texto que você deseja traduzir"; +"translation.language.target.hint" = "O idioma para traduzir o texto"; + + +/* ======================================== + History View + ======================================== */ + +"history.title" = "Histórico de Tradução"; +"history.search.placeholder" = "Pesquisar histórico..."; +"history.clear.all" = "Limpar todo o histórico"; +"history.empty.title" = "Nenhum Histórico de Tradução"; +"history.empty.message" = "Seus screenshots traduzidos aparecerão aqui"; +"history.no.results.title" = "Nenhum Resultado"; +"history.no.results.message" = "Nenhuma entrada corresponde à sua pesquisa"; +"history.clear.search" = "Limpar Pesquisa"; + +"history.source" = "Origem"; +"history.translation" = "Tradução"; +"history.truncated" = "truncado"; + +"history.copy.translation" = "Copiar Tradução"; +"history.copy.source" = "Copiar Origem"; +"history.copy.both" = "Copiar Ambos"; +"history.delete" = "Excluir"; + +"history.clear.alert.title" = "Limpar Histórico"; +"history.clear.alert.message" = "Tem certeza de que deseja excluir todo o histórico de tradução? Esta ação não pode ser desfeita."; + + +/* ======================================== + Permission Prompt + ======================================== */ + +"permission.prompt.title" = "Permissão de Gravação de Tela Necessária"; +"permission.prompt.message" = "O ScreenTranslate precisa de permissão para capturar sua tela. Isso é necessário para tirar screenshots. + +Depois de clicar em Continuar, o macOS pedirá que você conceda permissão de Gravação de Tela. Você pode conceder em Configurações do Sistema > Privacidade e Segurança > Gravação de Tela."; +"permission.prompt.continue" = "Continuar"; +"permission.prompt.later" = "Mais Tarde"; + +/* Accessibility Permission */ +"permission.accessibility.title" = "Permissão de Acessibilidade Necessária"; +"permission.accessibility.message" = "O ScreenTranslate precisa de permissão de acessibilidade para capturar texto selecionado e inserir traduções. + +Isso permite que o aplicativo: +• Copie texto selecionado de qualquer aplicativo +• Insira texto traduzido em campos de entrada + +Sua privacidade está protegida - o ScreenTranslate usa isso apenas para tradução de texto."; +"permission.accessibility.grant" = "Conceder Permissão"; +"permission.accessibility.open.settings" = "Abrir Configurações do Sistema"; +"permission.accessibility.denied.title" = "Permissão de Acessibilidade Necessária"; +"permission.accessibility.denied.message" = "A captura e inserção de texto requer permissão de acessibilidade. + +Conceda permissão em Configurações do Sistema > Privacidade e Segurança > Acessibilidade."; + +/* Input Monitoring Permission */ +"permission.input.monitoring.title" = "Permissão de Monitoramento de Entrada Necessária"; +"permission.input.monitoring.message" = "O ScreenTranslate precisa de permissão de monitoramento de entrada para inserir texto traduzido em aplicativos. + +Você precisará ativar isso em: +Configurações do Sistema > Privacidade e Segurança > Monitoramento de Entrada"; +"permission.input.monitoring.open.settings" = "Abrir Configurações do Sistema"; +"permission.input.monitoring.denied.title" = "Permissão de Monitoramento de Entrada Necessária"; +"permission.input.monitoring.denied.message" = "A inserção de texto requer permissão de monitoramento de entrada. + +Conceda permissão em Configurações do Sistema > Privacidade e Segurança > Monitoramento de Entrada."; + +/* Common Permission Strings */ +"permission.open.settings" = "Abrir Configurações do Sistema"; + + +/* ======================================== + Onboarding + ======================================== */ + +"onboarding.window.title" = "Bem-vindo ao ScreenTranslate"; + +/* Onboarding - Welcome Step */ +"onboarding.welcome.title" = "Bem-vindo ao ScreenTranslate"; +"onboarding.welcome.message" = "Vamos configurar os recursos de captura de tela e tradução. Isso levará apenas um minuto."; + +"onboarding.feature.local.ocr.title" = "OCR Local"; +"onboarding.feature.local.ocr.description" = "Framework Vision macOS para reconhecimento de texto rápido e privado"; +"onboarding.feature.local.translation.title" = "Tradução Local"; +"onboarding.feature.local.translation.description" = "Tradução Apple para tradução instantânea offline"; +"onboarding.feature.shortcuts.title" = "Atalhos Globais"; +"onboarding.feature.shortcuts.description" = "Capture e traduza de qualquer lugar com atalhos de teclado"; + +/* Onboarding - Permissions Step */ +"onboarding.permissions.title" = "Permissões"; +"onboarding.permissions.message" = "O ScreenTranslate precisa de algumas permissões para funcionar corretamente. Conceda as seguintes permissões:"; +"onboarding.permissions.hint" = "Depois de conceder as permissões, o status será atualizado automaticamente."; + +"onboarding.permission.screen.recording" = "Gravação de Tela"; +"onboarding.permission.accessibility" = "Acessibilidade"; +"onboarding.permission.granted" = "Concedida"; +"onboarding.permission.not.granted" = "Não Concedida"; +"onboarding.permission.grant" = "Conceder Permissão"; + +/* Onboarding - Configuration Step */ +"onboarding.configuration.title" = "Configuração Opcional"; +"onboarding.configuration.message" = "Seus recursos locais de OCR e tradução já estão ativados. Opcionalmente configure serviços externos:"; +"onboarding.configuration.paddleocr" = "Endereço do Servidor PaddleOCR"; +"onboarding.configuration.paddleocr.hint" = "Deixe em branco para usar o OCR Vision macOS"; +"onboarding.configuration.mtran" = "Endereço MTranServer"; +"onboarding.configuration.mtran.hint" = "Deixe em branco para usar a Tradução Apple"; +"onboarding.configuration.placeholder" = "http://localhost:8080"; +"onboarding.configuration.placeholder.address" = "localhost"; +"onboarding.configuration.test" = "Testar Tradução"; +"onboarding.configuration.test.button" = "Testar Tradução"; +"onboarding.configuration.testing" = "Testando..."; +"onboarding.test.success" = "Teste de tradução bem-sucedido: \"%@\" → \"%@\""; +"onboarding.test.failed" = "Teste de tradução falhou: %@"; + +/* Onboarding - Complete Step */ +"onboarding.complete.title" = "Tudo Pronto!"; +"onboarding.complete.message" = "O ScreenTranslate está pronto para usar. Aqui está como começar:"; +"onboarding.complete.shortcuts" = "Use ⌘⇧F para capturar a tela cheia"; +"onboarding.complete.selection" = "Use ⌘⇧A para capturar uma seleção e traduzir"; +"onboarding.complete.settings" = "Abra as Configurações na barra de menus para personalizar opções"; +"onboarding.complete.start" = "Começar a Usar o ScreenTranslate"; + +/* Onboarding - Navigation */ +"onboarding.back" = "Voltar"; +"onboarding.continue" = "Continuar"; +"onboarding.next" = "Próximo"; +"onboarding.skip" = "Pular"; +"onboarding.complete" = "Concluir"; + +/* Onboarding - PaddleOCR */ +"onboarding.paddleocr.title" = "PaddleOCR (Opcional)"; +"onboarding.paddleocr.description" = "Motor OCR aprimorado para melhor precisão de reconhecimento de texto, especialmente para chinês."; +"onboarding.paddleocr.installed" = "Instalado"; +"onboarding.paddleocr.not.installed" = "Não Instalado"; +"onboarding.paddleocr.install" = "Instalar"; +"onboarding.paddleocr.installing" = "Instalando..."; +"onboarding.paddleocr.install.hint" = "Requer Python 3 e pip. Execute: pip3 install paddleocr paddlepaddle"; +"onboarding.paddleocr.copy.command" = "Copiar Comando"; +"onboarding.paddleocr.refresh" = "Atualizar Status"; +"onboarding.paddleocr.version" = "Versão: %@"; + +/* Settings - PaddleOCR */ +"settings.paddleocr.installed" = "Instalado"; +"settings.paddleocr.not.installed" = "Não Instalado"; +"settings.paddleocr.install" = "Instalar"; +"settings.paddleocr.installing" = "Instalando..."; +"settings.paddleocr.install.hint" = "Requer Python 3 e pip instalados no seu sistema."; +"settings.paddleocr.copy.command" = "Copiar Comando"; +"settings.paddleocr.refresh" = "Atualizar Status"; +"settings.paddleocr.ready" = "PaddleOCR está pronto"; +"settings.paddleocr.not.installed.message" = "PaddleOCR não está instalado"; +"settings.paddleocr.description" = "PaddleOCR é um motor OCR local. É gratuito, funciona offline e não requer chave API."; +"settings.paddleocr.install.button" = "Instalar PaddleOCR"; +"settings.paddleocr.copy.command.button" = "Copiar Comando de Instalação"; +"settings.paddleocr.mode" = "Modo"; +"settings.paddleocr.mode.fast" = "Rápido"; +"settings.paddleocr.mode.precise" = "Preciso"; +"settings.paddleocr.mode.fast.description" = "~1s, OCR rápido com agrupamento de linhas"; +"settings.paddleocr.mode.precise.description" = "~12s, modelo VL-1.5 com maior precisão"; +"settings.paddleocr.useCloud" = "Usar API em Nuvem"; +"settings.paddleocr.cloudBaseURL" = "URL da API em Nuvem"; +"settings.paddleocr.cloudAPIKey" = "Chave API"; +"settings.paddleocr.cloudModelId" = "ID do Modelo"; +"settings.paddleocr.localVLModelDir" = "Diretório do Modelo Local (vllm)"; +"settings.paddleocr.localVLModelDir.hint" = "Caminho para o modelo PaddleOCR-VL local (ex: ~/.paddlex/official_models/PaddleOCR-VL-1.5)"; +"error.paddleocr.notInstalled" = "PaddleOCR não está instalado. Instale usando: pip3 install paddleocr paddlepaddle"; + + +/* ======================================== + VLM Configuration + ======================================== */ + +"settings.vlm.title" = "Configuração VLM"; +"settings.vlm.provider" = "Provedor"; +"settings.vlm.apiKey" = "Chave API"; +"settings.vlm.apiKey.optional" = "Chave API é opcional para provedores locais"; +"settings.vlm.baseURL" = "URL Base"; +"settings.vlm.model" = "Nome do Modelo"; +"settings.vlm.test.button" = "Testar Conexão"; +"settings.vlm.test.success" = "Conexão bem-sucedida! Modelo: %@"; +"settings.vlm.test.ollama.success" = "Servidor em execução. Modelo '%@' disponível"; +"settings.vlm.test.ollama.available" = "Servidor em execução. Disponível: %@"; + +"vlm.provider.openai" = "OpenAI"; +"vlm.provider.claude" = "Claude"; +"vlm.provider.glmocr" = "GLM OCR"; +"vlm.provider.ollama" = "Ollama"; +"vlm.provider.paddleocr" = "PaddleOCR"; +"vlm.provider.openai.description" = "API OpenAI GPT-4 Vision"; +"vlm.provider.claude.description" = "API Anthropic Claude Vision"; +"vlm.provider.glmocr.description" = "API de análise de layout Zhipu GLM-OCR"; +"vlm.provider.ollama.description" = "Servidor Ollama local"; +"vlm.provider.paddleocr.description" = "Motor OCR local (gratuito, offline)"; + + +/* ======================================== + Translation Workflow Configuration + ======================================== */ + +"settings.translation.workflow.title" = "Motor de Tradução"; +"settings.translation.preferred" = "Motor Preferido"; +"settings.translation.mtran.url" = "URL MTransServer"; +"settings.translation.mtran.test.button" = "Testar Conexão"; +"settings.translation.mtran.test.success" = "Conexão bem-sucedida"; +"settings.translation.mtran.test.failed" = "Falha na conexão: %@"; +"settings.translation.fallback" = "Reserva"; +"settings.translation.fallback.description" = "Usar Tradução Apple como reserva quando o motor preferido falhar"; + +"translation.preferred.apple.description" = "Tradução macOS integrada, funciona offline"; +"translation.preferred.mtran.description" = "Servidor de tradução auto-hospedado para melhor qualidade"; + + +/* ======================================== + Accessibility Labels + ======================================== */ + +"accessibility.close.button" = "Fechar"; +"accessibility.settings.button" = "Configurações"; +"accessibility.capture.button" = "Capturar"; +"accessibility.translate.button" = "Traduzir"; + + +/* ======================================== + Bilingual Result Window + ======================================== */ + +/* ======================================== + Translation Flow + ======================================== */ + +"translationFlow.phase.idle" = "Pronto"; +"translationFlow.phase.analyzing" = "Analisando imagem..."; +"translationFlow.phase.translating" = "Traduzindo..."; +"translationFlow.phase.rendering" = "Renderizando..."; +"translationFlow.phase.completed" = "Concluído"; +"translationFlow.phase.failed" = "Falhou"; + +"translationFlow.error.title" = "Erro de Tradução"; +"translationFlow.error.title.analysis" = "Falha no Reconhecimento de Imagem"; +"translationFlow.error.title.translation" = "Falha na Tradução"; +"translationFlow.error.title.rendering" = "Falha na Renderização"; +"translationFlow.error.unknown" = "Ocorreu um erro desconhecido."; +"translationFlow.error.analysis" = "Falha na análise: %@"; +"translationFlow.error.translation" = "Falha na tradução: %@"; +"translationFlow.error.rendering" = "Falha na renderização: %@"; +"translationFlow.error.cancelled" = "Tradução foi cancelada."; +"translationFlow.error.noTextFound" = "Nenhum texto encontrado na área selecionada."; +"translationFlow.error.translation.engine" = "Motor de Tradução"; + +"translationFlow.recovery.analysis" = "Tente novamente com uma imagem mais clara ou verifique as configurações do provedor VLM."; +"translationFlow.recovery.translation" = "Verifique as configurações do motor de tradução e conexão de rede, depois tente novamente."; +"translationFlow.recovery.rendering" = "Tente novamente."; +"translationFlow.recovery.noTextFound" = "Tente selecionar uma área com texto visível."; + +"common.ok" = "OK"; + + +/* ======================================== + Bilingual Result Window + ======================================== */ + +"bilingualResult.window.title" = "Tradução Bilíngue"; +"bilingualResult.loading" = "Traduzindo..."; +"bilingualResult.loading.analyzing" = "Analisando imagem..."; +"bilingualResult.loading.translating" = "Traduzindo texto..."; +"bilingualResult.loading.rendering" = "Renderizando resultado..."; +"bilingualResult.copyImage" = "Copiar Imagem"; +"bilingualResult.copyText" = "Copiar Texto"; +"bilingualResult.save" = "Salvar"; +"bilingualResult.zoomIn" = "Ampliar"; +"bilingualResult.zoomOut" = "Reduzir"; +"bilingualResult.resetZoom" = "Redefinir Zoom"; +"bilingualResult.copySuccess" = "Copiado para a área de transferência"; +"bilingualResult.copyTextSuccess" = "Texto de tradução copiado"; +"bilingualResult.saveSuccess" = "Salvo com sucesso"; +"bilingualResult.copyFailed" = "Falha ao copiar imagem"; +"bilingualResult.saveFailed" = "Falha ao salvar imagem"; +"bilingualResult.noTextToCopy" = "Nenhum texto de tradução para copiar"; + + +/* ======================================== + Text Translation (US-003 to US-010) + ======================================== */ + +/* Text Translation Flow */ +"textTranslation.phase.idle" = "Pronto"; +"textTranslation.phase.translating" = "Traduzindo..."; +"textTranslation.phase.completed" = "Concluído"; +"textTranslation.phase.failed" = "Falhou"; + +"textTranslation.error.emptyInput" = "Nenhum texto para traduzir"; +"textTranslation.error.translationFailed" = "Falha na tradução: %@"; +"textTranslation.error.cancelled" = "Tradução foi cancelada"; +"textTranslation.error.serviceUnavailable" = "Serviço de tradução indisponível"; +"textTranslation.error.insertFailed" = "Falha ao inserir texto traduzido"; + +"textTranslation.recovery.emptyInput" = "Selecione algum texto primeiro"; +"textTranslation.recovery.translationFailed" = "Tente novamente"; +"textTranslation.recovery.serviceUnavailable" = "Verifique sua conexão de rede e tente novamente"; + +"textTranslation.loading" = "Traduzindo..."; +"textTranslation.noSelection.title" = "Nenhum Texto Selecionado"; +"textTranslation.noSelection.message" = "Selecione algum texto em qualquer aplicativo e tente novamente."; + +/* Translate and Insert */ +"translateAndInsert.emptyClipboard.title" = "Área de Transferência Vazia"; +"translateAndInsert.emptyClipboard.message" = "Copie algum texto para a área de transferência primeiro, depois use este atalho."; +"translateAndInsert.success.title" = "Tradução Inserida"; +"translateAndInsert.success.message" = "O texto traduzido foi inserido no campo de entrada focado."; + +/* Language Display */ +"language.auto" = "Detectado Automaticamente"; + +/* Common Text Translation UI */ +"common.copy" = "Copiar"; +"common.copied" = "Copiado"; +"common.insert" = "Inserir"; + +/* Text Translation Window */ +"textTranslation.window.title" = "Tradução de Texto"; + +/* Translate and Insert Language Settings */ +"settings.translateAndInsert.language.section" = "Idiomas de Tradução e Inserção"; +"settings.translateAndInsert.language.source" = "Idioma de Origem"; +"settings.translateAndInsert.language.target" = "Idioma Alvo"; + + +/* ======================================== + Multi OpenAI Compatible Engines + ======================================== */ + +/* Compatible Engine Configuration */ +"engine.compatible.new" = "Novo Motor Compatível"; +"engine.compatible.description" = "Endpoint API compatível com OpenAI"; +"engine.compatible.displayName" = "Nome de Exibição"; +"engine.compatible.displayName.placeholder" = "ex: Meu Servidor LLM"; +"engine.compatible.requireApiKey" = "Requer Chave API"; +"engine.compatible.add" = "Adicionar Motor Compatível"; +"engine.compatible.delete" = "Excluir este motor"; +"engine.compatible.useAsEngine" = "Usar como motor de tradução"; +"engine.compatible.max.reached" = "Máximo de 5 motores compatíveis atingido"; + +/* Prompt Configuration */ +"prompt.compatible.title" = "Motores Compatíveis"; + + +/* ======================================== + About Window + ======================================== */ + +"about.title" = "Sobre o ScreenTranslate"; +"about.app.name" = "ScreenTranslate"; +"about.version.format" = "Versão %@ (%@)"; +"about.copyright" = "Direitos Autorais"; +"about.copyright.value" = "© 2026 Todos os direitos reservados"; +"about.license" = "Licença"; +"about.license.value" = "Licença MIT"; +"about.github.link" = "GitHub: hubo1989/ScreenTranslate"; +"about.check.for.updates" = "Verificar Atualizações"; +"about.update.checking" = "Verificando..."; +"about.update.available" = "Atualização disponível"; +"about.update.uptodate" = "Você está atualizado"; +"about.update.failed" = "Verificação falhou"; +"about.acknowledgements" = "Agradecimentos"; +"about.acknowledgements.title" = "Agradecimentos"; +"about.acknowledgements.intro" = "Este software usa as seguintes bibliotecas de código aberto:"; +"about.acknowledgements.upstream" = "Baseado em"; +"about.acknowledgements.author.format" = "por %@"; +"about.close" = "Fechar"; +"settings.glmocr.mode" = "Modo"; +"settings.glmocr.mode.cloud" = "Nuvem"; +"settings.glmocr.mode.local" = "Local"; +"settings.glmocr.local.apiKey.optional" = "A chave API é opcional para servidores MLX-VLM locais"; +"vlm.provider.glmocr.local.description" = "Servidor MLX-VLM local para GLM-OCR"; diff --git a/ScreenTranslate/Resources/ru.lproj/Localizable.strings b/ScreenTranslate/Resources/ru.lproj/Localizable.strings new file mode 100644 index 0000000..5b1c8d8 --- /dev/null +++ b/ScreenTranslate/Resources/ru.lproj/Localizable.strings @@ -0,0 +1,823 @@ +/* + Localizable.strings (Русский) + ScreenTranslate +*/ + +/* ======================================== + Сообщения об ошибках + ======================================== */ + +/* Ошибки разрешений */ +"error.permission.denied" = "Требуется разрешение на запись экрана для создания скриншотов."; +"error.permission.denied.recovery" = "Откройте Системные настройки для предоставления разрешения."; + +/* Ошибки дисплея */ +"error.display.not.found" = "Выбранный дисплей больше не доступен."; +"error.display.not.found.recovery" = "Выберите другой дисплей."; +"error.display.disconnected" = "Дисплей '%@' был отключён во время захвата."; +"error.display.disconnected.recovery" = "Подключите дисплей повторно и попробуйте снова."; + +/* Ошибки захвата */ +"error.capture.failed" = "Не удалось захватить экран."; +"error.capture.failed.recovery" = "Попробуйте снова."; + +/* Ошибки сохранения */ +"error.save.location.invalid" = "Место сохранения недоступно."; +"error.save.location.invalid.recovery" = "Выберите другое место сохранения в Настройках."; +"error.save.location.invalid.detail" = "Невозможно сохранить в %@. Место недоступно."; +"error.save.unknown" = "Произошла непредвиденная ошибка при сохранении."; +"error.disk.full" = "Недостаточно места на диске для сохранения скриншота."; +"error.disk.full.recovery" = "Освободите место на диске и попробуйте снова."; + +/* Ошибки экспорта */ +"error.export.encoding.failed" = "Не удалось закодировать изображение."; +"error.export.encoding.failed.recovery" = "Попробуйте другой формат в Настройках."; +"error.export.encoding.failed.detail" = "Не удалось закодировать изображение в формат %@."; + +/* Ошибки буфера обмена */ +"error.clipboard.write.failed" = "Не удалось скопировать скриншот в буфер обмена."; +"error.clipboard.write.failed.recovery" = "Попробуйте снова."; + +/* Ошибки горячих клавиш */ +"error.hotkey.registration.failed" = "Не удалось зарегистрировать сочетание клавиш."; +"error.hotkey.registration.failed.recovery" = "Сочетание может конфликтовать с другим приложением. Попробуйте другое."; +"error.hotkey.conflict" = "Это сочетание клавиш конфликтует с другим приложением."; +"error.hotkey.conflict.recovery" = "Выберите другое сочетание клавиш."; + +/* Ошибки OCR */ +"error.ocr.failed" = "Распознавание текста не удалось."; +"error.ocr.failed.recovery" = "Попробуйте снова с более чётким изображением."; +"error.ocr.no.text" = "Текст на изображении не распознан."; +"error.ocr.no.text.recovery" = "Попробуйте захватить область с видимым текстом."; +"error.ocr.cancelled" = "Распознавание текста отменено."; +"error.ocr.server.unreachable" = "Не удаётся подключиться к серверу OCR."; +"error.ocr.server.unreachable.recovery" = "Проверьте адрес сервера и сетевое подключение."; + +/* Ошибки перевода */ +"error.translation.in.progress" = "Перевод уже выполняется"; +"error.translation.in.progress.recovery" = "Подождите завершения текущего перевода"; +"error.translation.empty.input" = "Нет текста для перевода"; +"error.translation.empty.input.recovery" = "Сначала выберите текст"; +"error.translation.timeout" = "Истекло время ожидания перевода"; +"error.translation.timeout.recovery" = "Попробуйте снова"; +"error.translation.unsupported.pair" = "Перевод с %@ на %@ не поддерживается"; +"error.translation.unsupported.pair.recovery" = "Выберите другие языки"; +"error.translation.failed" = "Перевод не удался"; +"error.translation.failed.recovery" = "Попробуйте снова"; +"error.translation.language.not.installed" = "Язык перевода '%@' не установлен"; +"error.translation.language.download.instructions" = "Перейдите в Системные настройки > Основные > Язык и регион > Языки перевода, затем скачайте необходимый язык."; + +/* Общий интерфейс ошибок */ +"error.title" = "Ошибка"; +"error.ok" = "ОК"; +"error.dismiss" = "Закрыть"; +"error.retry.capture" = "Повторить"; +"error.permission.open.settings" = "Открыть настройки системы"; + + +/* ======================================== + Пункты меню + ======================================== */ + +"menu.capture.full.screen" = "Захватить весь экран"; +"menu.capture.fullscreen" = "Захватить весь экран"; +"menu.capture.selection" = "Захватить область"; +"menu.translation.mode" = "Режим перевода"; +"menu.translation.history" = "История переводов"; +"menu.settings" = "Настройки..."; +"menu.about" = "О ScreenTranslate"; +"menu.quit" = "Выйти из ScreenTranslate"; + + +/* ======================================== + Выбор дисплея + ======================================== */ + +"display.selector.title" = "Выбрать дисплей"; +"display.selector.header" = "Выберите дисплей для захвата:"; +"display.selector.cancel" = "Отмена"; + + +/* ======================================== + Окно предпросмотра + ======================================== */ + +"preview.window.title" = "Предпросмотр скриншота"; +"preview.title" = "Предпросмотр скриншота"; +"preview.dimensions" = "%d × %d пикселей"; +"preview.file.size" = "≈%@ %@"; +"preview.screenshot" = "Скриншот"; +"preview.enter.text" = "Введите текст"; +"preview.image.dimensions" = "Размер изображения"; +"preview.estimated.size" = "Оценочный размер файла"; +"preview.edit.label" = "Редактировать:"; +"preview.active.tool" = "Активный инструмент"; +"preview.crop.mode.active" = "Режим обрезки активен"; + +/* Обрезка */ +"preview.crop" = "Обрезать"; +"preview.crop.cancel" = "Отмена"; +"preview.crop.apply" = "Применить обрезку"; + +/* Распознанный текст */ +"preview.recognized.text" = "Распознанный текст:"; +"preview.translation" = "Перевод:"; +"preview.results.panel" = "Результаты текста"; +"preview.copy.text" = "Копировать текст"; + +/* Всплывающие подсказки панели инструментов */ +"preview.tooltip.crop" = "Обрезать (C)"; +"preview.tooltip.pin" = "Закрепить на экране (P)"; +"preview.tooltip.undo" = "Отменить (⌘Z)"; +"preview.tooltip.redo" = "Повторить (⌘⇧Z)"; +"preview.tooltip.copy" = "Копировать в буфер обмена (⌘C)"; +"preview.tooltip.save" = "Сохранить (⌘S)"; +"preview.tooltip.ocr" = "Распознать текст (OCR)"; +"preview.tooltip.confirm" = "Копировать в буфер и закрыть (Enter)"; +"preview.tooltip.dismiss" = "Закрыть (Escape)"; +"preview.tooltip.delete" = "Удалить выбранную аннотацию"; + +/* Метки доступности */ +"preview.accessibility.save" = "Сохранить скриншот"; +"preview.accessibility.saving" = "Сохранение скриншота"; +"preview.accessibility.confirm" = "Подтвердить и копировать в буфер"; +"preview.accessibility.copying" = "Копирование в буфер"; +"preview.accessibility.hint.commandS" = "Command S"; +"preview.accessibility.hint.enter" = "Клавиша Enter"; + +/* Переключение фигуры */ +"preview.shape.filled" = "Заполненная"; +"preview.shape.hollow" = "Контурная"; +"preview.shape.toggle.hint" = "Нажмите для переключения между заполненной и контурной"; + + +/* ======================================== + Инструменты аннотаций + ======================================== */ + +"tool.rectangle" = "Прямоугольник"; +"tool.freehand" = "Кисть"; +"tool.text" = "Текст"; +"tool.arrow" = "Стрелка"; +"tool.ellipse" = "Эллипс"; +"tool.line" = "Линия"; +"tool.highlight" = "Выделение"; +"tool.mosaic" = "Мозаика"; +"tool.numberLabel" = "Номер"; + + +/* ======================================== + Цвета + ======================================== */ + +"color.red" = "Красный"; +"color.orange" = "Оранжевый"; +"color.yellow" = "Жёлтый"; +"color.green" = "Зелёный"; +"color.blue" = "Синий"; +"color.purple" = "Фиолетовый"; +"color.pink" = "Розовый"; +"color.white" = "Белый"; +"color.black" = "Чёрный"; +"color.custom" = "Другой"; + + +/* ======================================== + Действия + ======================================== */ + +"action.save" = "Сохранить"; +"action.copy" = "Копировать"; +"action.cancel" = "Отмена"; +"action.undo" = "Отменить"; +"action.redo" = "Повторить"; +"action.delete" = "Удалить"; +"action.clear" = "Очистить"; +"action.reset" = "Сбросить"; +"action.close" = "Закрыть"; +"action.done" = "Готово"; + +/* Кнопки */ +"button.ok" = "ОК"; +"button.cancel" = "Отмена"; +"button.clear" = "Очистить"; +"button.reset" = "Сбросить"; +"button.save" = "Сохранить"; +"button.delete" = "Удалить"; +"button.confirm" = "Подтвердить"; + +/* Успешное сохранение */ +"save.success.title" = "Успешно сохранено"; +"save.success.message" = "Сохранено в %@"; +"save.with.translations.message" = "Выберите место сохранения изображения с переводом"; + +/* Ошибка отсутствия перевода */ +"error.no.translations" = "Нет доступных переводов. Сначала выполните перевод текста."; + +/* Успешное копирование */ +"copy.success.message" = "Скопировано в буфер обмена"; + + +/* ======================================== + Окно настроек + ======================================== */ + +"settings.window.title" = "Настройки ScreenTranslate"; +"settings.title" = "Настройки ScreenTranslate"; + +/* Разделы настроек */ +"settings.section.permissions" = "Разрешения"; +"settings.section.general" = "Основные"; +"settings.section.engines" = "Движки"; +"settings.section.prompts" = "Конфигурация промптов"; +"settings.section.languages" = "Языки"; +"settings.section.export" = "Экспорт"; +"settings.section.shortcuts" = "Сочетания клавиш"; +"settings.section.text.translation" = "Перевод текста"; +"settings.section.annotations" = "Аннотации"; + +/* Языковые настройки */ +"settings.language" = "Язык"; +"settings.language.system" = "Системный"; +"settings.language.restart.hint" = "Некоторые изменения могут требовать перезапуска"; + +/* Разрешения */ +"settings.permission.screen.recording" = "Запись экрана"; +"settings.permission.screen.recording.hint" = "Требуется для захвата скриншотов"; +"settings.permission.accessibility" = "Универсальный доступ"; +"settings.permission.accessibility.hint" = "Требуется для глобальных сочетаний клавиш"; +"settings.permission.granted" = "Предоставлено"; +"settings.permission.not.granted" = "Не предоставлено"; +"settings.permission.grant" = "Предоставить доступ"; +"settings.permission.authorization.title" = "Требуется авторизация"; +"settings.permission.authorization.cancel" = "Отмена"; +"settings.permission.authorization.go" = "Авторизовать"; +"settings.permission.authorization.screen.message" = "Требуется разрешение на запись экрана. Нажмите «Авторизовать» для открытия Системных настроек и включения ScreenCapture для этого приложения."; +"settings.permission.authorization.accessibility.message" = "Требуется разрешение универсального доступа. Нажмите «Авторизовать» для открытия Системных настроек и добавления этого приложения в список универсального доступа."; + +/* Место сохранения */ +"settings.save.location" = "Место сохранения"; +"settings.save.location.choose" = "Выбрать..."; +"settings.save.location.select" = "Выбрать"; +"settings.save.location.message" = "Выберите место по умолчанию для сохранения скриншотов"; +"settings.save.location.reveal" = "Показать в Finder"; + +/* Формат экспорта */ +"settings.format" = "Формат по умолчанию"; +"settings.format.png" = "PNG"; +"settings.format.jpeg" = "JPEG"; +"settings.format.heic" = "HEIC"; +"settings.jpeg.quality" = "Качество JPEG"; +"settings.jpeg.quality.hint" = "Более высокое качество увеличивает размер файла"; +"settings.heic.quality" = "Качество HEIC"; +"settings.heic.quality.hint" = "HEIC обеспечивает лучшее сжатие"; + +/* Сочетания клавиш */ +"settings.shortcuts" = "Сочетания клавиш"; +"settings.shortcut.fullscreen" = "Захват полного экрана"; +"settings.shortcut.selection" = "Захват области"; +"settings.shortcut.translation.mode" = "Режим перевода"; +"settings.shortcut.text.selection.translation" = "Перевод выбранного текста"; +"settings.shortcut.translate.and.insert" = "Перевести и вставить"; +"settings.shortcut.recording" = "Нажмите клавиши..."; +"settings.shortcut.reset" = "Сбросить по умолчанию"; +"settings.shortcut.error.no.modifier" = "Сочетания должны включать Command, Control или Option"; +"settings.shortcut.error.conflict" = "Это сочетание уже используется"; + +/* Аннотации */ +"settings.annotations" = "Параметры аннотаций по умолчанию"; +"settings.stroke.color" = "Цвет штриха"; +"settings.stroke.width" = "Толщина штриха"; +"settings.text.size" = "Размер текста"; +"settings.mosaic.blockSize" = "Размер блока мозаики"; + +/* Движки */ +"settings.ocr.engine" = "Движок OCR"; +"settings.translation.engine" = "Движок перевода"; +"settings.translation.mode" = "Режим перевода"; + +/* Сброс */ +"settings.reset.all" = "Сбросить всё по умолчанию"; + +/* Ошибки */ +"settings.error.title" = "Ошибка"; +"settings.error.ok" = "ОК"; + + +/* ======================================== + Движки OCR + ======================================== */ + +"ocr.engine.vision" = "Apple Vision"; +"ocr.engine.vision.description" = "Встроенный фреймворк macOS Vision, быстро и конфиденциально"; +"ocr.engine.paddleocr" = "PaddleOCR"; +"ocr.engine.paddleocr.description" = "Самостоятельный OCR-сервер для лучшей точности"; + + +/* ======================================== + Движки перевода + ======================================== */ + +"translation.engine.apple" = "Apple Перевод"; +"translation.engine.apple.description" = "Встроенный перевод macOS, настройка не требуется"; +"translation.engine.mtran" = "MTranServer"; +"translation.engine.mtran.description" = "Самостоятельный сервер перевода"; + +/* Новые движки перевода */ +"translation.engine.openai" = "OpenAI"; +"translation.engine.openai.description" = "Перевод через GPT-4 API OpenAI"; +"translation.engine.claude" = "Claude"; +"translation.engine.claude.description" = "Перевод через Claude API Anthropic"; +"translation.engine.gemini" = "Gemini"; +"translation.engine.gemini.description" = "Перевод через Gemini API Google AI"; +"translation.engine.ollama" = "Ollama"; +"translation.engine.ollama.description" = "Локальная LLM-перевод через Ollama"; +"translation.engine.google" = "Google Перевод"; +"translation.engine.google.description" = "API облачного перевода Google"; +"translation.engine.deepl" = "DeepL"; +"translation.engine.deepl.description" = "Высококачественный перевод через API DeepL"; +"translation.engine.baidu" = "Baidu Перевод"; +"translation.engine.baidu.description" = "API перевода Baidu"; +"translation.engine.custom" = "Совместимый с OpenAI"; +"translation.engine.custom.description" = "Пользовательская конечная точка, совместимая с OpenAI"; + +/* Категории движков */ +"engine.category.builtin" = "Встроенный"; +"engine.category.llm" = "LLM-перевод"; +"engine.category.cloud" = "Облачные сервисы"; +"engine.category.compatible" = "Совместимый"; + +/* Заголовок конфигурации движка */ +"engine.config.title" = "Конфигурация движка перевода"; + +/* Режимы выбора движка */ +"engine.selection.mode.title" = "Режим выбора движка"; +"engine.selection.mode.primary_fallback" = "Основной/резервный"; +"engine.selection.mode.primary_fallback.description" = "Использовать основной движок, переключаться на резервный при ошибке"; +"engine.selection.mode.parallel" = "Параллельный"; +"engine.selection.mode.parallel.description" = "Запускать несколько движков одновременно и сравнивать результаты"; +"engine.selection.mode.quick_switch" = "Быстрое переключение"; +"engine.selection.mode.quick_switch.description" = "Начать с основного, быстро переключаться на другие движки"; +"engine.selection.mode.scene_binding" = "Привязка к сцене"; +"engine.selection.mode.scene_binding.description" = "Использовать разные движки для разных сценариев перевода"; + +/* Метки для режимов */ +"engine.config.primary" = "Основной"; +"engine.config.fallback" = "Резервный"; +"engine.config.switch.order" = "Порядок переключения"; +"engine.config.parallel.select" = "Выберите движки для параллельного запуска"; +"engine.config.replace" = "Заменить движок"; +"engine.config.remove" = "Удалить"; +"engine.config.add" = "Добавить движок"; + +/* Сцены перевода */ +"translation.scene.screenshot" = "Перевод скриншотов"; +"translation.scene.screenshot.description" = "OCR и перевод захваченных областей скриншота"; +"translation.scene.text_selection" = "Перевод выбранного текста"; +"translation.scene.text_selection.description" = "Перевод выбранного текста из любого приложения"; +"translation.scene.translate_and_insert" = "Перевести и вставить"; +"translation.scene.translate_and_insert.description" = "Перевести текст из буфера и вставить в позицию курсора"; + +/* Конфигурация движка */ +"engine.config.enabled" = "Включить этот движок"; +"engine.config.apiKey" = "API-ключ"; +"engine.config.apiKey.placeholder" = "Введите ваш API-ключ"; +"engine.config.getApiKey" = "Получить API-ключ"; +"engine.config.baseURL" = "Базовый URL"; +"engine.config.model" = "Название модели"; +"engine.config.test" = "Проверить подключение"; +"engine.config.test.success" = "Подключение успешно"; +"engine.config.test.failed" = "Сбой подключения"; +"engine.config.baidu.credentials" = "Учётные данные Baidu"; +"engine.config.baidu.appID" = "ID приложения"; +"engine.config.baidu.secretKey" = "Секретный ключ"; +"engine.config.mtran.url" = "URL сервера"; + +/* Состояние движка */ +"engine.status.configured" = "Настроен"; +"engine.status.unconfigured" = "Не настроен"; +"engine.available.title" = "Доступные движки"; +"engine.parallel.title" = "Параллельные движки"; +"engine.parallel.description" = "Выберите движки для одновременной работы в параллельном режиме"; +"engine.scene.binding.title" = "Привязка движка к сцене"; +"engine.scene.binding.description" = "Настройте, какой движок использовать для каждого сценария перевода"; +"engine.scene.fallback.tooltip" = "Включить переключение на другие движки"; + +/* Ошибки Keychain */ +"keychain.error.item_not_found" = "Учётные данные не найдены в связке ключей"; +"keychain.error.item_not_found.recovery" = "Настройте ваши API-учётные данные в Настройках"; +"keychain.error.duplicate_item" = "Учётные данные уже существуют в связке ключей"; +"keychain.error.duplicate_item.recovery" = "Сначала удалите существующие учётные данные"; +"keychain.error.invalid_data" = "Неверный формат данных учётных данных"; +"keychain.error.invalid_data.recovery" = "Повторно введите ваши учётные данные"; +"keychain.error.unexpected_status" = "Ошибка операции связки ключей"; +"keychain.error.unexpected_status.recovery" = "Проверьте права доступа к связке ключей"; + +/* Ошибки мультим Movка */ +"multiengine.error.all_failed" = "Все движки перевода завершились ошибкой"; +"multiengine.error.no_engines" = "Не настроено ни одного движка перевода"; +"multiengine.error.primary_unavailable" = "Основной движок %@ недоступен"; +"multiengine.error.no_results" = "Нет результатов перевода"; + +/* Ошибки реестра */ +"registry.error.already_registered" = "Поставщик уже зарегистрирован"; +"registry.error.not_registered" = "Нет зарегистрированного поставщика для %@"; +"registry.error.config_missing" = "Отсутствует конфигурация для %@"; +"registry.error.credentials_not_found" = "Учётные данные не найдены для %@"; + +/* Конфигурация промптов */ +"prompt.engine.title" = "Промпты движков"; +"prompt.engine.description" = "Настройте промпты перевода для каждого LLM-движка"; +"prompt.scene.title" = "Промпты сцен"; +"prompt.scene.description" = "Настройте промпты перевода для каждого сценария перевода"; +"prompt.default.title" = "Шаблон промпта по умолчанию"; +"prompt.default.description" = "Этот шаблон используется, когда пользовательский промпт не настроен"; +"prompt.button.edit" = "Редактировать"; +"prompt.button.reset" = "Сбросить"; +"prompt.editor.title" = "Редактировать промпт"; +"prompt.editor.variables" = "Доступные переменные:"; +"prompt.variable.source_language" = "Название исходного языка"; +"prompt.variable.target_language" = "Название целевого языка"; +"prompt.variable.text" = "Текст для перевода"; + + +/* ======================================== + Режимы перевода + ======================================== */ + +"translation.mode.inline" = "Замена на месте"; +"translation.mode.inline.description" = "Заменить исходный текст переводом"; +"translation.mode.below" = "Под оригиналом"; +"translation.mode.below.description" = "Показать перевод под исходным текстом"; + + +/* ======================================== + Настройки перевода + ======================================== */ + +"translation.auto" = "Автоопределение"; +"translation.auto.detected" = "Автоопределено"; +"translation.language.follow.system" = "Как в системе"; +"translation.language.source" = "Исходный язык"; +"translation.language.target" = "Целевой язык"; +"translation.language.source.hint" = "Язык текста, который нужно перевести"; +"translation.language.target.hint" = "Язык, на который нужно перевести текст"; + + +/* ======================================== + История переводов + ======================================== */ + +"history.title" = "История переводов"; +"history.search.placeholder" = "Поиск в истории..."; +"history.clear.all" = "Очистить всю историю"; +"history.empty.title" = "Нет истории переводов"; +"history.empty.message" = "Ваши переведённые скриншоты появятся здесь"; +"history.no.results.title" = "Нет результатов"; +"history.no.results.message" = "Нет записей, соответствующих поиску"; +"history.clear.search" = "Очистить поиск"; + +"history.source" = "Исходный"; +"history.translation" = "Перевод"; +"history.truncated" = "усечено"; + +"history.copy.translation" = "Копировать перевод"; +"history.copy.source" = "Копировать исходный"; +"history.copy.both" = "Копировать оба"; +"history.delete" = "Удалить"; + +"history.clear.alert.title" = "Очистить историю"; +"history.clear.alert.message" = "Вы уверены, что хотите удалить всю историю переводов? Это действие нельзя отменить."; + + +/* ======================================== + Запросы разрешений + ======================================== */ + +"permission.prompt.title" = "Требуется разрешение на запись экрана"; +"permission.prompt.message" = "ScreenTranslate требуется разрешение для захвата экрана. Это необходимо для создания скриншотов.\n\nПосле нажатия «Продолжить» macOS запросит разрешение на запись экрана. Вы можете предоставить его в Системные настройки > Конфиденциальность и безопасность > Запись экрана."; +"permission.prompt.continue" = "Продолжить"; +"permission.prompt.later" = "Позже"; + +/* Разрешение универсального доступа */ +"permission.accessibility.title" = "Требуется разрешение универсального доступа"; +"permission.accessibility.message" = "ScreenTranslate требуется разрешение универсального доступа для захвата выбранного текста и вставки переводов.\n\nЭто позволяет приложению:\n• Копировать выбранный текст из любого приложения\n• Вставлять переведённый текст в поля ввода\n\nВаша конфиденциальность защищена — ScreenTranslate использует это только для перевода текста."; +"permission.accessibility.grant" = "Предоставить разрешение"; +"permission.accessibility.open.settings" = "Открыть настройки системы"; +"permission.accessibility.denied.title" = "Требуется разрешение универсального доступа"; +"permission.accessibility.denied.message" = "Захват и вставка текста требуют разрешения универсального доступа.\n\nПредоставьте разрешение в Системные настройки > Конфиденциальность и безопасность > Универсальный доступ."; + +/* Разрешение мониторинга ввода */ +"permission.input.monitoring.title" = "Требуется разрешение на мониторинг ввода"; +"permission.input.monitoring.message" = "ScreenTranslate требуется разрешение на мониторинг ввода для вставки переведённого текста в приложения.\n\nВам нужно включить это в:\nСистемные настройки > Конфиденциальность и безопасность > Мониторинг ввода"; +"permission.input.monitoring.open.settings" = "Открыть настройки системы"; +"permission.input.monitoring.denied.title" = "Требуется разрешение на мониторинг ввода"; +"permission.input.monitoring.denied.message" = "Вставка текста требует разрешения на мониторинг ввода.\n\nПредоставьте разрешение в Системные настройки > Конфиденциальность и безопасность > Мониторинг ввода."; + +/* Общие строки разрешений */ +"permission.open.settings" = "Открыть настройки системы"; + + +/* ======================================== + Приветствие + ======================================== */ + +"onboarding.window.title" = "Добро пожаловать в ScreenTranslate"; + +/* Приветствие - этап приветствия */ +"onboarding.welcome.title" = "Добро пожаловать в ScreenTranslate"; +"onboarding.welcome.message" = "Настроим захват экрана и перевод. Это займёт всего минуту."; + +"onboarding.feature.local.ocr.title" = "Локальный OCR"; +"onboarding.feature.local.ocr.description" = "Фреймворк macOS Vision для быстрого и конфиденциального распознавания текста"; +"onboarding.feature.local.translation.title" = "Локальный перевод"; +"onboarding.feature.local.translation.description" = "Apple Перевод для мгновенного перевода без сети"; +"onboarding.feature.shortcuts.title" = "Глобальные сочетания"; +"onboarding.feature.shortcuts.description" = "Захватывайте и переводите отовсюду с помощью сочетаний клавиш"; + +/* Приветствие - этап разрешений */ +"onboarding.permissions.title" = "Разрешения"; +"onboarding.permissions.message" = "Для работы ScreenTranslate требуются некоторые разрешения. Предоставьте следующие разрешения:"; +"onboarding.permissions.hint" = "После предоставления разрешений статус обновится автоматически."; + +"onboarding.permission.screen.recording" = "Запись экрана"; +"onboarding.permission.accessibility" = "Универсальный доступ"; +"onboarding.permission.granted" = "Предоставлено"; +"onboarding.permission.not.granted" = "Не предоставлено"; +"onboarding.permission.grant" = "Предоставить разрешение"; + +/* Приветствие - этап конфигурации */ +"onboarding.configuration.title" = "Дополнительная конфигурация"; +"onboarding.configuration.message" = "Ваши локальные OCR и перевод уже включены. Дополнительно настройте внешние сервисы:"; +"onboarding.configuration.paddleocr" = "Адрес сервера PaddleOCR"; +"onboarding.configuration.paddleocr.hint" = "Оставьте пустым для использования macOS Vision OCR"; +"onboarding.configuration.mtran" = "Адрес MTranServer"; +"onboarding.configuration.mtran.hint" = "Оставьте пустым для использования Apple Перевод"; +"onboarding.configuration.placeholder" = "http://localhost:8080"; +"onboarding.configuration.placeholder.address" = "localhost"; +"onboarding.configuration.test" = "Проверить перевод"; +"onboarding.configuration.test.button" = "Проверить перевод"; +"onboarding.configuration.testing" = "Проверка..."; +"onboarding.test.success" = "Тест перевода успешен: \"%@\" → \"%@\""; +"onboarding.test.failed" = "Тест перевода не удался: %@"; + +/* Приветствие - этап завершения */ +"onboarding.complete.title" = "Всё готово!"; +"onboarding.complete.message" = "ScreenTranslate готов к работе. Как начать:"; +"onboarding.complete.shortcuts" = "Используйте ⌘⇧F для захвата полного экрана"; +"onboarding.complete.selection" = "Используйте ⌘⇧A для захвата области и перевода"; +"onboarding.complete.settings" = "Откройте Настройки из меню для настройки параметров"; +"onboarding.complete.start" = "Начать работу с ScreenTranslate"; + +/* Приветствие - навигация */ +"onboarding.back" = "Назад"; +"onboarding.continue" = "Продолжить"; +"onboarding.next" = "Далее"; +"onboarding.skip" = "Пропустить"; +"onboarding.complete" = "Завершить"; + +/* Приветствие - PaddleOCR */ +"onboarding.paddleocr.title" = "PaddleOCR (опционально)"; +"onboarding.paddleocr.description" = "Улучшенный движок OCR для лучшей точности распознавания текста, особенно китайского."; +"onboarding.paddleocr.installed" = "Установлен"; +"onboarding.paddleocr.not.installed" = "Не установлен"; +"onboarding.paddleocr.install" = "Установить"; +"onboarding.paddleocr.installing" = "Установка..."; +"onboarding.paddleocr.install.hint" = "Требуется Python 3 и pip. Выполните: pip3 install paddleocr paddlepaddle"; +"onboarding.paddleocr.copy.command" = "Копировать команду"; +"onboarding.paddleocr.refresh" = "Обновить статус"; +"onboarding.paddleocr.version" = "Версия: %@"; + +/* Настройки - PaddleOCR */ +"settings.paddleocr.installed" = "Установлен"; +"settings.paddleocr.not.installed" = "Не установлен"; +"settings.paddleocr.install" = "Установить"; +"settings.paddleocr.installing" = "Установка..."; +"settings.paddleocr.install.hint" = "Требуется установленный Python 3 и pip в системе."; +"settings.paddleocr.copy.command" = "Копировать команду"; +"settings.paddleocr.refresh" = "Обновить статус"; +"settings.paddleocr.ready" = "PaddleOCR готов"; +"settings.paddleocr.not.installed.message" = "PaddleOCR не установлен"; +"settings.paddleocr.description" = "PaddleOCR — это локальный движок OCR. Бесплатный, работает без сети, не требует API-ключа."; +"settings.paddleocr.install.button" = "Установить PaddleOCR"; +"settings.paddleocr.copy.command.button" = "Копировать команду установки"; +"settings.paddleocr.mode" = "Режим"; +"settings.paddleocr.mode.fast" = "Быстрый"; +"settings.paddleocr.mode.precise" = "Точный"; +"settings.paddleocr.mode.fast.description" = "~1с, быстрый OCR с группировкой строк"; +"settings.paddleocr.mode.precise.description" = "~12с, модель VL-1.5 с более высокой точностью"; +"settings.paddleocr.useCloud" = "Использовать облачный API"; +"settings.paddleocr.cloudBaseURL" = "URL облачного API"; +"settings.paddleocr.cloudAPIKey" = "API-ключ"; +"settings.paddleocr.cloudModelId" = "ID модели"; +"settings.paddleocr.localVLModelDir" = "Каталог локальной модели (vllm)"; +"settings.paddleocr.localVLModelDir.hint" = "Путь к локальной модели PaddleOCR-VL (например ~/.paddlex/official_models/PaddleOCR-VL-1.5)"; +"error.paddleocr.notInstalled" = "PaddleOCR не установлен. Установите с помощью: pip3 install paddleocr paddlepaddle"; + + +/* ======================================== + Конфигурация VLM + ======================================== */ + +"settings.vlm.title" = "Конфигурация VLM"; +"settings.vlm.provider" = "Поставщик"; +"settings.vlm.apiKey" = "API-ключ"; +"settings.vlm.apiKey.optional" = "API-ключ необязателен для локальных поставщиков"; +"settings.vlm.baseURL" = "Базовый URL"; +"settings.vlm.model" = "Название модели"; +"settings.vlm.test.button" = "Проверить подключение"; +"settings.vlm.test.success" = "Подключение успешно! Модель: %@"; +"settings.vlm.test.ollama.success" = "Сервер запущен. Модель '%@' доступна"; +"settings.vlm.test.ollama.available" = "Сервер запущен. Доступно: %@"; + +"vlm.provider.openai" = "OpenAI"; +"vlm.provider.claude" = "Claude"; +"vlm.provider.glmocr" = "GLM OCR"; +"vlm.provider.ollama" = "Ollama"; +"vlm.provider.paddleocr" = "PaddleOCR"; +"vlm.provider.openai.description" = "OpenAI GPT-4 Vision API"; +"vlm.provider.claude.description" = "Anthropic Claude Vision API"; +"vlm.provider.glmocr.description" = "API анализа макета Zhipu GLM-OCR"; +"vlm.provider.ollama.description" = "Локальный сервер Ollama"; +"vlm.provider.paddleocr.description" = "Локальный движок OCR (бесплатный, работает без сети)"; + + +/* ======================================== + Конфигурация рабочего процесса перевода + ======================================== */ + +"settings.translation.workflow.title" = "Движок перевода"; +"settings.translation.preferred" = "Предпочитаемый движок"; +"settings.translation.mtran.url" = "URL MTransServer"; +"settings.translation.mtran.test.button" = "Проверить подключение"; +"settings.translation.mtran.test.success" = "Подключение успешно"; +"settings.translation.mtran.test.failed" = "Сбой подключения: %@"; +"settings.translation.fallback" = "Резервный"; +"settings.translation.fallback.description" = "Использовать Apple Перевод как резервный при сбое предпочитаемого движка"; + +"translation.preferred.apple.description" = "Встроенный перевод macOS, работает без сети"; +"translation.preferred.mtran.description" = "Самостоятельный сервер перевода для лучшего качества"; + + +/* ======================================== + Метки доступности + ======================================== */ + +"accessibility.close.button" = "Закрыть"; +"accessibility.settings.button" = "Настройки"; +"accessibility.capture.button" = "Захватить"; +"accessibility.translate.button" = "Перевести"; + + +/* ======================================== + Окно двуязычного результата + ======================================== */ + +/* ======================================== + Рабочий процесс перевода + ======================================== */ + +"translationFlow.phase.idle" = "Готов"; +"translationFlow.phase.analyzing" = "Анализ изображения..."; +"translationFlow.phase.translating" = "Перевод..."; +"translationFlow.phase.rendering" = "Отрисовка..."; +"translationFlow.phase.completed" = "Завершено"; +"translationFlow.phase.failed" = "Ошибка"; + +"translationFlow.error.title" = "Ошибка перевода"; +"translationFlow.error.title.analysis" = "Ошибка распознавания изображения"; +"translationFlow.error.title.translation" = "Ошибка перевода"; +"translationFlow.error.title.rendering" = "Ошибка отрисовки"; +"translationFlow.error.unknown" = "Произошла неизвестная ошибка."; +"translationFlow.error.analysis" = "Ошибка анализа: %@"; +"translationFlow.error.translation" = "Ошибка перевода: %@"; +"translationFlow.error.rendering" = "Ошибка отрисовки: %@"; +"translationFlow.error.cancelled" = "Перевод отменён."; +"translationFlow.error.noTextFound" = "Текст не найден в выбранной области."; +"translationFlow.error.translation.engine" = "Движок перевода"; + +"translationFlow.recovery.analysis" = "Попробуйте снова с более чётким изображением или проверьте настройки поставщика VLM."; +"translationFlow.recovery.translation" = "Проверьте настройки движка перевода и сетевое подключение, затем попробуйте снова."; +"translationFlow.recovery.rendering" = "Попробуйте снова."; +"translationFlow.recovery.noTextFound" = "Выберите область с видимым текстом."; + +"common.ok" = "ОК"; + + +/* ======================================== + Окно двуязычного результата + ======================================== */ + +"bilingualResult.window.title" = "Двуязычный перевод"; +"bilingualResult.loading" = "Перевод..."; +"bilingualResult.loading.analyzing" = "Анализ изображения..."; +"bilingualResult.loading.translating" = "Перевод текста..."; +"bilingualResult.loading.rendering" = "Отрисовка результата..."; +"bilingualResult.copyImage" = "Копировать изображение"; +"bilingualResult.copyText" = "Копировать текст"; +"bilingualResult.save" = "Сохранить"; +"bilingualResult.zoomIn" = "Увеличить"; +"bilingualResult.zoomOut" = "Уменьшить"; +"bilingualResult.resetZoom" = "Сбросить масштаб"; +"bilingualResult.copySuccess" = "Скопировано в буфер"; +"bilingualResult.copyTextSuccess" = "Текст перевода скопирован"; +"bilingualResult.saveSuccess" = "Успешно сохранено"; +"bilingualResult.copyFailed" = "Не удалось скопировать изображение"; +"bilingualResult.saveFailed" = "Не удалось сохранить изображение"; +"bilingualResult.noTextToCopy" = "Нет текста перевода для копирования"; + + +/* ======================================== + Перевод текста (US-003 до US-010) + ======================================== */ + +/* Рабочий процесс перевода текста */ +"textTranslation.phase.idle" = "Готов"; +"textTranslation.phase.translating" = "Перевод..."; +"textTranslation.phase.completed" = "Завершено"; +"textTranslation.phase.failed" = "Ошибка"; + +"textTranslation.error.emptyInput" = "Нет текста для перевода"; +"textTranslation.error.translationFailed" = "Ошибка перевода: %@"; +"textTranslation.error.cancelled" = "Перевод отменён"; +"textTranslation.error.serviceUnavailable" = "Сервис перевода недоступен"; +"textTranslation.error.insertFailed" = "Не удалось вставить переведённый текст"; + +"textTranslation.recovery.emptyInput" = "Сначала выберите текст"; +"textTranslation.recovery.translationFailed" = "Попробуйте снова"; +"textTranslation.recovery.serviceUnavailable" = "Проверьте сетевое подключение и попробуйте снова"; + +"textTranslation.loading" = "Перевод..."; +"textTranslation.noSelection.title" = "Текст не выбран"; +"textTranslation.noSelection.message" = "Выберите текст в любом приложении и попробуйте снова."; + +/* Перевести и вставить */ +"translateAndInsert.emptyClipboard.title" = "Буфер обмена пуст"; +"translateAndInsert.emptyClipboard.message" = "Сначала скопируйте текст в буфер обмена, затем используйте это сочетание."; +"translateAndInsert.success.title" = "Перевод вставлен"; +"translateAndInsert.success.message" = "Переведённый текст вставлен в активное поле ввода."; + +/* Отображение языка */ +"language.auto" = "Автоопределено"; + +/* Общий интерфейс перевода текста */ +"common.copy" = "Копировать"; +"common.copied" = "Скопировано"; +"common.insert" = "Вставить"; + +/* Окно перевода текста */ +"textTranslation.window.title" = "Перевод текста"; + +/* Настройки языка для перевода и вставки */ +"settings.translateAndInsert.language.section" = "Языки перевода и вставки"; +"settings.translateAndInsert.language.source" = "Исходный язык"; +"settings.translateAndInsert.language.target" = "Целевой язык"; + + +/* ======================================== + Несколько движков, совместимых с OpenAI + ======================================== */ + +/* Конфигурация совместимого движка */ +"engine.compatible.new" = "Новый совместимый движок"; +"engine.compatible.description" = "Конечная точка API, совместимая с OpenAI"; +"engine.compatible.displayName" = "Отображаемое имя"; +"engine.compatible.displayName.placeholder" = "например, мой LLM-сервер"; +"engine.compatible.requireApiKey" = "Требуется API-ключ"; +"engine.compatible.add" = "Добавить совместимый движок"; +"engine.compatible.delete" = "Удалить этот движок"; +"engine.compatible.useAsEngine" = "Использовать как движок перевода"; +"engine.compatible.max.reached" = "Достигнуто максимальное количество 5 совместимых движков"; + +/* Конфигурация промптов */ +"prompt.compatible.title" = "Совместимые движки"; + + +/* ======================================== + Окно о программе + ======================================== */ + +"about.title" = "О ScreenTranslate"; +"about.app.name" = "ScreenTranslate"; +"about.version.format" = "Версия %@ (%@)"; +"about.copyright" = "Авторское право"; +"about.copyright.value" = "© 2026 Все права защищены"; +"about.license" = "Лицензия"; +"about.license.value" = "Лицензия MIT"; +"about.github.link" = "GitHub: hubo1989/ScreenTranslate"; +"about.check.for.updates" = "Проверить обновления"; +"about.update.checking" = "Проверка..."; +"about.update.available" = "Доступно обновление"; +"about.update.uptodate" = "Актуальная версия"; +"about.update.failed" = "Проверка не удалась"; +"about.acknowledgements" = "Благодарности"; +"about.acknowledgements.title" = "Благодарности"; +"about.acknowledgements.intro" = "В этом программном обеспечении использованы следующие библиотеки с открытым исходным кодом:"; +"about.acknowledgements.upstream" = "Основано на"; +"about.acknowledgements.author.format" = "автор: %@"; +"about.close" = "Закрыть"; +"settings.glmocr.mode" = "Режим"; +"settings.glmocr.mode.cloud" = "Облако"; +"settings.glmocr.mode.local" = "Локально"; +"settings.glmocr.local.apiKey.optional" = "API-ключ необязателен для локальных серверов MLX-VLM"; +"vlm.provider.glmocr.local.description" = "Локальный сервер MLX-VLM для GLM-OCR"; diff --git a/ScreenTranslate/Resources/zh-Hans.lproj/Localizable.strings b/ScreenTranslate/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 0000000..26ac053 --- /dev/null +++ b/ScreenTranslate/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,823 @@ +/* + Localizable.strings (简体中文) + ScreenTranslate +*/ + +/* ======================================== + 错误消息 + ======================================== */ + +/* 权限错误 */ +"error.permission.denied" = "需要屏幕录制权限才能截取屏幕。"; +"error.permission.denied.recovery" = "请打开系统设置授予权限。"; + +/* 显示器错误 */ +"error.display.not.found" = "所选显示器已不可用。"; +"error.display.not.found.recovery" = "请选择其他显示器。"; +"error.display.disconnected" = "显示器 '%@' 在截图过程中断开连接。"; +"error.display.disconnected.recovery" = "请重新连接显示器后再试。"; + +/* 截图错误 */ +"error.capture.failed" = "截图失败。"; +"error.capture.failed.recovery" = "请重试。"; + +/* 保存错误 */ +"error.save.location.invalid" = "保存位置不可访问。"; +"error.save.location.invalid.recovery" = "请在设置中选择其他保存位置。"; +"error.save.location.invalid.detail" = "无法保存到 %@,该位置不可访问。"; +"error.save.unknown" = "保存时发生未知错误。"; +"error.disk.full" = "磁盘空间不足,无法保存截图。"; +"error.disk.full.recovery" = "请释放磁盘空间后重试。"; + +/* 导出错误 */ +"error.export.encoding.failed" = "图像编码失败。"; +"error.export.encoding.failed.recovery" = "请在设置中尝试其他格式。"; +"error.export.encoding.failed.detail" = "无法将图像编码为 %@ 格式。"; + +/* 剪贴板错误 */ +"error.clipboard.write.failed" = "复制截图到剪贴板失败。"; +"error.clipboard.write.failed.recovery" = "请重试。"; + +/* 快捷键错误 */ +"error.hotkey.registration.failed" = "注册键盘快捷键失败。"; +"error.hotkey.registration.failed.recovery" = "该快捷键可能与其他应用冲突,请尝试其他快捷键。"; +"error.hotkey.conflict" = "此键盘快捷键与其他应用程序冲突。"; +"error.hotkey.conflict.recovery" = "请选择其他键盘快捷键。"; + +/* OCR 错误 */ +"error.ocr.failed" = "文字识别失败。"; +"error.ocr.failed.recovery" = "请使用更清晰的图像重试。"; +"error.ocr.no.text" = "图像中未识别到文字。"; +"error.ocr.no.text.recovery" = "请尝试截取包含可见文字的区域。"; +"error.ocr.cancelled" = "文字识别已取消。"; +"error.ocr.server.unreachable" = "无法连接到 OCR 服务器。"; +"error.ocr.server.unreachable.recovery" = "请检查服务器地址和网络连接。"; + +/* 翻译错误 */ +"error.translation.in.progress" = "翻译正在进行中"; +"error.translation.in.progress.recovery" = "请等待当前翻译完成"; +"error.translation.empty.input" = "没有可翻译的文本"; +"error.translation.empty.input.recovery" = "请先选择一些文本"; +"error.translation.timeout" = "翻译超时"; +"error.translation.timeout.recovery" = "请重试"; +"error.translation.unsupported.pair" = "不支持从 %@ 翻译到 %@"; +"error.translation.unsupported.pair.recovery" = "请选择其他语言"; +"error.translation.failed" = "翻译失败"; +"error.translation.failed.recovery" = "请重试"; +"error.translation.language.not.installed" = "翻译语言 '%@' 未安装"; +"error.translation.language.download.instructions" = "请前往系统设置 > 通用 > 语言与地区 > 翻译语言,下载所需语言。"; + +/* 通用错误 UI */ +"error.title" = "错误"; +"error.ok" = "好的"; +"error.dismiss" = "关闭"; +"error.retry.capture" = "重试"; +"error.permission.open.settings" = "打开系统设置"; + + +/* ======================================== + 菜单项 + ======================================== */ + +"menu.capture.full.screen" = "全屏截图"; +"menu.capture.fullscreen" = "全屏截图"; +"menu.capture.selection" = "区域截图"; +"menu.translation.mode" = "截图翻译"; +"menu.translation.history" = "翻译历史"; +"menu.settings" = "设置..."; +"menu.about" = "关于 ScreenTranslate"; +"menu.quit" = "退出 ScreenTranslate"; + + +/* ======================================== + 显示器选择 + ======================================== */ + +"display.selector.title" = "选择显示器"; +"display.selector.header" = "选择要截取的显示器:"; +"display.selector.cancel" = "取消"; + + +/* ======================================== + 预览窗口 + ======================================== */ + +"preview.window.title" = "截图预览"; +"preview.title" = "截图预览"; +"preview.dimensions" = "%d × %d 像素"; +"preview.file.size" = "约 %@ %@"; +"preview.screenshot" = "截图"; +"preview.enter.text" = "输入文本"; +"preview.image.dimensions" = "图片尺寸"; +"preview.estimated.size" = "预估文件大小"; +"preview.edit.label" = "编辑:"; +"preview.active.tool" = "当前工具"; +"preview.crop.mode.active" = "裁剪模式已激活"; + +/* 裁剪 */ +"preview.crop" = "裁剪"; +"preview.crop.cancel" = "取消"; +"preview.crop.apply" = "应用裁剪"; + +/* 识别文本 */ +"preview.recognized.text" = "识别文本:"; +"preview.translation" = "翻译结果:"; +"preview.results.panel" = "文本结果"; +"preview.copy.text" = "复制文本"; + +/* 工具栏提示 */ +"preview.tooltip.crop" = "裁剪 (C)"; +"preview.tooltip.pin" = "钉图 (P)"; +"preview.tooltip.undo" = "撤销 (⌘Z)"; +"preview.tooltip.redo" = "重做 (⌘⇧Z)"; +"preview.tooltip.copy" = "复制到剪贴板 (⌘C)"; +"preview.tooltip.save" = "保存 (⌘S)"; +"preview.tooltip.ocr" = "识别文字 (OCR)"; +"preview.tooltip.confirm" = "复制到剪贴板并关闭 (回车)"; +"preview.tooltip.dismiss" = "关闭 (Escape)"; +"preview.tooltip.delete" = "删除选中的标注"; + +/* 无障碍标签 */ +"preview.accessibility.save" = "保存截图"; +"preview.accessibility.saving" = "正在保存截图"; +"preview.accessibility.confirm" = "确定并复制到剪贴板"; +"preview.accessibility.copying" = "正在复制到剪贴板"; +"preview.accessibility.hint.commandS" = "Command S"; +"preview.accessibility.hint.enter" = "回车键"; + +/* 形状切换 */ +"preview.shape.filled" = "填充"; +"preview.shape.hollow" = "空心"; +"preview.shape.toggle.hint" = "点击切换填充/空心"; + + +/* ======================================== + 标注工具 + ======================================== */ + +"tool.rectangle" = "矩形"; +"tool.freehand" = "画笔"; +"tool.text" = "文本"; +"tool.arrow" = "箭头"; +"tool.ellipse" = "椭圆"; +"tool.line" = "直线"; +"tool.highlight" = "高亮"; +"tool.mosaic" = "马赛克"; +"tool.numberLabel" = "数字标签"; + + +/* ======================================== + 颜色 + ======================================== */ + +"color.red" = "红色"; +"color.orange" = "橙色"; +"color.yellow" = "黄色"; +"color.green" = "绿色"; +"color.blue" = "蓝色"; +"color.purple" = "紫色"; +"color.pink" = "粉色"; +"color.white" = "白色"; +"color.black" = "黑色"; +"color.custom" = "自定义"; + + +/* ======================================== + 操作 + ======================================== */ + +"action.save" = "保存"; +"action.copy" = "复制"; +"action.cancel" = "取消"; +"action.undo" = "撤销"; +"action.redo" = "重做"; +"action.delete" = "删除"; +"action.clear" = "清除"; +"action.reset" = "重置"; +"action.close" = "关闭"; +"action.done" = "完成"; + +/* 按钮 */ +"button.ok" = "好的"; +"button.cancel" = "取消"; +"button.clear" = "清除"; +"button.reset" = "重置"; +"button.save" = "保存"; +"button.delete" = "删除"; +"button.confirm" = "确定"; + +/* 保存成功 */ +"save.success.title" = "保存成功"; +"save.success.message" = "已保存到 %@"; +"save.with.translations.message" = "选择保存带译文图片的位置"; + +/* 无翻译错误 */ +"error.no.translations" = "没有可用的翻译。请先翻译文本。"; + +/* 复制成功 */ +"copy.success.message" = "已复制到剪贴板"; + + +/* ======================================== + 设置窗口 + ======================================== */ + +"settings.window.title" = "ScreenTranslate 设置"; +"settings.title" = "ScreenTranslate 设置"; + +/* 设置标签/分区 */ +"settings.section.permissions" = "权限"; +"settings.section.general" = "通用"; +"settings.section.engines" = "引擎"; +"settings.section.prompts" = "提示词配置"; +"settings.section.languages" = "语言"; +"settings.section.export" = "导出"; +"settings.section.shortcuts" = "键盘快捷键"; +"settings.section.text.translation" = "文本翻译"; +"settings.section.annotations" = "标注"; + +/* 语言设置 */ +"settings.language" = "语言"; +"settings.language.system" = "跟随系统"; +"settings.language.restart.hint" = "部分更改可能需要重启应用"; + +/* 权限 */ +"settings.permission.screen.recording" = "屏幕录制"; +"settings.permission.screen.recording.hint" = "截图功能需要此权限"; +"settings.permission.accessibility" = "辅助功能"; +"settings.permission.accessibility.hint" = "全局快捷键需要此权限"; +"settings.permission.granted" = "已授权"; +"settings.permission.not.granted" = "未授权"; +"settings.permission.grant" = "授权"; +"settings.permission.authorization.title" = "需要授权"; +"settings.permission.authorization.cancel" = "取消"; +"settings.permission.authorization.go" = "去授权"; +"settings.permission.authorization.screen.message" = "需要屏幕录制权限。点击「去授权」打开系统设置,为本应用启用屏幕录制权限。"; +"settings.permission.authorization.accessibility.message" = "需要辅助功能权限。点击「去授权」打开系统设置,将本应用添加到辅助功能列表中。"; + +/* 保存位置 */ +"settings.save.location" = "保存位置"; +"settings.save.location.choose" = "选择..."; +"settings.save.location.select" = "选择"; +"settings.save.location.message" = "选择截图的默认保存位置"; +"settings.save.location.reveal" = "在访达中显示"; + +/* 导出格式 */ +"settings.format" = "默认格式"; +"settings.format.png" = "PNG"; +"settings.format.jpeg" = "JPEG"; +"settings.format.heic" = "HEIC"; +"settings.jpeg.quality" = "JPEG 质量"; +"settings.jpeg.quality.hint" = "质量越高,文件越大"; +"settings.heic.quality" = "HEIC 质量"; +"settings.heic.quality.hint" = "HEIC 提供更好的压缩率"; + +/* 键盘快捷键 */ +"settings.shortcuts" = "键盘快捷键"; +"settings.shortcut.fullscreen" = "全屏截图"; +"settings.shortcut.selection" = "区域截图"; +"settings.shortcut.translation.mode" = "翻译模式"; +"settings.shortcut.text.selection.translation" = "文本选择翻译"; +"settings.shortcut.translate.and.insert" = "翻译并插入"; +"settings.shortcut.recording" = "按下快捷键..."; +"settings.shortcut.reset" = "恢复默认"; +"settings.shortcut.error.no.modifier" = "快捷键必须包含 Command、Control 或 Option"; +"settings.shortcut.error.conflict" = "此快捷键已被使用"; + +/* 标注 */ +"settings.annotations" = "标注默认设置"; +"settings.stroke.color" = "描边颜色"; +"settings.stroke.width" = "描边宽度"; +"settings.text.size" = "文字大小"; +"settings.mosaic.blockSize" = "马赛克块大小"; + +/* 引擎 */ +"settings.ocr.engine" = "OCR 引擎"; +"settings.translation.engine" = "翻译引擎"; +"settings.translation.mode" = "翻译模式"; + +/* 重置 */ +"settings.reset.all" = "恢复所有默认设置"; + +/* 错误 */ +"settings.error.title" = "错误"; +"settings.error.ok" = "好的"; + + +/* ======================================== + OCR 引擎 + ======================================== */ + +"ocr.engine.vision" = "Apple Vision"; +"ocr.engine.vision.description" = "内置 macOS Vision 框架,快速且隐私安全"; +"ocr.engine.paddleocr" = "PaddleOCR"; +"ocr.engine.paddleocr.description" = "自托管 OCR 服务器,识别更准确"; + + +/* ======================================== + 翻译引擎 + ======================================== */ + +"translation.engine.apple" = "Apple 翻译"; +"translation.engine.apple.description" = "内置 macOS 翻译,无需配置"; +"translation.engine.mtran" = "MTranServer"; +"translation.engine.mtran.description" = "自托管翻译服务器"; + +/* 新增翻译引擎 */ +"translation.engine.openai" = "OpenAI"; +"translation.engine.openai.description" = "通过 OpenAI API 使用 GPT-4 翻译"; +"translation.engine.claude" = "Claude"; +"translation.engine.claude.description" = "通过 Anthropic API 使用 Claude 翻译"; +"translation.engine.gemini" = "Gemini"; +"translation.engine.gemini.description" = "通过 Google AI API 使用 Gemini 翻译"; +"translation.engine.ollama" = "Ollama"; +"translation.engine.ollama.description" = "通过 Ollama 使用本地大模型翻译"; +"translation.engine.google" = "Google 翻译"; +"translation.engine.google.description" = "Google 云翻译 API"; +"translation.engine.deepl" = "DeepL"; +"translation.engine.deepl.description" = "通过 DeepL API 获取高质量翻译"; +"translation.engine.baidu" = "百度翻译"; +"translation.engine.baidu.description" = "百度翻译 API"; +"translation.engine.custom" = "OpenAI 兼容"; +"translation.engine.custom.description" = "自定义 OpenAI 兼容端点"; + +/* 引擎分类 */ +"engine.category.builtin" = "内置"; +"engine.category.llm" = "大模型翻译"; +"engine.category.cloud" = "云服务"; +"engine.category.compatible" = "兼容接口"; + +/* 引擎配置标题 */ +"engine.config.title" = "翻译引擎配置"; + +/* 引擎选择模式 */ +"engine.selection.mode.title" = "引擎选择模式"; +"engine.selection.mode.primary_fallback" = "主备"; +"engine.selection.mode.primary_fallback.description" = "使用主引擎,失败时切换到备用引擎"; +"engine.selection.mode.parallel" = "并行"; +"engine.selection.mode.parallel.description" = "同时运行多个引擎并比较结果"; +"engine.selection.mode.quick_switch" = "快切"; +"engine.selection.mode.quick_switch.description" = "从主引擎开始,可快速切换到其他引擎"; +"engine.selection.mode.scene_binding" = "场景"; +"engine.selection.mode.scene_binding.description" = "为不同翻译场景使用不同引擎"; + +/* 模式特定标签 */ +"engine.config.primary" = "主引擎"; +"engine.config.fallback" = "备用引擎"; +"engine.config.switch.order" = "切换顺序"; +"engine.config.parallel.select" = "选择并行运行的引擎"; +"engine.config.replace" = "替换引擎"; +"engine.config.remove" = "移除"; +"engine.config.add" = "添加引擎"; + +/* 翻译场景 */ +"translation.scene.screenshot" = "截图翻译"; +"translation.scene.screenshot.description" = "OCR识别并翻译截图区域"; +"translation.scene.text_selection" = "文本选择翻译"; +"translation.scene.text_selection.description" = "翻译从任意应用选中的文本"; +"translation.scene.translate_and_insert" = "翻译并插入"; +"translation.scene.translate_and_insert.description" = "翻译剪贴板文本并插入到光标位置"; + +/* 引擎配置 */ +"engine.config.enabled" = "启用此引擎"; +"engine.config.apiKey" = "API 密钥"; +"engine.config.apiKey.placeholder" = "输入您的 API 密钥"; +"engine.config.getApiKey" = "获取 API 密钥"; +"engine.config.baseURL" = "基础 URL"; +"engine.config.model" = "模型名称"; +"engine.config.test" = "测试连接"; +"engine.config.test.success" = "连接成功"; +"engine.config.test.failed" = "连接失败"; +"engine.config.baidu.credentials" = "百度翻译凭证"; +"engine.config.baidu.appID" = "应用 ID"; +"engine.config.baidu.secretKey" = "密钥"; +"engine.config.mtran.url" = "服务器地址"; + +/* 引擎状态 */ +"engine.status.configured" = "已配置"; +"engine.status.unconfigured" = "未配置"; +"engine.available.title" = "可用引擎"; +"engine.parallel.title" = "并行引擎"; +"engine.parallel.description" = "选择在并行模式下同时运行的引擎"; +"engine.scene.binding.title" = "场景引擎绑定"; +"engine.scene.binding.description" = "配置每个翻译场景使用的引擎"; +"engine.scene.fallback.tooltip" = "启用备用引擎"; + +/* Keychain 错误 */ +"keychain.error.item_not_found" = "钥匙串中未找到凭证"; +"keychain.error.item_not_found.recovery" = "请在设置中配置您的 API 凭证"; +"keychain.error.duplicate_item" = "钥匙串中已存在凭证"; +"keychain.error.duplicate_item.recovery" = "请先删除现有凭证"; +"keychain.error.invalid_data" = "无效的凭证数据格式"; +"keychain.error.invalid_data.recovery" = "请重新输入您的凭证"; +"keychain.error.unexpected_status" = "钥匙串操作失败"; +"keychain.error.unexpected_status.recovery" = "请检查您的钥匙串访问权限"; + +/* 多引擎错误 */ +"multiengine.error.all_failed" = "所有翻译引擎均失败"; +"multiengine.error.no_engines" = "未配置翻译引擎"; +"multiengine.error.primary_unavailable" = "主引擎 %@ 不可用"; +"multiengine.error.no_results" = "无翻译结果"; + +/* 注册表错误 */ +"registry.error.already_registered" = "提供者已注册"; +"registry.error.not_registered" = "%@ 未注册提供者"; +"registry.error.config_missing" = "%@ 配置缺失"; +"registry.error.credentials_not_found" = "%@ 凭证未找到"; + +/* 提示词配置 */ +"prompt.engine.title" = "引擎提示词"; +"prompt.engine.description" = "为每个大模型引擎自定义翻译提示词"; +"prompt.scene.title" = "场景提示词"; +"prompt.scene.description" = "为每个翻译场景自定义翻译提示词"; +"prompt.default.title" = "默认提示词模板"; +"prompt.default.description" = "当未配置自定义提示词时使用此模板"; +"prompt.button.edit" = "编辑"; +"prompt.button.reset" = "重置"; +"prompt.editor.title" = "编辑提示词"; +"prompt.editor.variables" = "可用变量:"; +"prompt.variable.source_language" = "源语言名称"; +"prompt.variable.target_language" = "目标语言名称"; +"prompt.variable.text" = "要翻译的文本"; + + +/* ======================================== + 翻译模式 + ======================================== */ + +"translation.mode.inline" = "原地替换"; +"translation.mode.inline.description" = "用译文替换原文"; +"translation.mode.below" = "原文下方显示"; +"translation.mode.below.description" = "在原文下方显示译文"; + + +/* ======================================== + 翻译设置 + ======================================== */ + +"translation.auto" = "自动检测"; +"translation.auto.detected" = "自动检测"; +"translation.language.follow.system" = "跟随系统"; +"translation.language.source" = "源语言"; +"translation.language.target" = "目标语言"; +"translation.language.source.hint" = "要翻译的文本的语言"; +"translation.language.target.hint" = "翻译的目标语言"; + + +/* ======================================== + 历史记录视图 + ======================================== */ + +"history.title" = "翻译历史"; +"history.search.placeholder" = "搜索历史记录..."; +"history.clear.all" = "清除所有历史"; +"history.empty.title" = "没有翻译历史"; +"history.empty.message" = "您的翻译截图将显示在这里"; +"history.no.results.title" = "无结果"; +"history.no.results.message" = "没有匹配的记录"; +"history.clear.search" = "清除搜索"; + +"history.source" = "原文"; +"history.translation" = "译文"; +"history.truncated" = "已截断"; + +"history.copy.translation" = "复制译文"; +"history.copy.source" = "复制原文"; +"history.copy.both" = "复制全部"; +"history.delete" = "删除"; + +"history.clear.alert.title" = "清除历史"; +"history.clear.alert.message" = "确定要删除所有翻译历史吗?此操作无法撤销。"; + + +/* ======================================== + 权限提示 + ======================================== */ + +"permission.prompt.title" = "需要屏幕录制权限"; +"permission.prompt.message" = "ScreenTranslate 需要权限来截取您的屏幕。这是截图功能所必需的。\n\n点击继续后,macOS 将要求您授予屏幕录制权限。您可以在系统设置 > 隐私与安全性 > 屏幕录制中授权。"; +"permission.prompt.continue" = "继续"; +"permission.prompt.later" = "稍后"; + +/* 辅助功能权限 */ +"permission.accessibility.title" = "需要辅助功能权限"; +"permission.accessibility.message" = "ScreenTranslate 需要辅助功能权限来捕获选中的文本并插入翻译结果。\n\n此权限允许应用:\n• 从任何应用复制选中的文本\n• 将翻译后的文本插入输入框\n\n您的隐私受到保护 - ScreenTranslate 仅将此用于文本翻译。"; +"permission.accessibility.grant" = "授予权限"; +"permission.accessibility.open.settings" = "打开系统设置"; +"permission.accessibility.denied.title" = "需要辅助功能权限"; +"permission.accessibility.denied.message" = "文本捕获和插入需要辅助功能权限。\n\n请在系统设置 > 隐私与安全性 > 辅助功能中授予权限。"; + +/* 输入监控权限 */ +"permission.input.monitoring.title" = "需要输入监控权限"; +"permission.input.monitoring.message" = "ScreenTranslate 需要输入监控权限来将翻译后的文本插入应用程序。\n\n您需要在以下位置启用:\n系统设置 > 隐私与安全性 > 输入监控"; +"permission.input.monitoring.open.settings" = "打开系统设置"; +"permission.input.monitoring.denied.title" = "需要输入监控权限"; +"permission.input.monitoring.denied.message" = "文本插入需要输入监控权限。\n\n请在系统设置 > 隐私与安全性 > 输入监控中授予权限。"; + +/* 通用权限字符串 */ +"permission.open.settings" = "打开系统设置"; + + +/* ======================================== + 引导页面 + ======================================== */ + +"onboarding.window.title" = "欢迎使用 ScreenTranslate"; + +/* 引导 - 欢迎步骤 */ +"onboarding.welcome.title" = "欢迎使用 ScreenTranslate"; +"onboarding.welcome.message" = "让我们为您设置屏幕截图和翻译功能。只需一分钟即可完成。"; + +"onboarding.feature.local.ocr.title" = "本地 OCR"; +"onboarding.feature.local.ocr.description" = "使用 macOS Vision 框架,快速且隐私安全的文字识别"; +"onboarding.feature.local.translation.title" = "本地翻译"; +"onboarding.feature.local.translation.description" = "使用 Apple 翻译,即时离线翻译"; +"onboarding.feature.shortcuts.title" = "全局快捷键"; +"onboarding.feature.shortcuts.description" = "随时随地使用键盘快捷键截图和翻译"; + +/* 引导 - 权限步骤 */ +"onboarding.permissions.title" = "权限"; +"onboarding.permissions.message" = "ScreenTranslate 需要一些权限才能正常工作。请授予以下权限:"; +"onboarding.permissions.hint" = "授权后,状态将自动更新。"; + +"onboarding.permission.screen.recording" = "屏幕录制"; +"onboarding.permission.accessibility" = "辅助功能"; +"onboarding.permission.granted" = "已授权"; +"onboarding.permission.not.granted" = "未授权"; +"onboarding.permission.grant" = "授权"; + +/* 引导 - 配置步骤 */ +"onboarding.configuration.title" = "可选配置"; +"onboarding.configuration.message" = "您的本地 OCR 和翻译功能已启用。可选择配置外部服务:"; +"onboarding.configuration.paddleocr" = "PaddleOCR 服务器地址"; +"onboarding.configuration.paddleocr.hint" = "留空则使用 macOS Vision OCR"; +"onboarding.configuration.mtran" = "MTranServer 地址"; +"onboarding.configuration.mtran.hint" = "留空则使用 Apple 翻译"; +"onboarding.configuration.placeholder" = "http://localhost:8080"; +"onboarding.configuration.placeholder.address" = "localhost"; +"onboarding.configuration.test" = "测试翻译"; +"onboarding.configuration.test.button" = "测试翻译"; +"onboarding.configuration.testing" = "测试中..."; +"onboarding.test.success" = "翻译测试成功:\"%@\" → \"%@\""; +"onboarding.test.failed" = "翻译测试失败:%@"; + +/* 引导 - 完成步骤 */ +"onboarding.complete.title" = "设置完成!"; +"onboarding.complete.message" = "ScreenTranslate 已准备就绪。以下是使用方法:"; +"onboarding.complete.shortcuts" = "使用 ⌘⇧F 全屏截图"; +"onboarding.complete.selection" = "使用 ⌘⇧A 区域截图并翻译"; +"onboarding.complete.settings" = "从菜单栏打开设置自定义选项"; +"onboarding.complete.start" = "开始使用 ScreenTranslate"; + +/* 引导 - 导航 */ +"onboarding.back" = "上一步"; +"onboarding.continue" = "继续"; +"onboarding.next" = "下一步"; +"onboarding.skip" = "跳过"; +"onboarding.complete" = "完成"; + +/* 引导 - PaddleOCR */ +"onboarding.paddleocr.title" = "PaddleOCR(可选)"; +"onboarding.paddleocr.description" = "增强型 OCR 引擎,文字识别更准确,尤其适合中文。"; +"onboarding.paddleocr.installed" = "已安装"; +"onboarding.paddleocr.not.installed" = "未安装"; +"onboarding.paddleocr.install" = "安装"; +"onboarding.paddleocr.installing" = "安装中..."; +"onboarding.paddleocr.install.hint" = "需要 Python 3 和 pip。执行命令:pip3 install paddleocr paddlepaddle"; +"onboarding.paddleocr.copy.command" = "复制命令"; +"onboarding.paddleocr.refresh" = "刷新状态"; +"onboarding.paddleocr.version" = "版本:%@"; + +/* 设置 - PaddleOCR */ +"settings.paddleocr.installed" = "已安装"; +"settings.paddleocr.not.installed" = "未安装"; +"settings.paddleocr.install" = "安装"; +"settings.paddleocr.installing" = "安装中..."; +"settings.paddleocr.install.hint" = "需要在系统上安装 Python 3 和 pip。"; +"settings.paddleocr.copy.command" = "复制命令"; +"settings.paddleocr.refresh" = "刷新状态"; +"settings.paddleocr.ready" = "PaddleOCR 已就绪"; +"settings.paddleocr.not.installed.message" = "PaddleOCR 未安装"; +"settings.paddleocr.description" = "PaddleOCR 是本地 OCR 引擎。免费、离线可用,无需 API 密钥。"; +"settings.paddleocr.install.button" = "安装 PaddleOCR"; +"settings.paddleocr.copy.command.button" = "复制安装命令"; +"settings.paddleocr.mode" = "模式"; +"settings.paddleocr.mode.fast" = "快速"; +"settings.paddleocr.mode.precise" = "精确"; +"settings.paddleocr.mode.fast.description" = "~1秒,快速 OCR 并自动合并行"; +"settings.paddleocr.mode.precise.description" = "~12秒,VL-1.5 模型,更高精度"; +"settings.paddleocr.useCloud" = "使用云端 API"; +"settings.paddleocr.cloudBaseURL" = "云端 API 地址"; +"settings.paddleocr.cloudAPIKey" = "API 密钥"; +"settings.paddleocr.cloudModelId" = "模型 ID"; +"settings.paddleocr.localVLModelDir" = "本地模型目录 (vllm)"; +"settings.paddleocr.localVLModelDir.hint" = "本地 PaddleOCR-VL 模型路径(如 ~/.paddlex/official_models/PaddleOCR-VL-1.5)"; +"error.paddleocr.notInstalled" = "PaddleOCR 未安装。请使用以下命令安装:pip3 install paddleocr paddlepaddle"; + + +/* ======================================== + VLM 配置 + ======================================== */ + +"settings.vlm.title" = "VLM 配置"; +"settings.vlm.provider" = "提供商"; +"settings.vlm.apiKey" = "API 密钥"; +"settings.vlm.apiKey.optional" = "本地提供商无需 API 密钥"; +"settings.vlm.baseURL" = "Base URL"; +"settings.vlm.model" = "模型名称"; +"settings.vlm.test.button" = "测试连接"; +"settings.vlm.test.success" = "连接成功!模型:%@"; +"settings.vlm.test.ollama.success" = "服务运行中。模型 '%@' 可用"; +"settings.vlm.test.ollama.available" = "服务运行中。可用模型:%@"; + +"vlm.provider.openai" = "OpenAI"; +"vlm.provider.claude" = "Claude"; +"vlm.provider.glmocr" = "GLM OCR"; +"vlm.provider.ollama" = "Ollama"; +"vlm.provider.paddleocr" = "PaddleOCR"; +"vlm.provider.openai.description" = "OpenAI GPT-4 Vision API"; +"vlm.provider.claude.description" = "Anthropic Claude Vision API"; +"vlm.provider.glmocr.description" = "智谱 GLM-OCR 版面解析 API"; +"vlm.provider.ollama.description" = "本地 Ollama 服务器"; +"vlm.provider.paddleocr.description" = "本地 OCR 引擎(免费、离线可用)"; + + +/* ======================================== + 翻译工作流配置 + ======================================== */ + +"settings.translation.workflow.title" = "翻译引擎"; +"settings.translation.preferred" = "首选引擎"; +"settings.translation.mtran.url" = "MTransServer URL"; +"settings.translation.mtran.test.button" = "测试连接"; +"settings.translation.mtran.test.success" = "连接成功"; +"settings.translation.mtran.test.failed" = "连接失败: %@"; +"settings.translation.fallback" = "回退"; +"settings.translation.fallback.description" = "首选引擎失败时使用 Apple 翻译作为回退"; + +"translation.preferred.apple.description" = "内置 macOS 翻译,支持离线使用"; +"translation.preferred.mtran.description" = "自托管翻译服务器,翻译质量更好"; + + +/* ======================================== + 无障碍标签 + ======================================== */ + +"accessibility.close.button" = "关闭"; +"accessibility.settings.button" = "设置"; +"accessibility.capture.button" = "截图"; +"accessibility.translate.button" = "翻译"; + + +/* ======================================== + 双语对照窗口 + ======================================== */ + +/* ======================================== + 翻译流程 + ======================================== */ + +"translationFlow.phase.idle" = "就绪"; +"translationFlow.phase.analyzing" = "正在分析图像..."; +"translationFlow.phase.translating" = "正在翻译..."; +"translationFlow.phase.rendering" = "正在渲染..."; +"translationFlow.phase.completed" = "已完成"; +"translationFlow.phase.failed" = "失败"; + +"translationFlow.error.title" = "翻译错误"; +"translationFlow.error.title.analysis" = "图像识别失败"; +"translationFlow.error.title.translation" = "翻译失败"; +"translationFlow.error.title.rendering" = "渲染失败"; +"translationFlow.error.unknown" = "发生未知错误。"; +"translationFlow.error.analysis" = "分析失败:%@"; +"translationFlow.error.translation" = "翻译失败:%@"; +"translationFlow.error.rendering" = "渲染失败:%@"; +"translationFlow.error.cancelled" = "翻译已取消。"; +"translationFlow.error.noTextFound" = "选中区域未找到文字。"; +"translationFlow.error.translation.engine" = "翻译引擎"; + +"translationFlow.recovery.analysis" = "请使用更清晰的图像重试,或检查 VLM 提供商设置。"; +"translationFlow.recovery.translation" = "请检查翻译引擎设置和网络连接后重试。"; +"translationFlow.recovery.rendering" = "请重试。"; +"translationFlow.recovery.noTextFound" = "请尝试选择包含可见文字的区域。"; + +"common.ok" = "好的"; + + +/* ======================================== + 双语对照窗口 + ======================================== */ + +"bilingualResult.window.title" = "双语对照"; +"bilingualResult.loading" = "翻译中..."; +"bilingualResult.loading.analyzing" = "正在分析图像..."; +"bilingualResult.loading.translating" = "正在翻译文本..."; +"bilingualResult.loading.rendering" = "正在渲染结果..."; +"bilingualResult.copyImage" = "复制图片"; +"bilingualResult.copyText" = "复制文本"; +"bilingualResult.save" = "保存"; +"bilingualResult.zoomIn" = "放大"; +"bilingualResult.zoomOut" = "缩小"; +"bilingualResult.resetZoom" = "重置缩放"; +"bilingualResult.copySuccess" = "已复制到剪贴板"; +"bilingualResult.copyTextSuccess" = "翻译文本已复制"; +"bilingualResult.saveSuccess" = "保存成功"; +"bilingualResult.copyFailed" = "复制图片失败"; +"bilingualResult.saveFailed" = "保存图片失败"; +"bilingualResult.noTextToCopy" = "没有可复制的翻译文本"; + + +/* ======================================== + 文本翻译 (US-003 至 US-010) + ======================================== */ + +/* 文本翻译流程 */ +"textTranslation.phase.idle" = "就绪"; +"textTranslation.phase.translating" = "翻译中..."; +"textTranslation.phase.completed" = "已完成"; +"textTranslation.phase.failed" = "失败"; + +"textTranslation.error.emptyInput" = "没有可翻译的文本"; +"textTranslation.error.translationFailed" = "翻译失败:%@"; +"textTranslation.error.cancelled" = "翻译已取消"; +"textTranslation.error.serviceUnavailable" = "翻译服务不可用"; +"textTranslation.error.insertFailed" = "插入翻译文本失败"; + +"textTranslation.recovery.emptyInput" = "请先选择一些文本"; +"textTranslation.recovery.translationFailed" = "请重试"; +"textTranslation.recovery.serviceUnavailable" = "请检查网络连接后重试"; + +"textTranslation.loading" = "翻译中..."; +"textTranslation.noSelection.title" = "未选择文本"; +"textTranslation.noSelection.message" = "请在任意应用中选择一些文本,然后重试。"; + +/* 翻译并插入 */ +"translateAndInsert.emptyClipboard.title" = "剪贴板为空"; +"translateAndInsert.emptyClipboard.message" = "请先将文本复制到剪贴板,然后使用此快捷键。"; +"translateAndInsert.success.title" = "翻译已插入"; +"translateAndInsert.success.message" = "翻译后的文本已插入到当前输入框中。"; + +/* 语言显示 */ +"language.auto" = "自动检测"; + +/* 通用文本翻译界面 */ +"common.copy" = "复制"; +"common.copied" = "已复制"; +"common.insert" = "插入"; + +/* 文本翻译窗口 */ +"textTranslation.window.title" = "文本翻译"; + +/* 翻译并插入语言设置 */ +"settings.translateAndInsert.language.section" = "翻译并插入语言"; +"settings.translateAndInsert.language.source" = "源语言"; +"settings.translateAndInsert.language.target" = "目标语言"; + + +/* ======================================== + 多 OpenAI 兼容引擎配置 + ======================================== */ + +/* 兼容引擎配置 */ +"engine.compatible.new" = "新建兼容引擎"; +"engine.compatible.description" = "OpenAI 兼容的 API 端点"; +"engine.compatible.displayName" = "显示名称"; +"engine.compatible.displayName.placeholder" = "例如:我的大模型服务"; +"engine.compatible.requireApiKey" = "需要 API 密钥"; +"engine.compatible.add" = "添加兼容引擎"; +"engine.compatible.delete" = "删除此引擎"; +"engine.compatible.useAsEngine" = "设为翻译引擎"; +"engine.compatible.max.reached" = "已达到最多 5 个兼容引擎上限"; + +/* 提示词配置 */ +"prompt.compatible.title" = "兼容引擎"; + + +/* ======================================== + 关于窗口 + ======================================== */ + +"about.title" = "关于 ScreenTranslate"; +"about.app.name" = "ScreenTranslate"; +"about.version.format" = "版本 %@ (%@)"; +"about.copyright" = "版权"; +"about.copyright.value" = "© 2026 保留所有权利"; +"about.license" = "许可证"; +"about.license.value" = "MIT 许可证"; +"about.github.link" = "GitHub: hubo1989/ScreenTranslate"; +"about.check.for.updates" = "检查更新"; +"about.update.checking" = "检查中..."; +"about.update.available" = "有新版本"; +"about.update.uptodate" = "已是最新版本"; +"about.update.failed" = "检查失败"; +"about.acknowledgements" = "致谢"; +"about.acknowledgements.title" = "致谢"; +"about.acknowledgements.intro" = "本软件使用了以下开源库:"; +"about.acknowledgements.upstream" = "基于"; +"about.acknowledgements.author.format" = "作者:%@"; +"about.close" = "关闭"; +"settings.glmocr.mode" = "模式"; +"settings.glmocr.mode.cloud" = "云端"; +"settings.glmocr.mode.local" = "本地"; +"settings.glmocr.local.apiKey.optional" = "本地 MLX-VLM 服务无需 API 密钥"; +"vlm.provider.glmocr.local.description" = "本地 MLX-VLM GLM-OCR 服务"; diff --git a/ScreenTranslate/Services/AccessibilityPermissionChecker.swift b/ScreenTranslate/Services/AccessibilityPermissionChecker.swift new file mode 100644 index 0000000..4814927 --- /dev/null +++ b/ScreenTranslate/Services/AccessibilityPermissionChecker.swift @@ -0,0 +1,27 @@ +import Foundation +import ApplicationServices +import AppKit + +/// Utility for checking and requesting accessibility permission for global hotkeys. +enum AccessibilityPermissionChecker { + /// Checks if the app has accessibility permission. + static var hasPermission: Bool { + AXIsProcessTrusted() + } + + /// Requests accessibility permission by showing system prompt. + /// Returns whether permission is granted after the prompt. + @discardableResult + static func requestPermission() -> Bool { + // Use the string literal directly (kAXTrustedCheckOptionPrompt = "AXTrustedCheckOptionPrompt") + let options: CFDictionary = ["AXTrustedCheckOptionPrompt": true] as CFDictionary + return AXIsProcessTrustedWithOptions(options) + } + + /// Opens System Settings to Accessibility pane. + static func openAccessibilitySettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + NSWorkspace.shared.open(url) + } + } +} diff --git a/ScreenTranslate/Services/AppleTranslationProvider.swift b/ScreenTranslate/Services/AppleTranslationProvider.swift new file mode 100644 index 0000000..42640fa --- /dev/null +++ b/ScreenTranslate/Services/AppleTranslationProvider.swift @@ -0,0 +1,66 @@ +// +// AppleTranslationProvider.swift +// ScreenTranslate +// +// Created for US-010: 创建 TranslationService 编排层 +// + +import Foundation + +/// Wrapper around TranslationEngine to conform to TranslationProvider protocol +@available(macOS 13.0, *) +actor AppleTranslationProvider: TranslationProvider { + nonisolated var id: String { "apple" } + nonisolated var name: String { "Apple Translation" } + + private let engine: TranslationEngine + + init(engine: TranslationEngine = .shared) { + self.engine = engine + } + + var isAvailable: Bool { + get async { true } + } + + func translate( + text: String, + from sourceLanguage: String?, + to targetLanguage: String + ) async throws -> TranslationResult { + guard let target = TranslationLanguage.fromTranslationCode(targetLanguage) else { + throw TranslationProviderError.unsupportedLanguage(targetLanguage) + } + + var config = TranslationEngine.Configuration.default + config.targetLanguage = target + config.sourceLanguage = TranslationLanguage.fromTranslationCode(sourceLanguage) + + do { + return try await engine.translate(text, config: config) + } catch let error as TranslationEngineError { + throw mapEngineError(error) + } + } + + func checkConnection() async -> Bool { + true + } + + private func mapEngineError(_ error: TranslationEngineError) -> TranslationProviderError { + switch error { + case .operationInProgress: + return .translationFailed("Translation operation already in progress") + case .emptyInput: + return .emptyInput + case .timeout: + return .timeout + case .unsupportedLanguagePair(_, let target): + return .unsupportedLanguage(target) + case .languageNotInstalled(let language, _): + return .translationFailed("Language not installed: \(language)") + case .translationFailed(let underlying): + return .translationFailed(underlying.localizedDescription) + } + } +} diff --git a/ScreenTranslate/Services/ClaudeVLMProvider.swift b/ScreenTranslate/Services/ClaudeVLMProvider.swift new file mode 100644 index 0000000..1f55331 --- /dev/null +++ b/ScreenTranslate/Services/ClaudeVLMProvider.swift @@ -0,0 +1,545 @@ +// +// ClaudeVLMProvider.swift +// ScreenTranslate +// +// Created for US-006: Claude Vision Provider +// + +import CoreGraphics +import Foundation +import os + +// MARK: - Claude VLM Provider + +/// VLM provider implementation for Anthropic Claude Vision models +struct ClaudeVLMProvider: VLMProvider, Sendable { + // MARK: - Properties + + let id: String = "claude" + let name: String = "Claude Vision" + let configuration: VLMProviderConfiguration + + /// Default Anthropic API base URL + static let defaultBaseURL = URL(string: "https://api.anthropic.com")! + + /// Default model for vision tasks + static let defaultModel = "claude-sonnet-4-20250514" + + /// Anthropic API version header + private static let apiVersion = "2023-06-01" + + /// Request timeout in seconds + private let timeout: TimeInterval + + private let logger = Logger.translation + + // MARK: - Initialization + + /// Initialize with full configuration + /// - Parameters: + /// - configuration: VLM provider configuration + /// - timeout: Request timeout in seconds (default: 60) + init(configuration: VLMProviderConfiguration, timeout: TimeInterval = 60) { + self.configuration = configuration + self.timeout = timeout + } + + /// Convenience initializer with individual parameters + /// - Parameters: + /// - apiKey: Anthropic API key + /// - baseURL: API base URL (default: Anthropic's official endpoint) + /// - modelName: Model to use (default: claude-sonnet-4-20250514) + /// - timeout: Request timeout in seconds (default: 60) + init( + apiKey: String, + baseURL: URL = ClaudeVLMProvider.defaultBaseURL, + modelName: String = ClaudeVLMProvider.defaultModel, + timeout: TimeInterval = 60 + ) { + self.configuration = VLMProviderConfiguration( + apiKey: apiKey, + baseURL: baseURL, + modelName: modelName + ) + self.timeout = timeout + } + + private func logDebug(_ message: String) { + logger.debug("\(message, privacy: .public)") + } + + private func logWarning(_ message: String) { + logger.warning("\(message, privacy: .public)") + } + + private func logError(_ message: String) { + logger.error("\(message, privacy: .public)") + } + + // MARK: - VLMProvider Protocol + + var isAvailable: Bool { + get async { + !configuration.apiKey.isEmpty + } + } + + /// Maximum number of continuation attempts when response is truncated + private let maxContinuationAttempts = 3 + + func analyze(image: CGImage) async throws -> ScreenAnalysisResult { + guard let imageData = image.jpegData(quality: 0.85), !imageData.isEmpty else { + throw VLMProviderError.imageEncodingFailed + } + + let base64Image = imageData.base64EncodedString() + let imageSize = CGSize(width: image.width, height: image.height) + + // Use multi-turn conversation with continuation support + let vlmResponse = try await analyzeWithContinuation( + base64Image: base64Image, + imageSize: imageSize, + maxAttempts: maxContinuationAttempts + ) + + return vlmResponse.toScreenAnalysisResult(imageSize: imageSize) + } + + /// Performs analysis with automatic continuation on truncation + private func analyzeWithContinuation( + base64Image: String, + imageSize: CGSize, + maxAttempts: Int + ) async throws -> VLMAnalysisResponse { + var allSegments: [VLMTextSegment] = [] + var conversationHistory: [ClaudeMessage] = [ + ClaudeMessage( + role: "user", + content: [ + .image(ClaudeImageContent( + source: ClaudeImageSource( + type: "base64", + mediaType: "image/jpeg", + data: base64Image + ) + )), + .text(VLMPromptTemplate.userPrompt), + ] + ), + ] + + for attempt in 0.. 0 + ) + let responseData = try await executeRequest(request) + + let (content, isTruncated, stopReason) = try extractContentAndStatus(from: responseData) + + logDebug("Attempt \(attempt + 1)/\(maxAttempts): received \(content.count) chars, stop reason: \(stopReason ?? "unknown")") + + // Try to parse this response + do { + let response = try parseVLMContent(content) + + // For continuation requests, filter out duplicates from the beginning + // LLM often repeats some segments when continuing + let newSegments: [VLMTextSegment] + if attempt > 0 { + newSegments = filterDuplicateSegments( + existing: allSegments, + new: response.segments + ) + } else { + newSegments = response.segments + } + + allSegments.append(contentsOf: newSegments) + logDebug("Parsed \(response.segments.count) segments, added \(newSegments.count) new (attempt \(attempt + 1))") + + if !isTruncated { + // Complete - return merged result with deduplication + let deduplicated = deduplicateSegments(allSegments) + logDebug("Complete response received, \(allSegments.count) -> \(deduplicated.count) segments after dedup") + return VLMAnalysisResponse(segments: deduplicated) + } + } catch { + logWarning("Parse error on attempt \(attempt + 1) [\(String(describing: type(of: error)))]") + + // Try partial parsing for truncated response + if isTruncated { + if let partial = try? parsePartialVLMContent(content) { + // Filter duplicates for partial parse too + let newSegments = filterDuplicateSegments( + existing: allSegments, + new: partial.segments + ) + allSegments.append(contentsOf: newSegments) + logDebug("Partial parse recovered \(partial.segments.count) segments, added \(newSegments.count) new") + } + } + + // If not truncated but parse failed, this is a real error + if !isTruncated { + throw error + } + } + + // Response truncated, need to continue + logDebug("Response truncated, requesting continuation") + + // Add assistant's partial response to conversation + conversationHistory.append(ClaudeMessage( + role: "assistant", + content: [.text(content)] + )) + + // Request continuation - ask for remaining segments only + conversationHistory.append(ClaudeMessage( + role: "user", + content: [.text("Continue with the remaining segments only. Do not repeat any segments you've already provided.")] + )) + } + + // Final deduplication before returning + let deduplicated = deduplicateSegments(allSegments) + logWarning("Max continuation attempts reached, \(allSegments.count) -> \(deduplicated.count) segments after dedup") + return VLMAnalysisResponse(segments: deduplicated) + } + + /// Filters out segments from new array that already exist in existing array + private func filterDuplicateSegments( + existing: [VLMTextSegment], + new: [VLMTextSegment] + ) -> [VLMTextSegment] { + VLMTextDeduplicator.filterDuplicates(existing: existing, new: new) + } + + /// Removes duplicate segments from the final result + private func deduplicateSegments(_ segments: [VLMTextSegment]) -> [VLMTextSegment] { + VLMTextDeduplicator.deduplicate(segments) { length, count, threshold in + // Log only safe statistics, not plaintext content + logDebug("Detected overrepresented text: length=\(length), count=\(count), threshold=\(threshold)") + } + } + + /// Extracts content text and truncation status from Claude response + private func extractContentAndStatus(from data: Data) throws -> (content: String, isTruncated: Bool, stopReason: String?) { + if let errorResponse = try? JSONDecoder().decode(ClaudeErrorResponse.self, from: data), + errorResponse.type == "error" { + throw VLMProviderError.invalidResponse(errorResponse.error.message) + } + + let decoder = JSONDecoder() + let claudeResponse = try decoder.decode(ClaudeMessagesResponse.self, from: data) + + guard let contentBlocks = claudeResponse.content, + let textBlock = contentBlocks.first(where: { $0.type == "text" }), + let content = textBlock.text + else { + throw VLMProviderError.invalidResponse("No text content in response") + } + + let isTruncated = claudeResponse.stopReason == "max_tokens" + return (content, isTruncated, claudeResponse.stopReason) + } + + /// Builds request with custom messages and continuation settings + private func buildRequest(messages: [ClaudeMessage], isContinuation: Bool) throws -> URLRequest { + let endpoint = configuration.baseURL.appendingPathComponent("v1/messages") + + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(configuration.apiKey, forHTTPHeaderField: "x-api-key") + request.setValue(Self.apiVersion, forHTTPHeaderField: "anthropic-version") + request.timeoutInterval = timeout + + // Use higher max_tokens for continuation requests to minimize truncation + let maxTokens = isContinuation ? 16384 : 8192 + + let requestBody = ClaudeMessagesRequest( + model: configuration.modelName, + maxTokens: maxTokens, + system: VLMPromptTemplate.systemPrompt, + messages: messages + ) + + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + request.httpBody = try encoder.encode(requestBody) + + return request + } + + // MARK: - Private Methods + + /// Executes the HTTP request with timeout handling + private func executeRequest(_ request: URLRequest) async throws -> Data { + let (data, response): (Data, URLResponse) + + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch let error as URLError { + switch error.code { + case .timedOut: + throw VLMProviderError.networkError("Request timed out") + case .notConnectedToInternet, .networkConnectionLost: + throw VLMProviderError.networkError("No internet connection") + default: + throw VLMProviderError.networkError(error.localizedDescription) + } + } catch { + throw VLMProviderError.networkError(error.localizedDescription) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw VLMProviderError.invalidResponse("Invalid HTTP response") + } + + try handleHTTPStatus(httpResponse, data: data) + + return data + } + + /// Handles HTTP status codes and throws appropriate errors + private func handleHTTPStatus(_ response: HTTPURLResponse, data: Data) throws { + switch response.statusCode { + case 200 ... 299: + return + + case 401: + throw VLMProviderError.authenticationFailed + + case 429: + let retryAfter = parseRetryAfter(from: response, data: data) + let errorMessage = parseErrorMessage(from: data) + throw VLMProviderError.rateLimited(retryAfter: retryAfter, message: errorMessage) + + case 404: + throw VLMProviderError.modelUnavailable(configuration.modelName) + + case 400: + let message = parseErrorMessage(from: data) ?? "Bad request" + throw VLMProviderError.invalidConfiguration(message) + + case 500 ... 599: + let message = parseErrorMessage(from: data) ?? "Server error" + throw VLMProviderError.networkError("Server error (\(response.statusCode)): \(message)") + + default: + let message = parseErrorMessage(from: data) ?? "Unknown error" + throw VLMProviderError.invalidResponse("HTTP \(response.statusCode): \(message)") + } + } + + /// Parses the retry-after value from rate limit response + private func parseRetryAfter(from response: HTTPURLResponse, data: Data) -> TimeInterval? { + if let headerValue = response.value(forHTTPHeaderField: "Retry-After"), + let seconds = Double(headerValue) + { + return seconds + } + + if let errorResponse = try? JSONDecoder().decode(ClaudeErrorResponse.self, from: data), + let retryAfter = errorResponse.error.retryAfter + { + return retryAfter + } + + return nil + } + + /// Parses error message from Claude error response + private func parseErrorMessage(from data: Data) -> String? { + guard let errorResponse = try? JSONDecoder().decode(ClaudeErrorResponse.self, from: data) else { + return nil + } + return errorResponse.error.message + } + + /// Parses the VLM JSON content from assistant message + private func parseVLMContent(_ content: String) throws -> VLMAnalysisResponse { + let cleanedContent = extractJSON(from: content) + + guard let jsonData = cleanedContent.data(using: .utf8) else { + throw VLMProviderError.parsingFailed("Failed to convert content to data") + } + + do { + let response = try JSONDecoder().decode(VLMAnalysisResponse.self, from: jsonData) + return response + } catch { + throw VLMProviderError.parsingFailed( + "Failed to parse VLM response JSON: \(error.localizedDescription). Content: \(cleanedContent.prefix(200))..." + ) + } + } + + /// Attempts to parse partial/truncated VLM content by extracting valid JSON segments + private func parsePartialVLMContent(_ content: String) throws -> VLMAnalysisResponse { + let cleanedContent = extractJSON(from: content) + + // Try to find the last complete textBlock object + // Look for the last complete "}]}" pattern which ends a text block + if let lastCompleteBlockEnd = cleanedContent.range(of: "}", options: .backwards) { + let truncatedContent = String(cleanedContent[.. String { + var text = content.trimmingCharacters(in: .whitespacesAndNewlines) + + if text.hasPrefix("```json") { + text = String(text.dropFirst(7)) + } else if text.hasPrefix("```") { + text = String(text.dropFirst(3)) + } + + if text.hasSuffix("```") { + text = String(text.dropLast(3)) + } + + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +// MARK: - Claude API Request/Response Models + +/// Claude Messages API request structure +private struct ClaudeMessagesRequest: Encodable, Sendable { + let model: String + let maxTokens: Int + let system: String + let messages: [ClaudeMessage] + + enum CodingKeys: String, CodingKey { + case model + case maxTokens = "max_tokens" + case system + case messages + } +} + +/// Claude message with support for multimodal content +private struct ClaudeMessage: Encodable, Sendable { + let role: String + let content: [ClaudeContentBlock] +} + +/// Content block that can be text or image +private enum ClaudeContentBlock: Encodable, Sendable { + case text(String) + case image(ClaudeImageContent) + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .text(let text): + try container.encode(ClaudeTextBlock(type: "text", text: text)) + case .image(let imageContent): + try container.encode(imageContent) + } + } +} + +private struct ClaudeTextBlock: Encodable, Sendable { + let type: String + let text: String +} + +/// Image content structure for Claude vision requests +private struct ClaudeImageContent: Encodable, Sendable { + let type: String = "image" + let source: ClaudeImageSource +} + +/// Image source with base64 data +private struct ClaudeImageSource: Encodable, Sendable { + let type: String + let mediaType: String + let data: String + + enum CodingKeys: String, CodingKey { + case type + case mediaType = "media_type" + case data + } +} + +/// Claude Messages API response structure +private struct ClaudeMessagesResponse: Decodable, Sendable { + let id: String? + let type: String? + let role: String? + let content: [ClaudeResponseContentBlock]? + let model: String? + let stopReason: String? + let usage: ClaudeUsage? + + enum CodingKeys: String, CodingKey { + case id, type, role, content, model + case stopReason = "stop_reason" + case usage + } +} + +private struct ClaudeResponseContentBlock: Decodable, Sendable { + let type: String + let text: String? +} + +private struct ClaudeUsage: Decodable, Sendable { + let inputTokens: Int + let outputTokens: Int + + enum CodingKeys: String, CodingKey { + case inputTokens = "input_tokens" + case outputTokens = "output_tokens" + } +} + +/// Claude error response structure +private struct ClaudeErrorResponse: Decodable, Sendable { + let type: String + let error: ClaudeError +} + +private struct ClaudeError: Decodable, Sendable { + let type: String + let message: String + let retryAfter: TimeInterval? + + enum CodingKeys: String, CodingKey { + case type, message + case retryAfter = "retry_after" + } +} diff --git a/ScreenCapture/Services/ClipboardService.swift b/ScreenTranslate/Services/ClipboardService.swift similarity index 57% rename from ScreenCapture/Services/ClipboardService.swift rename to ScreenTranslate/Services/ClipboardService.swift index a96cd1d..1a9c035 100644 --- a/ScreenCapture/Services/ClipboardService.swift +++ b/ScreenTranslate/Services/ClipboardService.swift @@ -5,14 +5,14 @@ import CoreGraphics /// Service for copying screenshots to the system clipboard. /// Uses NSPasteboard for compatibility with all macOS applications. @MainActor -struct ClipboardService: Sendable { +struct ClipboardService { // MARK: - Public API /// Copies an image with annotations to the system clipboard. /// - Parameters: /// - image: The base image to copy /// - annotations: Annotations to composite onto the image - /// - Throws: ScreenCaptureError.clipboardWriteFailed if the operation fails + /// - Throws: ScreenTranslateError.clipboardWriteFailed if the operation fails func copy(_ image: CGImage, annotations: [Annotation]) throws { // Composite annotations if any exist let finalImage: CGImage @@ -34,13 +34,13 @@ struct ClipboardService: Sendable { // Write both PNG and TIFF for maximum compatibility guard pasteboard.writeObjects([nsImage]) else { - throw ScreenCaptureError.clipboardWriteFailed + throw ScreenTranslateError.clipboardWriteFailed } } /// Copies an image (without annotations) to the system clipboard. /// - Parameter image: The image to copy - /// - Throws: ScreenCaptureError.clipboardWriteFailed if the operation fails + /// - Throws: ScreenTranslateError.clipboardWriteFailed if the operation fails func copy(_ image: CGImage) throws { try copy(image, annotations: []) } @@ -54,6 +54,18 @@ struct ClipboardService: Sendable { ]) } + /// Copies text to the system clipboard. + /// - Parameter text: The text to copy + /// - Throws: ScreenTranslateError.clipboardWriteFailed if the operation fails + func copyText(_ text: String) throws { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + + guard pasteboard.setString(text, forType: .string) else { + throw ScreenTranslateError.clipboardWriteFailed + } + } + // MARK: - Annotation Compositing /// Composites annotations onto an image. @@ -61,7 +73,7 @@ struct ClipboardService: Sendable { /// - annotations: The annotations to draw /// - image: The base image /// - Returns: A new CGImage with annotations rendered - /// - Throws: ScreenCaptureError if compositing fails + /// - Throws: ScreenTranslateError if compositing fails private func compositeAnnotations( _ annotations: [Annotation], onto image: CGImage @@ -80,7 +92,7 @@ struct ClipboardService: Sendable { space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { - throw ScreenCaptureError.clipboardWriteFailed + throw ScreenTranslateError.clipboardWriteFailed } // Draw base image @@ -97,7 +109,7 @@ struct ClipboardService: Sendable { // Create final image guard let result = context.makeImage() else { - throw ScreenCaptureError.clipboardWriteFailed + throw ScreenTranslateError.clipboardWriteFailed } return result @@ -112,12 +124,22 @@ struct ClipboardService: Sendable { switch annotation { case .rectangle(let rect): renderRectangle(rect, in: context, imageHeight: imageHeight) + case .ellipse(let ellipse): + renderEllipse(ellipse, in: context, imageHeight: imageHeight) + case .line(let line): + renderLine(line, in: context, imageHeight: imageHeight) case .freehand(let freehand): renderFreehand(freehand, in: context, imageHeight: imageHeight) case .arrow(let arrow): renderArrow(arrow, in: context, imageHeight: imageHeight) + case .highlight(let highlight): + renderHighlight(highlight, in: context, imageHeight: imageHeight) + case .mosaic(let mosaic): + renderMosaic(mosaic, in: context, imageHeight: imageHeight) case .text(let text): renderText(text, in: context, imageHeight: imageHeight) + case .numberLabel(let label): + renderNumberLabel(label, in: context, imageHeight: imageHeight) } } @@ -243,6 +265,140 @@ struct ClipboardService: Sendable { CTLineDraw(line, context) context.restoreGState() } + + /// Renders an ellipse annotation. + private func renderEllipse( + _ annotation: EllipseAnnotation, + in context: CGContext, + imageHeight: CGFloat + ) { + let rect = CGRect( + x: annotation.rect.origin.x, + y: imageHeight - annotation.rect.origin.y - annotation.rect.height, + width: annotation.rect.width, + height: annotation.rect.height + ) + + if annotation.isFilled { + context.setFillColor(annotation.style.color.cgColor) + context.fillEllipse(in: rect) + } else { + context.setStrokeColor(annotation.style.color.cgColor) + context.setLineWidth(annotation.style.lineWidth) + context.strokeEllipse(in: rect) + } + } + + /// Renders a line annotation. + private func renderLine( + _ annotation: LineAnnotation, + in context: CGContext, + imageHeight: CGFloat + ) { + let start = CGPoint(x: annotation.startPoint.x, y: imageHeight - annotation.startPoint.y) + let end = CGPoint(x: annotation.endPoint.x, y: imageHeight - annotation.endPoint.y) + + context.setStrokeColor(annotation.style.color.cgColor) + context.setLineWidth(annotation.style.lineWidth) + context.setLineCap(.round) + + context.beginPath() + context.move(to: start) + context.addLine(to: end) + context.strokePath() + } + + /// Renders a highlight annotation. + private func renderHighlight( + _ annotation: HighlightAnnotation, + in context: CGContext, + imageHeight: CGFloat + ) { + let rect = CGRect( + x: annotation.rect.origin.x, + y: imageHeight - annotation.rect.origin.y - annotation.rect.height, + width: annotation.rect.width, + height: annotation.rect.height + ) + + let color = annotation.color.cgColor + let alphaColor = CGColor( + red: color.components?[0] ?? 1, + green: color.components?[1] ?? 1, + blue: color.components?[2] ?? 0, + alpha: annotation.opacity + ) + context.setFillColor(alphaColor) + context.fill(rect) + } + + /// Renders a mosaic annotation. + private func renderMosaic( + _ annotation: MosaicAnnotation, + in context: CGContext, + imageHeight: CGFloat + ) { + let rect = CGRect( + x: annotation.rect.origin.x, + y: imageHeight - annotation.rect.origin.y - annotation.rect.height, + width: annotation.rect.width, + height: annotation.rect.height + ) + let blockSize = CGFloat(annotation.blockSize) + + for y in stride(from: rect.minY, to: rect.maxY, by: blockSize) { + for x in stride(from: rect.minX, to: rect.maxX, by: blockSize) { + let blockRect = CGRect( + x: x, + y: y, + width: min(blockSize, rect.maxX - x), + height: min(blockSize, rect.maxY - y) + ) + let gray: CGFloat = ((Int(x / blockSize) + Int(y / blockSize)) % 2 == 0) ? 0.5 : 0.55 + context.setFillColor(CGColor(gray: gray, alpha: 1.0)) + context.fill(blockRect) + } + } + } + + /// Renders a number label annotation. + private func renderNumberLabel( + _ annotation: NumberLabelAnnotation, + in context: CGContext, + imageHeight: CGFloat + ) { + let center = CGPoint( + x: annotation.position.x, + y: imageHeight - annotation.position.y + ) + let radius = annotation.size / 2 + + context.setFillColor(annotation.color.cgColor) + context.fillEllipse(in: CGRect( + x: center.x - radius, + y: center.y - radius, + width: annotation.size, + height: annotation.size + )) + + let font = NSFont.systemFont(ofSize: annotation.size * 0.6, weight: .bold) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: NSColor.white + ] + let text = "\(annotation.number)" + let attributedString = NSAttributedString(string: text, attributes: attributes) + let textSize = attributedString.size() + + context.saveGState() + let line = CTLineCreateWithAttributedString(attributedString) + context.textPosition = CGPoint( + x: center.x - textSize.width / 2, + y: center.y - textSize.height / 2 + ) + CTLineDraw(line, context) + context.restoreGState() + } } // MARK: - Shared Instance diff --git a/ScreenTranslate/Services/GLMOCRVLMProvider.swift b/ScreenTranslate/Services/GLMOCRVLMProvider.swift new file mode 100644 index 0000000..50a93cf --- /dev/null +++ b/ScreenTranslate/Services/GLMOCRVLMProvider.swift @@ -0,0 +1,548 @@ +// +// GLMOCRVLMProvider.swift +// ScreenTranslate +// +// Integrates Zhipu GLM-OCR layout parsing for screenshot text extraction. +// + +import CoreGraphics +import Foundation + +struct GLMOCRVLMProvider: VLMProvider, Sendable { + let id: String = "glm_ocr" + let name: String = "GLM OCR" + let configuration: VLMProviderConfiguration + let mode: GLMOCRMode + + static let defaultBaseURL: URL = { + guard let url = URL(string: "https://open.bigmodel.cn/api/paas/v4") else { + fatalError("Invalid URL literal for GLMOCRVLMProvider.defaultBaseURL") + } + return url + }() + static let defaultModel = "glm-ocr" + static let defaultLocalBaseURL: URL = { + guard let url = URL(string: "http://127.0.0.1:18081") else { + fatalError("Invalid URL literal for GLMOCRVLMProvider.defaultLocalBaseURL") + } + return url + }() + static let defaultLocalModel = "mlx-community/GLM-OCR-bf16" + + static let connectionTestImageDataURI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAE0lEQVR4nGP8//8/AwwwwVl4OQCWbgMF7ZjH1AAAAABJRU5ErkJggg==" + + private let timeout: TimeInterval + + init(configuration: VLMProviderConfiguration, mode: GLMOCRMode = .cloud, timeout: TimeInterval = 60) { + self.configuration = configuration + self.mode = mode + self.timeout = timeout + } + + var isAvailable: Bool { + get async { + switch mode { + case .cloud: + return !configuration.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .local: + return true + } + } + } + + func analyze(image: CGImage) async throws -> ScreenAnalysisResult { + guard let imageData = image.jpegData(quality: 0.9), !imageData.isEmpty else { + throw VLMProviderError.imageEncodingFailed + } + + let imageSize = CGSize(width: image.width, height: image.height) + let dataURI = "data:image/jpeg;base64,\(imageData.base64EncodedString())" + + switch mode { + case .cloud: + let request = try Self.makeLayoutParsingRequest( + baseURL: configuration.baseURL, + apiKey: configuration.apiKey, + modelName: configuration.modelName, + fileDataURI: dataURI, + timeout: timeout + ) + let data = try await executeRequest(request, mode: .cloud) + return try Self.parseResponse(data, fallbackImageSize: imageSize) + case .local: + let request = try Self.makeLocalChatRequest( + baseURL: configuration.baseURL, + apiKey: configuration.apiKey, + modelName: configuration.modelName, + fileDataURI: dataURI, + timeout: timeout + ) + let data = try await executeRequest(request, mode: .local) + return try Self.parseLocalResponse(data, fallbackImageSize: imageSize) + } + } + + static func makeLayoutParsingRequest( + baseURL: URL, + apiKey: String, + modelName: String, + fileDataURI: String, + timeout: TimeInterval + ) throws -> URLRequest { + let trimmedAPIKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedAPIKey.isEmpty else { + throw VLMProviderError.invalidConfiguration("GLM OCR requires an API key.") + } + + let endpoint = baseURL.appendingPathComponent("layout_parsing") + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.timeoutInterval = timeout + request.setValue("Bearer \(trimmedAPIKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = GLMOCRLayoutParsingRequest( + model: modelName.isEmpty ? defaultModel : modelName, + file: fileDataURI, + returnCropImages: false, + needLayoutVisualization: false + ) + request.httpBody = try JSONEncoder().encode(body) + return request + } + + static func makeLocalChatRequest( + baseURL: URL, + apiKey: String, + modelName: String, + fileDataURI: String, + timeout: TimeInterval + ) throws -> URLRequest { + let endpoint = baseURL.appendingPathComponent("chat/completions") + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.timeoutInterval = timeout + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = GLMOCRLocalChatRequest( + model: modelName.isEmpty ? defaultLocalModel : modelName, + messages: [ + GLMOCRLocalChatMessage( + role: "system", + content: .text(VLMPromptTemplate.localModelSystemPrompt) + ), + GLMOCRLocalChatMessage( + role: "user", + content: .vision([ + .text(VLMPromptTemplate.localModelUserPrompt + "\nReturn only valid JSON."), + .imageURL(GLMOCRLocalImageURL(url: fileDataURI)) + ]) + ), + ], + maxTokens: 4096, + temperature: 0.1 + ) + request.httpBody = try JSONEncoder().encode(body) + return request + } + + static func parseResponse(_ data: Data, fallbackImageSize: CGSize) throws -> ScreenAnalysisResult { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + if let errorResponse = try? decoder.decode(GLMOCRAPIErrorResponse.self, from: data), + let message = errorResponse.error.message, + !message.isEmpty { + throw VLMProviderError.invalidResponse(message) + } + + let response: GLMOCRLayoutParsingResponse + do { + response = try decoder.decode(GLMOCRLayoutParsingResponse.self, from: data) + } catch { + throw VLMProviderError.parsingFailed("Failed to decode GLM OCR response: \(error.localizedDescription)") + } + + let segments = response.layoutDetails + .flatMap { $0 } + .compactMap { item in + textSegment(from: item) + } + + let resolvedImageSize = response.dataInfo?.pages.first.map { + CGSize(width: $0.width, height: $0.height) + } ?? fallbackImageSize + + return ScreenAnalysisResult(segments: segments, imageSize: resolvedImageSize) + } + + static func parseLocalResponse(_ data: Data, fallbackImageSize: CGSize) throws -> ScreenAnalysisResult { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + if let errorResponse = try? decoder.decode(GLMOCRLocalErrorResponse.self, from: data) { + throw VLMProviderError.invalidResponse(errorResponse.error.message) + } + + let response: GLMOCRLocalChatResponse + do { + response = try decoder.decode(GLMOCRLocalChatResponse.self, from: data) + } catch { + throw VLMProviderError.parsingFailed("Failed to decode local GLM OCR response: \(error.localizedDescription)") + } + + guard let content = response.choices?.first?.message?.content, + !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw VLMProviderError.invalidResponse("No content in local GLM OCR response") + } + + do { + return try parseLocalContent(content).toScreenAnalysisResult(imageSize: fallbackImageSize) + } catch { + throw VLMProviderError.parsingFailed("Failed to parse local GLM OCR content: \(error.localizedDescription)") + } + } + + private func executeRequest(_ request: URLRequest, mode: GLMOCRMode) async throws -> Data { + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw VLMProviderError.invalidResponse("Invalid HTTP response") + } + + switch httpResponse.statusCode { + case 200: + return data + case 401, 403: + throw VLMProviderError.authenticationFailed + case 429: + throw VLMProviderError.rateLimited( + retryAfter: httpResponse.value(forHTTPHeaderField: "Retry-After").flatMap(TimeInterval.init), + message: parseAPIErrorMessage(from: data, mode: mode) + ) + default: + let message = parseAPIErrorMessage(from: data, mode: mode) ?? "HTTP \(httpResponse.statusCode)" + throw VLMProviderError.invalidResponse(message) + } + } catch let error as VLMProviderError { + throw error + } catch { + throw VLMProviderError.networkError(error.localizedDescription) + } + } + + private func parseAPIErrorMessage(from data: Data, mode: GLMOCRMode) -> String? { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + switch mode { + case .cloud: + if let response = try? decoder.decode(GLMOCRAPIErrorResponse.self, from: data), + let message = response.error.message, + !message.isEmpty { + return message + } + case .local: + if let response = try? decoder.decode(GLMOCRLocalErrorResponse.self, from: data) { + return response.error.message + } + } + return String(data: data, encoding: .utf8) + } + + private static func parseLocalContent(_ content: String) throws -> VLMAnalysisResponse { + let cleanedContent = extractJSON(from: content) + + if let jsonData = cleanedContent.data(using: .utf8), + let response = try? JSONDecoder().decode(VLMAnalysisResponse.self, from: jsonData) { + return response + } + + if let jsonData = cleanedContent.data(using: .utf8), + let textOnlyResponse = try? JSONDecoder().decode(GLMOCRLocalTextOnlyResponse.self, from: jsonData), + let textContent = textOnlyResponse.resolvedText, + let plainTextResponse = parsePlainTextResponse(textContent) { + return plainTextResponse + } + + if let plainTextResponse = parsePlainTextResponse(content) { + return plainTextResponse + } + + throw VLMProviderError.parsingFailed("Content was not valid JSON") + } + + private static func textSegment(from item: GLMOCRLayoutItem) -> TextSegment? { + let text = cleanedContent(from: item) + guard !text.isEmpty else { + return nil + } + + guard item.bbox2D.count == 4 else { + return nil + } + + let x1 = clamp(item.bbox2D[0]) + let y1 = clamp(item.bbox2D[1]) + let x2 = clamp(item.bbox2D[2]) + let y2 = clamp(item.bbox2D[3]) + let width = max(0, x2 - x1) + let height = max(0, y2 - y1) + + guard width > 0, height > 0 else { + return nil + } + + return TextSegment( + text: text, + boundingBox: CGRect(x: x1, y: y1, width: width, height: height), + confidence: 1.0 + ) + } + + private static func cleanedContent(from item: GLMOCRLayoutItem) -> String { + let trimmed = item.content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return "" + } + + switch item.label { + case "image": + return "" + case "table": + return stripHTML(from: trimmed) + default: + return collapseWhitespace(in: trimmed) + } + } + + private static func stripHTML(from string: String) -> String { + let withoutTags = string.replacingOccurrences( + of: "<[^>]+>", + with: " ", + options: .regularExpression + ) + return collapseWhitespace(in: withoutTags) + } + + private static func collapseWhitespace(in string: String) -> String { + string.replacingOccurrences( + of: "\\s+", + with: " ", + options: .regularExpression + ).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func clamp(_ value: CGFloat) -> CGFloat { + min(max(value, 0), 1) + } + + private static func extractJSON(from content: String) -> String { + var text = content.trimmingCharacters(in: .whitespacesAndNewlines) + + if text.hasPrefix("```json") { + text = String(text.dropFirst(7)) + } else if text.hasPrefix("```") { + text = String(text.dropFirst(3)) + } + + if text.hasSuffix("```") { + text = String(text.dropLast(3)) + } + + if let jsonStart = text.firstIndex(of: "{"), jsonStart != text.startIndex { + text = String(text[jsonStart...]) + } + + if let jsonEnd = text.lastIndex(of: "}") { + text = String(text[...jsonEnd]) + } + + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func parsePlainTextResponse(_ content: String) -> VLMAnalysisResponse? { + let lines = content + .trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { line in + guard !line.isEmpty else { return false } + if line == "```" || line.hasPrefix("```") { return false } + if ["{", "}", "[", "]"].contains(line) { return false } + return true + } + + guard !lines.isEmpty else { + return nil + } + + let segments = lines.enumerated().map { index, text in + VLMTextSegment( + text: text, + boundingBox: VLMBoundingBox( + x: 0, + y: CGFloat(index) / CGFloat(max(lines.count, 1)), + width: 1, + height: 1 / CGFloat(max(lines.count, 1)) + ), + confidence: nil + ) + } + + return VLMAnalysisResponse(segments: segments) + } +} + +private struct GLMOCRLayoutParsingRequest: Encodable, Sendable { + let model: String + let file: String + let returnCropImages: Bool + let needLayoutVisualization: Bool + + enum CodingKeys: String, CodingKey { + case model + case file + case returnCropImages = "return_crop_images" + case needLayoutVisualization = "need_layout_visualization" + } +} + +private struct GLMOCRLocalChatRequest: Encodable, Sendable { + let model: String + let messages: [GLMOCRLocalChatMessage] + let maxTokens: Int + let temperature: Double + + enum CodingKeys: String, CodingKey { + case model, messages, temperature + case maxTokens = "max_tokens" + } +} + +private struct GLMOCRLocalTextOnlyResponse: Decodable, Sendable { + let text: String? + let Text: String? + + var resolvedText: String? { + text ?? Text + } +} + +private struct GLMOCRLocalChatMessage: Encodable, Sendable { + let role: String + let content: MessageContent + + enum MessageContent: Sendable { + case text(String) + case vision([VisionContent]) + } + + enum VisionContent: Sendable { + case text(String) + case imageURL(GLMOCRLocalImageURL) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(role, forKey: .role) + + switch content { + case .text(let text): + try container.encode(text, forKey: .content) + case .vision(let contents): + var contentArray = container.nestedUnkeyedContainer(forKey: .content) + for item in contents { + switch item { + case .text(let text): + try contentArray.encode(["type": "text", "text": text]) + case .imageURL(let imageURL): + var itemContainer = contentArray.nestedContainer(keyedBy: VisionCodingKeys.self) + try itemContainer.encode("image_url", forKey: .type) + try itemContainer.encode(imageURL, forKey: .imageUrl) + } + } + } + } + + enum CodingKeys: String, CodingKey { + case role, content + } + + enum VisionCodingKeys: String, CodingKey { + case type + case imageUrl = "image_url" + } +} + +private struct GLMOCRLocalImageURL: Encodable, Sendable { + let url: String + let detail: String + + init(url: String, detail: String = "high") { + self.url = url + self.detail = detail + } +} + +struct GLMOCRLayoutParsingResponse: Decodable, Sendable { + let id: String? + let model: String? + let mdResults: String? + let layoutDetails: [[GLMOCRLayoutItem]] + let dataInfo: GLMOCRDataInfo? +} + +struct GLMOCRLayoutItem: Decodable, Sendable { + let index: Int? + let label: String + let bbox2D: [CGFloat] + let content: String + let height: Int? + let width: Int? +} + +struct GLMOCRDataInfo: Decodable, Sendable { + let numPages: Int? + let pages: [GLMOCRPageInfo] +} + +struct GLMOCRPageInfo: Decodable, Sendable { + let width: CGFloat + let height: CGFloat +} + +private struct GLMOCRAPIErrorResponse: Decodable, Sendable { + let error: GLMOCRAPIErrorBody +} + +private struct GLMOCRAPIErrorBody: Decodable, Sendable { + let message: String? +} + +struct GLMOCRLocalModelsResponse: Decodable, Sendable { + let data: [GLMOCRLocalModel] +} + +struct GLMOCRLocalModel: Decodable, Sendable { + let id: String +} + +private struct GLMOCRLocalChatResponse: Decodable, Sendable { + let choices: [GLMOCRLocalChatChoice]? +} + +private struct GLMOCRLocalChatChoice: Decodable, Sendable { + let message: GLMOCRLocalResponseMessage? +} + +private struct GLMOCRLocalResponseMessage: Decodable, Sendable { + let content: String? +} + +private struct GLMOCRLocalErrorResponse: Decodable, Sendable { + let error: GLMOCRLocalErrorBody +} + +private struct GLMOCRLocalErrorBody: Decodable, Sendable { + let message: String +} diff --git a/ScreenTranslate/Services/HistoryStore.swift b/ScreenTranslate/Services/HistoryStore.swift new file mode 100644 index 0000000..2feed17 --- /dev/null +++ b/ScreenTranslate/Services/HistoryStore.swift @@ -0,0 +1,248 @@ +import Foundation +import AppKit +import CoreGraphics + +/// Manages the translation history with thumbnail generation and persistence. +/// Runs on the main actor for UI integration. +@MainActor +final class HistoryStore: ObservableObject { + // MARK: - Constants + + /// Maximum number of history entries to store + private static let maxHistoryEntries = 50 + + /// Maximum thumbnail dimension in pixels + private static let maxThumbnailSize: CGFloat = 128 + + /// Maximum thumbnail data size in bytes (10KB) + private static let maxThumbnailDataSize = 10 * 1024 + + /// JPEG quality for thumbnail compression + private static let thumbnailQuality: CGFloat = 0.7 + + /// UserDefaults key for history data + private static let historyKey = "ScreenTranslate.translationHistory" + + // MARK: - Properties + + /// The list of translation history entries (newest first) + @Published private(set) var entries: [TranslationHistory] = [] + + /// The current search query + @Published private(set) var searchQuery: String = "" + + /// Filtered entries based on search query + @Published private(set) var filteredEntries: [TranslationHistory] = [] + + /// Whether more entries can be loaded + @Published private(set) var hasMoreEntries: Bool = false + + /// Number of entries currently displayed + @Published private(set) var displayedCount: Int = 50 + + // MARK: - Initialization + + init() { + loadHistory() + updateFilteredEntries() + } + + // MARK: - Public API + + /// Adds a new translation result to the history. + /// - Parameters: + /// - result: The translation result to save + /// - image: Optional screenshot image for thumbnail generation + func add(result: TranslationResult, image: CGImage? = nil) { + let thumbnailData = image.flatMap { generateThumbnail(from: $0) } + + let entry = TranslationHistory.from(result: result, thumbnailData: thumbnailData) + + // Remove existing entry with same content to avoid duplicates + entries.removeAll { existing in + existing.sourceText == result.sourceText && + existing.translatedText == result.translatedText + } + + // Add new entry at the beginning + entries.insert(entry, at: 0) + + // Enforce maximum count + if entries.count > Self.maxHistoryEntries { + entries = Array(entries.prefix(Self.maxHistoryEntries)) + } + + saveHistory() + updateFilteredEntries() + } + + /// Removes a history entry. + /// - Parameter entry: The entry to remove + func remove(_ entry: TranslationHistory) { + entries.removeAll { $0.id == entry.id } + saveHistory() + updateFilteredEntries() + } + + /// Removes the entry at the specified index. + /// - Parameter index: The index of the entry to remove + func remove(at index: Int) { + guard index >= 0 && index < filteredEntries.count else { return } + let entry = filteredEntries[index] + remove(entry) + } + + /// Clears all history entries. + func clear() { + entries.removeAll() + saveHistory() + updateFilteredEntries() + } + + /// Sets the search query and updates filtered entries. + /// - Parameter query: The search string + func search(_ query: String) { + searchQuery = query + updateFilteredEntries() + } + + /// Loads more entries for scrolling. + func loadMore() { + displayedCount += 50 + updateFilteredEntries() + } + + /// Copies the translated text to clipboard. + /// - Parameter entry: The history entry whose translation to copy + func copyTranslation(_ entry: TranslationHistory) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(entry.translatedText, forType: .string) + } + + /// Copies the source text to clipboard. + /// - Parameter entry: The history entry whose source to copy + func copySource(_ entry: TranslationHistory) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(entry.sourceText, forType: .string) + } + + /// Copies both source and translation to clipboard. + /// - Parameter entry: The history entry to copy + func copyBoth(_ entry: TranslationHistory) { + let text = "\(entry.sourceText)\n\n--- \(entry.description) ---\n\n\(entry.translatedText)" + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + + // MARK: - Persistence + + /// Loads history from UserDefaults + private func loadHistory() { + guard let data = UserDefaults.standard.data(forKey: Self.historyKey) else { + entries = [] + return + } + + if let decoded = try? JSONDecoder().decode([TranslationHistory].self, from: data) { + entries = decoded + } else { + entries = [] + } + } + + /// Saves history to UserDefaults + private func saveHistory() { + if let data = try? JSONEncoder().encode(entries) { + UserDefaults.standard.set(data, forKey: Self.historyKey) + } + } + + // MARK: - Filter Management + + /// Updates filtered entries based on search query + private func updateFilteredEntries() { + if searchQuery.isEmpty { + let count = min(displayedCount, entries.count) + filteredEntries = Array(entries.prefix(count)) + } else { + let matched = entries.filter { $0.matches(searchQuery) } + let count = min(displayedCount, matched.count) + filteredEntries = Array(matched.prefix(count)) + } + + hasMoreEntries = filteredEntries.count < entries.count && searchQuery.isEmpty + } + + // MARK: - Thumbnail Generation + + /// Generates a JPEG thumbnail from a CGImage. + /// - Parameter image: The source image + /// - Returns: JPEG data for the thumbnail, or nil if generation fails + private func generateThumbnail(from image: CGImage) -> Data? { + let width = CGFloat(image.width) + let height = CGFloat(image.height) + + // Calculate scaled size maintaining aspect ratio + let scale: CGFloat + if width > height { + scale = Self.maxThumbnailSize / width + } else { + scale = Self.maxThumbnailSize / height + } + + // Only scale down, not up + let finalScale = min(scale, 1.0) + let newWidth = Int(width * finalScale) + let newHeight = Int(height * finalScale) + + // Create thumbnail context + guard let context = CGContext( + data: nil, + width: newWidth, + height: newHeight, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return nil + } + + // Draw scaled image + context.interpolationQuality = .high + context.draw(image, in: CGRect(x: 0, y: 0, width: newWidth, height: newHeight)) + + // Get thumbnail image + guard let thumbnailImage = context.makeImage() else { + return nil + } + + // Convert to JPEG data + let nsImage = NSImage( + cgImage: thumbnailImage, + size: NSSize(width: newWidth, height: newHeight) + ) + guard let tiffData = nsImage.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData), + let jpegData = bitmap.representation( + using: .jpeg, + properties: [.compressionFactor: Self.thumbnailQuality] + ) else { + return nil + } + + // Check size and reduce quality if needed + if jpegData.count > Self.maxThumbnailDataSize { + // Try with lower quality + let lowerQuality: CGFloat = 0.5 + if let reducedData = bitmap.representation(using: .jpeg, properties: [.compressionFactor: lowerQuality]), + reducedData.count <= Self.maxThumbnailDataSize { + return reducedData + } + // If still too large, return nil + return nil + } + + return jpegData + } +} diff --git a/ScreenCapture/Services/HotkeyManager.swift b/ScreenTranslate/Services/HotkeyManager.swift similarity index 95% rename from ScreenCapture/Services/HotkeyManager.swift rename to ScreenTranslate/Services/HotkeyManager.swift index 81d783c..62929d1 100644 --- a/ScreenCapture/Services/HotkeyManager.swift +++ b/ScreenTranslate/Services/HotkeyManager.swift @@ -52,7 +52,7 @@ actor HotkeyManager { /// - modifiers: The modifier flags (Carbon format) /// - handler: The closure to execute when the hotkey is pressed /// - Returns: A registration object that can be used to unregister the hotkey - /// - Throws: ScreenCaptureError.hotkeyRegistrationFailed if registration fails + /// - Throws: ScreenTranslateError.hotkeyRegistrationFailed if registration fails func register( keyCode: UInt32, modifiers: UInt32, @@ -82,7 +82,7 @@ actor HotkeyManager { ) guard status == noErr, let ref = hotKeyRef else { - throw ScreenCaptureError.hotkeyRegistrationFailed(keyCode: keyCode) + throw ScreenTranslateError.hotkeyRegistrationFailed(keyCode: keyCode) } // Store registration and handler @@ -97,7 +97,7 @@ actor HotkeyManager { /// - shortcut: The keyboard shortcut to register /// - handler: The closure to execute when the hotkey is pressed /// - Returns: A registration object that can be used to unregister the hotkey - /// - Throws: ScreenCaptureError.hotkeyRegistrationFailed if registration fails + /// - Throws: ScreenTranslateError.hotkeyRegistrationFailed if registration fails func register( shortcut: KeyboardShortcut, handler: @escaping HotkeyHandler @@ -163,7 +163,7 @@ actor HotkeyManager { ) guard status == noErr else { - throw ScreenCaptureError.hotkeyRegistrationFailed(keyCode: 0) + throw ScreenTranslateError.hotkeyRegistrationFailed(keyCode: 0) } isEventHandlerInstalled = true diff --git a/ScreenTranslate/Services/ImageExporter+AnnotationRendering.swift b/ScreenTranslate/Services/ImageExporter+AnnotationRendering.swift new file mode 100644 index 0000000..26797f2 --- /dev/null +++ b/ScreenTranslate/Services/ImageExporter+AnnotationRendering.swift @@ -0,0 +1,384 @@ +import Foundation +import CoreGraphics +import AppKit +import CoreImage + +// MARK: - Annotation Rendering + +extension ImageExporter { + // MARK: - Shared CIContext for performance + + private static let sharedCIContext = CIContext() + + // MARK: - Annotation Rendering Methods + /// Renders a single annotation into a graphics context. + /// - Parameters: + /// - annotation: The annotation to render + /// - context: The graphics context + /// - imageHeight: The image height (for coordinate transformation) + func renderAnnotation( + _ annotation: Annotation, + in context: CGContext, + imageHeight: CGFloat + ) { + switch annotation { + case .rectangle(let rect): + renderRectangle(rect, in: context, imageHeight: imageHeight) + case .ellipse(let ellipse): + renderEllipse(ellipse, in: context, imageHeight: imageHeight) + case .line(let line): + renderLine(line, in: context, imageHeight: imageHeight) + case .freehand(let freehand): + renderFreehand(freehand, in: context, imageHeight: imageHeight) + case .arrow(let arrow): + renderArrow(arrow, in: context, imageHeight: imageHeight) + case .highlight(let highlight): + renderHighlight(highlight, in: context, imageHeight: imageHeight) + case .mosaic(let mosaic): + renderMosaic(mosaic, in: context, imageHeight: imageHeight) + case .text(let text): + renderText(text, in: context, imageHeight: imageHeight) + case .numberLabel(let numberLabel): + renderNumberLabel(numberLabel, in: context, imageHeight: imageHeight) + } + } + + /// Renders a rectangle annotation. + func renderRectangle( + _ annotation: RectangleAnnotation, + in context: CGContext, + imageHeight: CGFloat + ) { + // Transform from SwiftUI coordinates (origin top-left) to CG coordinates (origin bottom-left) + let rect = CGRect( + x: annotation.rect.origin.x, + y: imageHeight - annotation.rect.origin.y - annotation.rect.height, + width: annotation.rect.width, + height: annotation.rect.height + ) + + if annotation.isFilled { + // Filled rectangle - solid color to hide underlying content + context.setFillColor(annotation.style.color.cgColor) + context.fill(rect) + } else { + // Hollow rectangle - outline only + context.setStrokeColor(annotation.style.color.cgColor) + context.setLineWidth(annotation.style.lineWidth) + context.stroke(rect) + } + } + + /// Renders a freehand annotation. + func renderFreehand( + _ annotation: FreehandAnnotation, + in context: CGContext, + imageHeight: CGFloat + ) { + guard annotation.points.count >= 2 else { return } + + context.setStrokeColor(annotation.style.color.cgColor) + context.setLineWidth(annotation.style.lineWidth) + + // Transform points and draw path + context.beginPath() + let firstPoint = annotation.points[0] + context.move(to: CGPoint(x: firstPoint.x, y: imageHeight - firstPoint.y)) + + for point in annotation.points.dropFirst() { + context.addLine(to: CGPoint(x: point.x, y: imageHeight - point.y)) + } + + context.strokePath() + } + + /// Renders an arrow annotation. + func renderArrow( + _ annotation: ArrowAnnotation, + in context: CGContext, + imageHeight: CGFloat + ) { + // Transform from SwiftUI coordinates (origin top-left) to CG coordinates (origin bottom-left) + let start = CGPoint(x: annotation.startPoint.x, y: imageHeight - annotation.startPoint.y) + let end = CGPoint(x: annotation.endPoint.x, y: imageHeight - annotation.endPoint.y) + let lineWidth = annotation.style.lineWidth + + context.setStrokeColor(annotation.style.color.cgColor) + context.setFillColor(annotation.style.color.cgColor) + context.setLineWidth(lineWidth) + context.setLineCap(.round) + context.setLineJoin(.round) + + // Draw the main line + context.beginPath() + context.move(to: start) + context.addLine(to: end) + context.strokePath() + + // Draw the arrowhead + let arrowHeadLength = max(lineWidth * 4, 12) + let arrowHeadAngle: CGFloat = .pi / 6 + + let dx = end.x - start.x + let dy = end.y - start.y + let angle = atan2(dy, dx) + + let arrowPoint1 = CGPoint( + x: end.x - arrowHeadLength * cos(angle - arrowHeadAngle), + y: end.y - arrowHeadLength * sin(angle - arrowHeadAngle) + ) + let arrowPoint2 = CGPoint( + x: end.x - arrowHeadLength * cos(angle + arrowHeadAngle), + y: end.y - arrowHeadLength * sin(angle + arrowHeadAngle) + ) + + context.beginPath() + context.move(to: end) + context.addLine(to: arrowPoint1) + context.addLine(to: arrowPoint2) + context.closePath() + context.fillPath() + } + + /// Renders a text annotation. + func renderText( + _ annotation: TextAnnotation, + in context: CGContext, + imageHeight: CGFloat + ) { + guard !annotation.content.isEmpty else { return } + + // Create attributed string + let font = NSFont(name: annotation.style.fontName, size: annotation.style.fontSize) + ?? NSFont.systemFont(ofSize: annotation.style.fontSize) + + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: annotation.style.color.nsColor + ] + + let attributedString = NSAttributedString(string: annotation.content, attributes: attributes) + + // Draw text at position (transform Y coordinate) + let position = CGPoint( + x: annotation.position.x, + y: imageHeight - annotation.position.y - annotation.style.fontSize + ) + + // Save context state + context.saveGState() + + // Create line and draw + let line = CTLineCreateWithAttributedString(attributedString) + context.textPosition = position + CTLineDraw(line, context) + + // Restore context state + context.restoreGState() + } + + /// Renders an ellipse annotation. + func renderEllipse( + _ annotation: EllipseAnnotation, + in context: CGContext, + imageHeight: CGFloat + ) { + let rect = CGRect( + x: annotation.rect.origin.x, + y: imageHeight - annotation.rect.origin.y - annotation.rect.height, + width: annotation.rect.width, + height: annotation.rect.height + ) + + if annotation.isFilled { + context.setFillColor(annotation.style.color.cgColor) + context.fillEllipse(in: rect) + } else { + context.setStrokeColor(annotation.style.color.cgColor) + context.setLineWidth(annotation.style.lineWidth) + context.strokeEllipse(in: rect) + } + } + + /// Renders a line annotation. + func renderLine( + _ annotation: LineAnnotation, + in context: CGContext, + imageHeight: CGFloat + ) { + let start = CGPoint(x: annotation.startPoint.x, y: imageHeight - annotation.startPoint.y) + let end = CGPoint(x: annotation.endPoint.x, y: imageHeight - annotation.endPoint.y) + + context.setStrokeColor(annotation.style.color.cgColor) + context.setLineWidth(annotation.style.lineWidth) + context.setLineCap(.round) + + context.beginPath() + context.move(to: start) + context.addLine(to: end) + context.strokePath() + } + + /// Renders a highlight annotation. + func renderHighlight( + _ annotation: HighlightAnnotation, + in context: CGContext, + imageHeight: CGFloat + ) { + let rect = CGRect( + x: annotation.rect.origin.x, + y: imageHeight - annotation.rect.origin.y - annotation.rect.height, + width: annotation.rect.width, + height: annotation.rect.height + ) + + // Convert color to sRGB color space to safely extract RGB components + let color = annotation.color.cgColor + let srgbColor: CGColor + if let srgbColorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let converted = color.converted(to: srgbColorSpace, intent: .defaultIntent, options: nil) { + srgbColor = converted + } else { + // Fallback to original color if sRGB conversion fails + srgbColor = color + } + + // Safely extract RGB components with fallbacks + let components = srgbColor.components ?? [1, 1, 1, 1] + let red = components.count > 0 ? components[0] : 1 + let green = components.count > 1 ? components[1] : 1 + let blue = components.count > 2 ? components[2] : 0 + + let alphaColor = CGColor( + red: red, + green: green, + blue: blue, + alpha: annotation.opacity + ) + context.setFillColor(alphaColor) + context.fill(rect) + } + + /// Renders a mosaic annotation. + func renderMosaic( + _ annotation: MosaicAnnotation, + in context: CGContext, + imageHeight: CGFloat + ) { + let rect = CGRect( + x: annotation.rect.origin.x, + y: imageHeight - annotation.rect.origin.y - annotation.rect.height, + width: annotation.rect.width, + height: annotation.rect.height + ) + + // Try to use real pixelation if source image is available + if let cgImage = context.makeImage() { + let imageSize = CGFloat(cgImage.height) + // Convert rect to image coordinates (origin at bottom-left for Core Image) + let imageRect = CGRect( + x: annotation.rect.origin.x, + y: imageSize - annotation.rect.origin.y - annotation.rect.height, + width: annotation.rect.width, + height: annotation.rect.height + ) + + // Create pixelated version + if let pixelatedCGImage = createPixelatedImage( + from: cgImage, + rect: imageRect, + blockSize: CGFloat(annotation.blockSize) + ) { + // Draw the pixelated image + context.draw(pixelatedCGImage, in: rect) + return + } + } + + // Fallback: draw mosaic blocks (same as preview fallback) + let blockSize = CGFloat(annotation.blockSize) + for y in stride(from: rect.minY, to: rect.maxY, by: blockSize) { + for x in stride(from: rect.minX, to: rect.maxX, by: blockSize) { + let blockRect = CGRect( + x: x, + y: y, + width: min(blockSize, rect.maxX - x), + height: min(blockSize, rect.maxY - y) + ) + // Use alternating gray for mosaic effect + let gray: CGFloat = ((Int(x / blockSize) + Int(y / blockSize)) % 2 == 0) ? 0.5 : 0.55 + context.setFillColor(CGColor(gray: gray, alpha: 1.0)) + context.fill(blockRect) + } + } + } + + /// Creates a pixelated CGImage from the source image in the specified rect + private func createPixelatedImage( + from cgImage: CGImage, + rect: CGRect, + blockSize: CGFloat + ) -> CGImage? { + let ciImage = CIImage(cgImage: cgImage) + + // Apply pixelation using CIPixellate filter + guard let pixellateFilter = CIFilter(name: "CIPixellate") else { + return nil + } + + pixellateFilter.setValue(ciImage, forKey: kCIInputImageKey) + pixellateFilter.setValue(blockSize, forKey: kCIInputScaleKey) + + guard let pixelatedCI = pixellateFilter.outputImage else { + return nil + } + + // Crop to the rect we want to pixelate + let croppedCI = pixelatedCI.cropped(to: rect) + + // Create CGImage from CIImage + return Self.sharedCIContext.createCGImage(croppedCI, from: croppedCI.extent) + } + + /// Renders a number label annotation. + func renderNumberLabel( + _ annotation: NumberLabelAnnotation, + in context: CGContext, + imageHeight: CGFloat + ) { + let center = CGPoint( + x: annotation.position.x, + y: imageHeight - annotation.position.y + ) + let radius = annotation.size / 2 + + // Draw filled circle + context.setFillColor(annotation.color.cgColor) + context.fillEllipse(in: CGRect( + x: center.x - radius, + y: center.y - radius, + width: annotation.size, + height: annotation.size + )) + + // Draw number text + let font = NSFont.systemFont(ofSize: annotation.size * 0.6, weight: .bold) + let textColor: NSColor = .white + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: textColor + ] + let text = "\(annotation.number)" + let attributedString = NSAttributedString(string: text, attributes: attributes) + let textSize = attributedString.size() + + context.saveGState() + let line = CTLineCreateWithAttributedString(attributedString) + context.textPosition = CGPoint( + x: center.x - textSize.width / 2, + y: center.y - textSize.height / 2 + ) + CTLineDraw(line, context) + context.restoreGState() + } +} diff --git a/ScreenTranslate/Services/ImageExporter+TranslationOverlay.swift b/ScreenTranslate/Services/ImageExporter+TranslationOverlay.swift new file mode 100644 index 0000000..5c9b242 --- /dev/null +++ b/ScreenTranslate/Services/ImageExporter+TranslationOverlay.swift @@ -0,0 +1,189 @@ +import Foundation +import CoreGraphics +import AppKit + +// MARK: - Translation Overlay Compositing + +extension ImageExporter { + func compositeTranslations( + _ image: CGImage, + ocrResult: OCRResult, + translations: [TranslationResult] + ) throws -> CGImage { + let width = image.width + let height = image.height + let imageSize = CGSize(width: CGFloat(width), height: CGFloat(height)) + + guard let colorSpace = image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw ScreenTranslateError.exportEncodingFailed(format: .png) + } + + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + + for (index, observation) in ocrResult.observations.enumerated() { + guard index < translations.count else { break } + + let translation = translations[index] + guard !translation.translatedText.isEmpty else { continue } + + let pixelRect = convertNormalizedToPixels( + normalizedRect: observation.boundingBox, + imageSize: imageSize + ) + + let cgRect = CGRect( + x: pixelRect.origin.x, + y: CGFloat(height) - pixelRect.origin.y - pixelRect.height, + width: pixelRect.width, + height: pixelRect.height + ) + + renderTranslationOverlay( + context: context, + text: translation.translatedText, + rect: cgRect, + image: image + ) + } + + guard let result = context.makeImage() else { + throw ScreenTranslateError.exportEncodingFailed(format: .png) + } + + return result + } + + func convertNormalizedToPixels( + normalizedRect: CGRect, + imageSize: CGSize + ) -> CGRect { + CGRect( + x: normalizedRect.origin.x * imageSize.width, + y: normalizedRect.origin.y * imageSize.height, + width: normalizedRect.width * imageSize.width, + height: normalizedRect.height * imageSize.height + ) + } + + func renderTranslationOverlay( + context: CGContext, + text: String, + rect: CGRect, + image: CGImage + ) { + let backgroundColor = sampleBackgroundColor(at: rect, image: image) + let textColor = calculateContrastingColor(for: backgroundColor) + let fontSize = calculateFontSize(for: rect) + + let bgWithAlpha = createColorWithAlpha(backgroundColor, alpha: 0.85) + context.setFillColor(bgWithAlpha) + let backgroundPath = CGPath(roundedRect: rect, cornerWidth: 2, cornerHeight: 2, transform: nil) + context.addPath(backgroundPath) + context.fillPath() + + let font = CTFontCreateWithName(".AppleSystemUIFont" as CFString, fontSize, nil) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: textColor + ] + + let attributedString = NSAttributedString(string: text, attributes: attributes) + let line = CTLineCreateWithAttributedString(attributedString) + + let textBounds = CTLineGetBoundsWithOptions(line, []) + let textX = rect.origin.x + (rect.width - textBounds.width) / 2 + let textY = rect.origin.y + (rect.height - textBounds.height) / 2 + textBounds.height * 0.25 + + context.saveGState() + context.textPosition = CGPoint(x: textX, y: textY) + CTLineDraw(line, context) + context.restoreGState() + } + + func createColorWithAlpha(_ color: CGColor, alpha: CGFloat) -> CGColor { + guard let components = color.components, components.count >= 3 else { + return CGColor(gray: 0, alpha: alpha) + } + return CGColor(red: components[0], green: components[1], blue: components[2], alpha: alpha) + } + + func sampleBackgroundColor(at rect: CGRect, image: CGImage) -> CGColor { + let samplePoints = [ + CGPoint(x: rect.minX + 2, y: rect.minY + 2), + CGPoint(x: rect.maxX - 2, y: rect.minY + 2), + CGPoint(x: rect.minX + 2, y: rect.maxY - 2), + CGPoint(x: rect.maxX - 2, y: rect.maxY - 2) + ] + + var totalRed: CGFloat = 0 + var totalGreen: CGFloat = 0 + var totalBlue: CGFloat = 0 + var validSamples = 0 + + guard let dataProvider = image.dataProvider, + let data = dataProvider.data, + let bytes = CFDataGetBytePtr(data) else { + return CGColor(gray: 0, alpha: 0.7) + } + + let bytesPerPixel = image.bitsPerPixel / 8 + let bytesPerRow = image.bytesPerRow + + for point in samplePoints { + let x = Int(point.x) + let y = image.height - Int(point.y) - 1 + + guard x >= 0, x < image.width, y >= 0, y < image.height else { + continue + } + + let pixelOffset = y * bytesPerRow + x * bytesPerPixel + let red = CGFloat(bytes[pixelOffset]) / 255.0 + let green = CGFloat(bytes[pixelOffset + 1]) / 255.0 + let blue = CGFloat(bytes[pixelOffset + 2]) / 255.0 + + totalRed += red + totalGreen += green + totalBlue += blue + validSamples += 1 + } + + guard validSamples > 0 else { + return CGColor(gray: 0, alpha: 0.7) + } + + return CGColor( + red: totalRed / CGFloat(validSamples), + green: totalGreen / CGFloat(validSamples), + blue: totalBlue / CGFloat(validSamples), + alpha: 1.0 + ) + } + + // W3C luminance formula: 0.299*R + 0.587*G + 0.114*B + func calculateContrastingColor(for backgroundColor: CGColor) -> CGColor { + guard let components = backgroundColor.components, components.count >= 3 else { + return CGColor(gray: 1, alpha: 1) + } + + let luminance = 0.299 * components[0] + 0.587 * components[1] + 0.114 * components[2] + + return luminance > 0.5 + ? CGColor(gray: 0, alpha: 1) + : CGColor(gray: 1, alpha: 1) + } + + func calculateFontSize(for rect: CGRect) -> CGFloat { + let baseFontSize = rect.height * 0.75 + return max(14, min(baseFontSize, 32)) + } +} diff --git a/ScreenTranslate/Services/ImageExporter.swift b/ScreenTranslate/Services/ImageExporter.swift new file mode 100644 index 0000000..b9fc27e --- /dev/null +++ b/ScreenTranslate/Services/ImageExporter.swift @@ -0,0 +1,187 @@ +import Foundation +import CoreGraphics +import AppKit +import UniformTypeIdentifiers + +/// Service for exporting screenshots to PNG or JPEG files. +/// Uses CGImageDestination for efficient image encoding. +struct ImageExporter: Sendable { + // MARK: - Constants + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd 'at' HH.mm.ss" + return formatter + }() + + // MARK: - Public API + + func save( + _ image: CGImage, + annotations: [Annotation], + to url: URL, + format: ExportFormat, + quality: Double = 0.9 + ) throws { + let finalImage: CGImage + if annotations.isEmpty { + finalImage = image + } else { + finalImage = try compositeAnnotations(annotations, onto: image) + } + + try writeImage(finalImage, to: url, format: format, quality: quality) + } + + func generateFilename(format: ExportFormat) -> String { + let timestamp = Self.dateFormatter.string(from: Date()) + return "Screenshot \(timestamp).\(format.fileExtension)" + } + + func generateFileURL(in directory: URL, format: ExportFormat) -> URL { + let filename = generateFilename(format: format) + var url = directory.appendingPathComponent(filename) + + var counter = 1 + while FileManager.default.fileExists(atPath: url.path) { + let baseName = "Screenshot \(Self.dateFormatter.string(from: Date())) (\(counter))" + url = directory.appendingPathComponent("\(baseName).\(format.fileExtension)") + counter += 1 + } + + return url + } + + func estimateFileSize( + for image: CGImage, + format: ExportFormat, + quality: Double = 0.9 + ) -> Int { + let pixelCount = image.width * image.height + + switch format { + case .png: + return pixelCount * 4 + case .jpeg: + let bytesPerPixel = 0.5 + (0.5 * quality) + return Int(Double(pixelCount) * bytesPerPixel) + case .heic: + let bytesPerPixel = 0.3 + (0.3 * quality) + return Int(Double(pixelCount) * bytesPerPixel) + } + } + + // MARK: - Annotation Compositing + + func compositeAnnotations( + _ annotations: [Annotation], + onto image: CGImage + ) throws -> CGImage { + let width = image.width + let height = image.height + + guard let colorSpace = image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw ScreenTranslateError.exportEncodingFailed(format: .png) + } + + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + context.setLineCap(.round) + context.setLineJoin(.round) + + for annotation in annotations { + renderAnnotation(annotation, in: context, imageHeight: CGFloat(height)) + } + + guard let result = context.makeImage() else { + throw ScreenTranslateError.exportEncodingFailed(format: .png) + } + + return result + } + + // MARK: - Save with Translations + + func saveWithTranslations( + _ image: CGImage, + annotations: [Annotation], + ocrResult: OCRResult?, + translations: [TranslationResult], + to url: URL, + format: ExportFormat, + quality: Double = 0.9 + ) throws { + var finalImage = image + + if !annotations.isEmpty { + finalImage = try compositeAnnotations(annotations, onto: finalImage) + } + + if let ocrResult = ocrResult, !translations.isEmpty { + finalImage = try compositeTranslations(finalImage, ocrResult: ocrResult, translations: translations) + } + + try writeImage(finalImage, to: url, format: format, quality: quality) + } + + // MARK: - Private Helpers + + private func writeImage( + _ image: CGImage, + to url: URL, + format: ExportFormat, + quality: Double + ) throws { + let directory = url.deletingLastPathComponent() + guard FileManager.default.isWritableFile(atPath: directory.path) else { + throw ScreenTranslateError.invalidSaveLocation(directory) + } + + let estimatedSize = Int64(image.width * image.height * 4) + do { + let resourceValues = try directory.resourceValues(forKeys: [.volumeAvailableCapacityKey]) + if let availableCapacity = resourceValues.volumeAvailableCapacity, + Int64(availableCapacity) < estimatedSize { + throw ScreenTranslateError.diskFull + } + } catch let error as ScreenTranslateError { + throw error + } catch { + // Ignore disk space check errors, proceed with save + } + + guard let destination = CGImageDestinationCreateWithURL( + url as CFURL, + format.uti.identifier as CFString, + 1, + nil + ) else { + throw ScreenTranslateError.exportEncodingFailed(format: format) + } + + var options: [CFString: Any] = [:] + if format == .jpeg || format == .heic { + options[kCGImageDestinationLossyCompressionQuality] = quality + } + + CGImageDestinationAddImage(destination, image, options as CFDictionary) + + guard CGImageDestinationFinalize(destination) else { + throw ScreenTranslateError.exportEncodingFailed(format: format) + } + } +} + +// MARK: - Shared Instance + +extension ImageExporter { + static let shared = ImageExporter() +} diff --git a/ScreenTranslate/Services/MTranServerEngine.swift b/ScreenTranslate/Services/MTranServerEngine.swift new file mode 100644 index 0000000..a56b61e --- /dev/null +++ b/ScreenTranslate/Services/MTranServerEngine.swift @@ -0,0 +1,446 @@ +import Foundation +import os.log + +actor MTranServerEngine: TranslationProvider { + // MARK: - TranslationProvider Properties + + nonisolated let id = "mtranserver" + nonisolated let name = "MTransServer" + + var isAvailable: Bool { + get async { await checkConnection() } + } + + nonisolated var configuration: Configuration { .default } + + // MARK: - Properties + + static let shared = MTranServerEngine() + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ScreenTranslate", category: "MTranServerEngine") + + // MARK: - Configuration + + /// MTranServer configuration options + struct Configuration: Sendable { + /// Server address (e.g., "localhost" or "192.168.1.100") + var serverAddress: String + + /// Server port (default 8989) + var serverPort: Int + + /// Request timeout in seconds + var timeout: TimeInterval + + /// Whether to automatically detect source language + var autoDetectSourceLanguage: Bool + + /// Default configuration from UserDefaults + static var `default`: Configuration { + // Read directly from UserDefaults to avoid MainActor isolation issues + let defaults = UserDefaults.standard + let prefix = "ScreenTranslate." + let host = defaults.string(forKey: prefix + "mtranServerHost") ?? "localhost" + let port = defaults.object(forKey: prefix + "mtranServerPort") as? Int ?? 8989 + return Configuration( + serverAddress: host, + serverPort: port, + timeout: 10.0, + autoDetectSourceLanguage: true + ) + } + } + + // MARK: - Initialization + + private init() {} + + // MARK: - Public API + + /// Translates text using MTranServer. + /// - Parameters: + /// - text: The text to translate + /// - sourceLanguage: Source language code (e.g., "en", "zh") + /// - targetLanguage: Target language code (e.g., "en", "zh") + /// - config: Translation configuration (uses default if not specified) + /// - Returns: TranslationResult containing translated text + /// - Throws: MTranServerError if translation fails + func translate( + _ text: String, + from sourceLanguage: String? = nil, + to targetLanguage: String, + config: Configuration = .default + ) async throws -> TranslationResult { + logger.info("Starting translation: \(text.count) chars to \(targetLanguage)") + logger.info("Config: \(config.serverAddress):\(config.serverPort)") + + // Reset cache to ensure we check with current settings + MTranServerChecker.resetCache() + guard MTranServerChecker.isAvailable else { + logger.error("MTranServer not available") + throw MTranServerError.notAvailable + } + + // Validate input + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw MTranServerError.emptyInput + } + + let effectiveSourceLanguage = resolveSourceLanguage( + sourceLanguage, + autoDetect: config.autoDetectSourceLanguage + ) + + // Build request + let url = try buildURL(config: config) + logger.info("Translation URL: \(url.absoluteString)") + let normalizedTarget = normalizeLanguageCode(targetLanguage) + let jsonData = try buildRequestBody(text: text, from: effectiveSourceLanguage, to: normalizedTarget) + + // Perform request with timeout + do { + let result = try await performTranslationRequest( + url: url, + jsonData: jsonData, + timeout: config.timeout + ) + logger.info("Translation successful: \(result.translatedText.count) chars") + return TranslationResult( + sourceText: text, + translatedText: result.translatedText, + sourceLanguage: result.detectedLanguage ?? effectiveSourceLanguage, + targetLanguage: targetLanguage + ) + } catch { + logger.error("Translation failed: \(error.localizedDescription)") + throw error + } + } + + /// Translates text with automatic language detection. + /// - Parameters: + /// - text: The text to translate + /// - targetLanguage: Target language code + /// - Returns: TranslationResult containing translated text + /// - Throws: MTranServerError if translation fails + func translate(_ text: String, to targetLanguage: String) async throws -> TranslationResult { + try await translate(text, from: nil, to: targetLanguage, config: .default) + } + + // MARK: - TranslationProvider Protocol + + func translate( + text: String, + from sourceLanguage: String?, + to targetLanguage: String + ) async throws -> TranslationResult { + try await translate(text, from: sourceLanguage, to: targetLanguage, config: .default) + } + + func translate( + texts: [String], + from sourceLanguage: String?, + to targetLanguage: String + ) async throws -> [TranslationResult] { + guard !texts.isEmpty else { return [] } + + var results: [TranslationResult] = [] + results.reserveCapacity(texts.count) + + for text in texts { + let result = try await translate( + text, + from: sourceLanguage, + to: targetLanguage, + config: .default + ) + results.append(result) + } + + return results + } + + func checkConnection() async -> Bool { + MTranServerChecker.resetCache() + return MTranServerChecker.isAvailable + } + + // MARK: - Private Methods + + /// Resolves the effective source language for translation + private func resolveSourceLanguage(_ source: String?, autoDetect: Bool) -> String { + if let source = source, !source.isEmpty { + return normalizeLanguageCode(source) + } + return "auto" + } + + /// Normalizes language codes for MTranServer compatibility + /// MTranServer expects simple codes like "zh", "en" rather than "zh-Hans", "en-US" + private func normalizeLanguageCode(_ code: String) -> String { + let lowercased = code.lowercased() + // Map common variants to simple codes + switch lowercased { + case "zh-hans", "zh-cn", "zh_hans", "zh_cn": + return "zh" + case "zh-hant", "zh-tw", "zh_hant", "zh_tw": + return "zh-TW" + case "en-us", "en-gb", "en_us", "en_gb": + return "en" + case "ja-jp", "ja_jp": + return "ja" + case "ko-kr", "ko_kr": + return "ko" + default: + // Extract base language code (e.g., "en-US" -> "en") + if let hyphenIndex = lowercased.firstIndex(of: "-") { + return String(lowercased[.. URL { + // Normalize localhost to 127.0.0.1 to avoid IPv6 resolution issues + let host = config.serverAddress == "localhost" ? "127.0.0.1" : config.serverAddress + let urlString = "http://\(host):\(config.serverPort)/translate" + guard let url = URL(string: urlString) else { + throw MTranServerError.invalidURL + } + return url + } + + /// Builds the JSON request body for translation + private func buildRequestBody(text: String, from: String, to: String) throws -> Data { + let requestBody: [String: Any] = [ + "text": text, + "from": from, + "to": to + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody) else { + throw MTranServerError.invalidRequest + } + return jsonData + } + + /// Performs the translation request with timeout handling + private func performTranslationRequest( + url: URL, + jsonData: Data, + timeout: TimeInterval + ) async throws -> TranslationResponse { + try await withThrowingTaskGroup(of: Result.self) { group in + // Translation task + group.addTask { [jsonData, url] in + await self.executeTranslationRequest(url: url, jsonData: jsonData) + } + + // Timeout task + _ = group.addTaskUnlessCancelled { [timeout] in + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + return .failure(MTranServerError.timeout) + } + + // Wait for first completed task + guard let result = try await group.next() else { + throw MTranServerError.timeout + } + group.cancelAll() + return try result.get() + } + } + + /// Executes the HTTP request to MTranServer + private func executeTranslationRequest( + url: URL, + jsonData: Data + ) async -> Result { + do { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = jsonData + + // Debug: log request body + if let jsonString = String(data: jsonData, encoding: .utf8) { + logger.debug("Request body: \(jsonString)") + } + + let (data, response) = try await URLSession.shared.data(for: request) + + // Debug: log response + if let responseString = String(data: data, encoding: .utf8) { + logger.debug("Response body: \(responseString)") + } + + guard let httpResponse = response as? HTTPURLResponse else { + logger.error("Invalid response type") + return .failure(MTranServerError.invalidResponse) + } + + logger.debug("Response status code: \(httpResponse.statusCode)") + + guard httpResponse.statusCode == 200 else { + if httpResponse.statusCode == 503 { + return .failure(MTranServerError.serviceUnavailable) + } + return .failure(MTranServerError.httpError(statusCode: httpResponse.statusCode)) + } + + let decoded = try JSONDecoder().decode(TranslationResponse.self, from: data) + return .success(decoded) + } catch let error as MTranServerError { + return .failure(error) + } catch { + logger.error("Request failed: \(error)") + return .failure(MTranServerError.requestFailed(underlying: error)) + } + } +} + +// MARK: - Translation Response + +/// MTranServer API response structure +private struct TranslationResponse: Codable, Sendable { + let translatedText: String + let detectedLanguage: String? + + enum CodingKeys: String, CodingKey { + case translatedText = "translated_text" + case detectedLanguage = "detected_language" + case result = "result" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let text = try? container.decode(String.self, forKey: .translatedText) { + translatedText = text + } else if let text = try? container.decode(String.self, forKey: .result) { + translatedText = text + } else { + throw DecodingError.keyNotFound( + CodingKeys.translatedText, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Neither 'translated_text' nor 'result' field found" + ) + ) + } + + detectedLanguage = try? container.decode(String.self, forKey: .detectedLanguage) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(translatedText, forKey: .translatedText) + try? container.encode(detectedLanguage, forKey: .detectedLanguage) + } +} + +// MARK: - MTranServer Errors + +/// Errors that can occur during MTranServer operations +enum MTranServerError: LocalizedError, Sendable { + /// MTranServer is not available + case notAvailable + + /// Translation operation is already in progress + case operationInProgress + + /// The input text is empty + case emptyInput + + /// Invalid URL constructed + case invalidURL + + /// Invalid request format + case invalidRequest + + /// Invalid response from server + case invalidResponse + + /// Request timeout + case timeout + + /// Service unavailable (HTTP 503) + case serviceUnavailable + + /// HTTP error with status code + case httpError(statusCode: Int) + + /// Request failed with underlying error + case requestFailed(underlying: any Error) + + var errorDescription: String? { + switch self { + case .notAvailable: + return NSLocalizedString("error.mtran.not.available", comment: "") + case .operationInProgress: + return NSLocalizedString("error.translation.in.progress", comment: "") + case .emptyInput: + return NSLocalizedString("error.translation.empty.input", comment: "") + case .invalidURL: + return NSLocalizedString("error.mtran.invalid.url", comment: "") + case .invalidRequest: + return NSLocalizedString("error.mtran.invalid.request", comment: "") + case .invalidResponse: + return NSLocalizedString( + "error.mtran.invalid.response", + comment: "" + ) + case .timeout: + return NSLocalizedString("error.translation.timeout", comment: "") + case .serviceUnavailable: + return NSLocalizedString( + "error.mtran.service.unavailable", + comment: "" + ) + case .httpError(let code): + return String( + format: NSLocalizedString("error.mtran.http.error", comment: ""), + code + ) + case .requestFailed: + return NSLocalizedString("error.translation.failed", comment: "") + } + } + + var recoverySuggestion: String? { + switch self { + case .notAvailable: + return NSLocalizedString( + "error.mtran.not.available.recovery", + comment: "" + ) + case .operationInProgress: + return NSLocalizedString("error.translation.in.progress.recovery", comment: "") + case .emptyInput: + return NSLocalizedString("error.translation.empty.input.recovery", comment: "") + case .invalidURL: + return NSLocalizedString("error.mtran.invalid.url.recovery", comment: "") + case .invalidRequest: + return NSLocalizedString("error.mtran.invalid.request.recovery", comment: "") + case .invalidResponse: + return NSLocalizedString( + "error.mtran.invalid.response.recovery", + comment: "" + ) + case .timeout: + return NSLocalizedString("error.translation.timeout.recovery", comment: "") + case .serviceUnavailable: + return NSLocalizedString( + "error.mtran.service.unavailable.recovery", + comment: "" + ) + case .httpError: + return NSLocalizedString("error.mtran.http.error.recovery", comment: "") + case .requestFailed: + return NSLocalizedString("error.translation.failed.recovery", comment: "") + } + } +} diff --git a/ScreenTranslate/Services/OCREngine.swift b/ScreenTranslate/Services/OCREngine.swift new file mode 100644 index 0000000..1e99400 --- /dev/null +++ b/ScreenTranslate/Services/OCREngine.swift @@ -0,0 +1,334 @@ +import Foundation +import Vision +import CoreGraphics +import os.signpost +import os.log + +/// Actor responsible for performing OCR on images using the Vision framework. +/// Thread-safe, async text recognition with support for multiple languages. +actor OCREngine { + // MARK: - Performance Logging + + private static let performanceLog = OSLog( + subsystem: Bundle.main.bundleIdentifier ?? "ScreenCapture", + category: .pointsOfInterest + ) + + private static let signpostID = OSSignpostID(log: performanceLog) + + // MARK: - Properties + + /// Shared instance for app-wide OCR operations + static let shared = OCREngine() + + /// Supported recognition languages + private var supportedLanguages: Set = [] + + /// Whether an OCR operation is currently in progress + private var isProcessing = false + + // MARK: - Recognition Language + + /// Text recognition languages supported by Vision framework + enum RecognitionLanguage: String, CaseIterable, Sendable { + case english = "en-US" + case chineseSimplified = "zh-Hans" + case chineseTraditional = "zh-Hant" + case japanese = "ja-JP" + case korean = "ko-KR" + case french = "fr-FR" + case german = "de-DE" + case spanish = "es-ES" + case italian = "it-IT" + case portuguese = "pt-BR" + case russian = "ru-RU" + case arabic = "ar" + case hindi = "hi-IN" + case thai = "th-TH" + case vietnamese = "vi-VN" + + /// The VNRecognizeTextRequest revision for this language + var visionLanguage: String { + rawValue + } + + /// Localized display name + var localizedName: String { + switch self { + case .english: return NSLocalizedString("lang.english", comment: "") + case .chineseSimplified: return NSLocalizedString("lang.chinese.simplified", comment: "") + case .chineseTraditional: return NSLocalizedString("lang.chinese.traditional", comment: "") + case .japanese: return NSLocalizedString("lang.japanese", comment: "") + case .korean: return NSLocalizedString("lang.korean", comment: "") + case .french: return NSLocalizedString("lang.french", comment: "") + case .german: return NSLocalizedString("lang.german", comment: "") + case .spanish: return NSLocalizedString("lang.spanish", comment: "") + case .italian: return NSLocalizedString("lang.italian", comment: "") + case .portuguese: return NSLocalizedString("lang.portuguese", comment: "") + case .russian: return NSLocalizedString("lang.russian", comment: "") + case .arabic: return NSLocalizedString("lang.arabic", comment: "") + case .hindi: return NSLocalizedString("lang.hindi", comment: "") + case .thai: return NSLocalizedString("lang.thai", comment: "") + case .vietnamese: return NSLocalizedString("lang.vietnamese", comment: "") + } + } + } + + // MARK: - Configuration + + /// OCR configuration options + struct Configuration: Sendable { + /// Recognition languages (empty for auto-detection) + var languages: Set + + /// Minimum confidence threshold (0.0 to 1.0) + var minimumConfidence: Float + + /// Whether to use automatic language detection + var useAutoLanguageDetection: Bool + + /// Recognition level (higher = more accurate but slower) + var recognitionLevel: RecognitionLevel + + /// Whether to prioritize speed over accuracy + var prefersFastRecognition: Bool + + static let `default` = Configuration( + languages: [], + minimumConfidence: 0.0, + useAutoLanguageDetection: true, + recognitionLevel: .accurate, + prefersFastRecognition: false + ) + } + + /// Recognition accuracy level + enum RecognitionLevel: Sendable { + case fast + case accurate + + var visionLevel: VNRequestTextRecognitionLevel { + switch self { + case .fast: return .fast + case .accurate: return .accurate + } + } + } + + // MARK: - Initialization + + private init() {} + + // MARK: - Public API + + /// Performs OCR on a CGImage with the specified configuration. + /// - Parameters: + /// - image: The image to process + /// - config: OCR configuration (uses default if not specified) + /// - Returns: OCRResult containing all recognized text + /// - Throws: OCRError if recognition fails + func recognize( + _ image: CGImage, + config: Configuration = .default + ) async throws -> OCRResult { + // Prevent concurrent OCR operations + guard !isProcessing else { + throw OCREngineError.operationInProgress + } + isProcessing = true + defer { isProcessing = false } + + // Validate image + guard image.width > 0 && image.height > 0 else { + throw OCREngineError.invalidImage + } + + let imageSize = CGSize(width: image.width, height: image.height) + + // Create the request + let request = createRecognitionRequest(config: config) + + // Perform recognition with signpost for profiling + os_signpost(.begin, log: Self.performanceLog, name: "OCRRecognition", signpostID: Self.signpostID) + let startTime = CFAbsoluteTimeGetCurrent() + + let handler = VNImageRequestHandler(cgImage: image, options: [:]) + + do { + try handler.perform([request]) + } catch { + os_signpost(.end, log: Self.performanceLog, name: "OCRRecognition", signpostID: Self.signpostID) + throw OCREngineError.recognitionFailed(underlying: error) + } + + let duration = (CFAbsoluteTimeGetCurrent() - startTime) * 1000 + os_signpost(.end, log: Self.performanceLog, name: "OCRRecognition", signpostID: Self.signpostID) + + #if DEBUG + os_log("OCR recognition completed in %.1fms", log: OSLog.default, type: .info, duration) + #endif + + // Extract results + guard let observations = request.results else { + return OCRResult.empty(imageSize: imageSize) + } + + // Convert to OCRText + let texts = observations.compactMap { obs in + OCRText.from(obs, imageSize: imageSize) + } + + // Filter by confidence + let filteredTexts = texts.filter { $0.confidence >= config.minimumConfidence } + + return OCRResult( + observations: filteredTexts, + imageSize: imageSize + ) + } + + /// Performs OCR on a CGImage with automatic language detection. + /// - Parameter image: The image to process + /// - Returns: OCRResult containing all recognized text + /// - Throws: OCRError if recognition fails + func recognize(_ image: CGImage) async throws -> OCRResult { + try await recognize(image, config: .default) + } + + /// Performs OCR with specific languages. + /// - Parameters: + /// - image: The image to process + /// - languages: Set of languages to recognize + /// - Returns: OCRResult containing all recognized text + /// - Throws: OCRError if recognition fails + func recognize( + _ image: CGImage, + languages: Set + ) async throws -> OCRResult { + var config = Configuration.default + config.languages = languages + config.useAutoLanguageDetection = languages.isEmpty + return try await recognize(image, config: config) + } + + // MARK: - Language Detection + + /// Detects the primary language in an image. + /// - Parameter image: The image to analyze + /// - Returns: Detected language, or nil if detection failed + func detectLanguage(in image: CGImage) async -> RecognitionLanguage? { + // Try each language and return the one with the best results + let languagesToTest: [RecognitionLanguage] = [ + .english, + .chineseSimplified, + .chineseTraditional, + .japanese, + .korean + ] + + var bestLanguage: RecognitionLanguage? + var bestConfidence: Float = 0.0 + + for language in languagesToTest { + do { + var config = Configuration.default + config.languages = [language] + config.useAutoLanguageDetection = false + config.recognitionLevel = .fast + config.prefersFastRecognition = true + config.minimumConfidence = 0.3 + + let result = try await recognize(image, config: config) + + if result.hasResults { + let avgConfidence = result.observations + .map(\.confidence) + .reduce(0, +) / Float(result.observations.count) + + if avgConfidence > bestConfidence { + bestConfidence = avgConfidence + bestLanguage = language + } + } + } catch { + // Try next language + continue + } + } + + return bestLanguage + } + + // MARK: - Private Methods + + /// Creates a configured VNRecognizeTextRequest + private func createRecognitionRequest(config: Configuration) -> VNRecognizeTextRequest { + let request = VNRecognizeTextRequest { _, _ in } + + // Set recognition level + request.recognitionLevel = config.recognitionLevel.visionLevel + + // Enable automatic language detection if requested + if config.useAutoLanguageDetection { + request.usesLanguageCorrection = true + } else { + // Set specific languages + request.recognitionLanguages = Array(config.languages).map(\.visionLanguage) + } + + // Enable text recognition for non-horizontal text + request.usesLanguageCorrection = true + + return request + } +} + +// MARK: - OCR Engine Errors + +/// Errors that can occur during OCR operations +enum OCREngineError: LocalizedError, Sendable { + /// OCR operation is already in progress + case operationInProgress + + /// The provided image is invalid or empty + case invalidImage + + /// Text recognition failed with an underlying error + case recognitionFailed(underlying: any Error) + + /// No languages are available for recognition + case noLanguagesAvailable + + /// The selected OCR engine is not available + case engineNotAvailable + + var errorDescription: String? { + switch self { + case .operationInProgress: + return NSLocalizedString("error.ocr.in.progress", comment: "") + case .invalidImage: + return NSLocalizedString("error.ocr.invalid.image", comment: "") + case .recognitionFailed: + return NSLocalizedString("error.ocr.recognition.failed", comment: "") + case .noLanguagesAvailable: + return NSLocalizedString("error.ocr.no.languages", comment: "") + case .engineNotAvailable: + return NSLocalizedString("error.ocr.engine.not.available", comment: "") + } + } + + var recoverySuggestion: String? { + switch self { + case .operationInProgress: + return NSLocalizedString("error.ocr.in.progress.recovery", comment: "") + case .invalidImage: + return NSLocalizedString("error.ocr.invalid.image.recovery", comment: "") + case .recognitionFailed: + return NSLocalizedString("error.ocr.recognition.failed.recovery", comment: "") + case .noLanguagesAvailable: + return NSLocalizedString("error.ocr.no.languages.recovery", comment: "") + case .engineNotAvailable: + return NSLocalizedString("error.ocr.engine.not.available.recovery", comment: "") + } + } +} diff --git a/ScreenTranslate/Services/OCREngineProtocol.swift b/ScreenTranslate/Services/OCREngineProtocol.swift new file mode 100644 index 0000000..a78430b --- /dev/null +++ b/ScreenTranslate/Services/OCREngineProtocol.swift @@ -0,0 +1,109 @@ +import Foundation +import CoreGraphics +import os + +/// Unified OCR engine protocol +/// All OCR engine implementations must conform to this protocol +protocol AnyOCREngine: Sendable { + /// Performs OCR on a CGImage + /// - Parameter image: The image to process + /// - Returns: OCRResult containing all recognized text + /// - Throws: An error if recognition fails + func recognize(_ image: CGImage) async throws -> OCRResult +} + +/// OCR service that routes to the appropriate engine based on user settings +actor OCRService { + /// Shared instance + static let shared = OCRService() + + /// Vision engine (built-in) + private let visionEngine = OCREngine.shared + + /// PaddleOCR engine (optional) + private let paddleOCREngine = PaddleOCREngine.shared + + private init() {} + + /// Performs OCR using the currently selected engine + /// - Parameter image: The image to process + /// - Returns: OCRResult containing all recognized text + /// - Throws: An error if recognition fails + func recognize(_ image: CGImage) async throws -> OCRResult { + let engineType = await AppSettings.shared.ocrEngine + + switch engineType { + case .vision: + return try await visionEngine.recognize(image) + case .paddleOCR: + guard await paddleOCREngine.isAvailable else { + throw OCREngineError.engineNotAvailable + } + return try await paddleOCREngine.recognize(image) + } + } + + /// Performs OCR with specific languages using the currently selected engine + /// - Parameters: + /// - image: The image to process + /// - languages: Set of Vision recognition languages (for Vision engine) + /// - Returns: OCRResult containing all recognized text + /// - Throws: An error if recognition fails + func recognize( + _ image: CGImage, + languages: Set + ) async throws -> OCRResult { + let engineType = await AppSettings.shared.ocrEngine + Logger.ocr.info("Engine type: \(String(describing: engineType)), image size: \(image.width)x\(image.height)") + + switch engineType { + case .vision: + Logger.ocr.info("Using Vision engine") + return try await visionEngine.recognize(image, languages: languages) + case .paddleOCR: + Logger.ocr.info("Using PaddleOCR engine") + let isAvailable = await paddleOCREngine.isAvailable + Logger.ocr.info("PaddleOCR available: \(isAvailable)") + guard isAvailable else { + Logger.ocr.error("PaddleOCR not available, throwing error") + throw OCREngineError.engineNotAvailable + } + let paddleLanguages = convertToPaddleOCRLanguages(languages) + Logger.ocr.info("PaddleOCR languages: \(String(describing: paddleLanguages))") + let result = try await paddleOCREngine.recognize(image, languages: paddleLanguages) + Logger.ocr.info("PaddleOCR result: \(result.observations.count) observations") + return result + } + } + + /// Converts Vision RecognitionLanguage to PaddleOCR Language + private func convertToPaddleOCRLanguages( + _ languages: Set + ) -> Set { + var result: Set = [] + + for language in languages { + switch language { + case .chineseSimplified: + result.insert(.chinese) + result.insert(.english) // PaddleOCR supports mixed + case .english: + result.insert(.english) + case .french: + result.insert(.french) + case .german: + result.insert(.german) + case .korean: + result.insert(.korean) + case .japanese: + result.insert(.japanese) + default: + // For unsupported languages, fall back to Chinese+English + result.insert(.chinese) + result.insert(.english) + } + } + + return result.isEmpty ? [.chinese, .english] : result + } +} diff --git a/ScreenTranslate/Services/OllamaVLMProvider.swift b/ScreenTranslate/Services/OllamaVLMProvider.swift new file mode 100644 index 0000000..f34b4fb --- /dev/null +++ b/ScreenTranslate/Services/OllamaVLMProvider.swift @@ -0,0 +1,307 @@ +// +// OllamaVLMProvider.swift +// ScreenTranslate +// +// Created for US-007: Ollama Vision Provider +// + +import CoreGraphics +import Foundation + +// MARK: - Ollama VLM Provider + +/// VLM provider implementation for local Ollama vision models (llava, qwen-vl, etc.) +struct OllamaVLMProvider: VLMProvider, Sendable { + // MARK: - Properties + + let id: String = "ollama" + let name: String = "Ollama Vision" + let configuration: VLMProviderConfiguration + + /// Default Ollama API base URL (local server) + static let defaultBaseURL = URL(string: "http://localhost:11434")! + + /// Default model for vision tasks + static let defaultModel = "llava" + + /// Request timeout in seconds + private let timeout: TimeInterval + + // MARK: - Initialization + + /// Initialize with full configuration + /// - Parameters: + /// - configuration: VLM provider configuration + /// - timeout: Request timeout in seconds (default: 120 for local models) + init(configuration: VLMProviderConfiguration, timeout: TimeInterval = 120) { + self.configuration = configuration + self.timeout = timeout + } + + /// Convenience initializer with individual parameters + /// - Parameters: + /// - baseURL: API base URL (default: localhost:11434) + /// - modelName: Model to use (default: llava) + /// - timeout: Request timeout in seconds (default: 120) + init( + baseURL: URL = OllamaVLMProvider.defaultBaseURL, + modelName: String = OllamaVLMProvider.defaultModel, + timeout: TimeInterval = 120 + ) { + // Ollama doesn't require API key, but VLMProviderConfiguration requires one + self.configuration = VLMProviderConfiguration( + apiKey: "", + baseURL: baseURL, + modelName: modelName + ) + self.timeout = timeout + } + + // MARK: - VLMProvider Protocol + + var isAvailable: Bool { + get async { + await checkServerAvailability() + } + } + + func analyze(image: CGImage) async throws -> ScreenAnalysisResult { + guard let imageData = image.jpegData(quality: 0.85), !imageData.isEmpty else { + throw VLMProviderError.imageEncodingFailed + } + + let base64Image = imageData.base64EncodedString() + let imageSize = CGSize(width: image.width, height: image.height) + let request = try buildRequest(base64Image: base64Image) + let responseData = try await executeRequest(request) + let vlmResponse = try parseOllamaResponse(responseData) + + return vlmResponse.toScreenAnalysisResult(imageSize: imageSize) + } + + // MARK: - Private Methods + + /// Checks if Ollama server is running and accessible + private func checkServerAvailability() async -> Bool { + let endpoint = configuration.baseURL.appendingPathComponent("api/tags") + var request = URLRequest(url: endpoint) + request.httpMethod = "GET" + request.timeoutInterval = 5 // Short timeout for health check + + do { + let (_, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + return false + } + return httpResponse.statusCode == 200 + } catch { + return false + } + } + + /// Builds the URLRequest for Ollama Generate API + private func buildRequest(base64Image: String) throws -> URLRequest { + let endpoint = configuration.baseURL.appendingPathComponent("api/generate") + + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = timeout + + let prompt = """ + \(VLMPromptTemplate.systemPrompt) + + \(VLMPromptTemplate.userPrompt) + """ + + let requestBody = OllamaGenerateRequest( + model: configuration.modelName, + prompt: prompt, + images: [base64Image], + stream: false, + options: OllamaOptions( + temperature: 0.1, + numPredict: 4096 + ) + ) + + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + request.httpBody = try encoder.encode(requestBody) + + return request + } + + /// Executes the HTTP request with timeout handling + private func executeRequest(_ request: URLRequest) async throws -> Data { + let (data, response): (Data, URLResponse) + + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch let error as URLError { + switch error.code { + case .timedOut: + throw VLMProviderError.networkError("Request timed out") + case .cannotConnectToHost, .cannotFindHost: + throw VLMProviderError.networkError( + "Cannot connect to Ollama server at \(configuration.baseURL). Is Ollama running?" + ) + case .notConnectedToInternet, .networkConnectionLost: + throw VLMProviderError.networkError("No network connection") + default: + throw VLMProviderError.networkError(error.localizedDescription) + } + } catch { + throw VLMProviderError.networkError(error.localizedDescription) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw VLMProviderError.invalidResponse("Invalid HTTP response") + } + + try handleHTTPStatus(httpResponse, data: data) + + return data + } + + /// Handles HTTP status codes and throws appropriate errors + private func handleHTTPStatus(_ response: HTTPURLResponse, data: Data) throws { + switch response.statusCode { + case 200 ... 299: + return + + case 404: + throw VLMProviderError.modelUnavailable( + "\(configuration.modelName). Run 'ollama pull \(configuration.modelName)' to download it." + ) + + case 400: + let message = parseErrorMessage(from: data) ?? "Bad request" + throw VLMProviderError.invalidConfiguration(message) + + case 500 ... 599: + let message = parseErrorMessage(from: data) ?? "Server error" + throw VLMProviderError.networkError("Ollama server error (\(response.statusCode)): \(message)") + + default: + let message = parseErrorMessage(from: data) ?? "Unknown error" + throw VLMProviderError.invalidResponse("HTTP \(response.statusCode): \(message)") + } + } + + /// Parses error message from Ollama error response + private func parseErrorMessage(from data: Data) -> String? { + guard let errorResponse = try? JSONDecoder().decode(OllamaErrorResponse.self, from: data) else { + return nil + } + return errorResponse.error + } + + /// Parses Ollama response and extracts VLM analysis + private func parseOllamaResponse(_ data: Data) throws -> VLMAnalysisResponse { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let ollamaResponse: OllamaGenerateResponse + do { + ollamaResponse = try decoder.decode(OllamaGenerateResponse.self, from: data) + } catch { + throw VLMProviderError.parsingFailed("Failed to decode Ollama response: \(error.localizedDescription)") + } + + guard !ollamaResponse.response.isEmpty else { + throw VLMProviderError.invalidResponse("Empty response from Ollama") + } + + return try parseVLMContent(ollamaResponse.response) + } + + /// Parses the VLM JSON content from assistant message + private func parseVLMContent(_ content: String) throws -> VLMAnalysisResponse { + let cleanedContent = extractJSON(from: content) + + guard let jsonData = cleanedContent.data(using: .utf8) else { + throw VLMProviderError.parsingFailed("Failed to convert content to data") + } + + do { + let response = try JSONDecoder().decode(VLMAnalysisResponse.self, from: jsonData) + return response + } catch { + throw VLMProviderError.parsingFailed( + "Failed to parse VLM response JSON: \(error.localizedDescription). Content: \(cleanedContent.prefix(200))..." + ) + } + } + + /// Extracts JSON from potentially markdown-wrapped content + private func extractJSON(from content: String) -> String { + var text = content.trimmingCharacters(in: .whitespacesAndNewlines) + + // Handle markdown code blocks + if text.hasPrefix("```json") { + text = String(text.dropFirst(7)) + } else if text.hasPrefix("```") { + text = String(text.dropFirst(3)) + } + + if text.hasSuffix("```") { + text = String(text.dropLast(3)) + } + + // Try to find JSON object boundaries if still not valid + if let startIndex = text.firstIndex(of: "{"), + let endIndex = text.lastIndex(of: "}") + { + text = String(text[startIndex ... endIndex]) + } + + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +// MARK: - Ollama API Request/Response Models + +/// Ollama Generate API request structure +private struct OllamaGenerateRequest: Encodable, Sendable { + let model: String + let prompt: String + let images: [String] + let stream: Bool + let options: OllamaOptions? +} + +/// Ollama generation options +private struct OllamaOptions: Encodable, Sendable { + let temperature: Double + let numPredict: Int + + enum CodingKeys: String, CodingKey { + case temperature + case numPredict = "num_predict" + } +} + +/// Ollama Generate API response structure +private struct OllamaGenerateResponse: Decodable, Sendable { + let model: String + let response: String + let done: Bool + let totalDuration: Int64? + let loadDuration: Int64? + let promptEvalCount: Int? + let evalCount: Int? + + enum CodingKeys: String, CodingKey { + case model, response, done + case totalDuration = "total_duration" + case loadDuration = "load_duration" + case promptEvalCount = "prompt_eval_count" + case evalCount = "eval_count" + } +} + +/// Ollama error response structure +private struct OllamaErrorResponse: Decodable, Sendable { + let error: String +} diff --git a/ScreenTranslate/Services/OpenAIVLMProvider.swift b/ScreenTranslate/Services/OpenAIVLMProvider.swift new file mode 100644 index 0000000..8f2da0b --- /dev/null +++ b/ScreenTranslate/Services/OpenAIVLMProvider.swift @@ -0,0 +1,771 @@ +// +// OpenAIVLMProvider.swift +// ScreenTranslate +// +// Created for US-005: OpenAI Vision Provider +// + +import CoreGraphics +import Foundation +import os + +// MARK: - OpenAI VLM Provider + +/// VLM provider implementation for OpenAI GPT-4V/GPT-4o vision models +struct OpenAIVLMProvider: VLMProvider, Sendable { + // MARK: - Properties + + let id: String = "openai" + let name: String = "OpenAI Vision" + let configuration: VLMProviderConfiguration + + /// Default OpenAI API base URL + static let defaultBaseURL = URL(string: "https://api.openai.com/v1")! + + /// Default model for vision tasks + static let defaultModel = "gpt-4o" + + /// Request timeout in seconds + private let timeout: TimeInterval + + private let logger = Logger.translation + + // MARK: - Initialization + + /// Initialize with full configuration + /// - Parameters: + /// - configuration: VLM provider configuration + /// - timeout: Request timeout in seconds (default: 60) + init(configuration: VLMProviderConfiguration, timeout: TimeInterval = 60) { + self.configuration = configuration + self.timeout = timeout + } + + private func logDebug(_ message: String) { + logger.debug("\(message, privacy: .public)") + } + + private func logInfo(_ message: String) { + logger.info("\(message, privacy: .public)") + } + + private func logWarning(_ message: String) { + logger.warning("\(message, privacy: .public)") + } + + private func logError(_ message: String) { + logger.error("\(message, privacy: .public)") + } + + /// Convenience initializer with individual parameters + /// - Parameters: + /// - apiKey: OpenAI API key + /// - baseURL: API base URL (default: OpenAI's official endpoint) + /// - modelName: Model to use (default: gpt-4o) + /// - timeout: Request timeout in seconds (default: 60) + init( + apiKey: String, + baseURL: URL = OpenAIVLMProvider.defaultBaseURL, + modelName: String = OpenAIVLMProvider.defaultModel, + timeout: TimeInterval = 60 + ) { + self.configuration = VLMProviderConfiguration( + apiKey: apiKey, + baseURL: baseURL, + modelName: modelName + ) + self.timeout = timeout + } + + // MARK: - VLMProvider Protocol + + var isAvailable: Bool { + get async { + !configuration.apiKey.isEmpty + } + } + + /// Maximum number of continuation attempts when response is truncated + private let maxContinuationAttempts = 3 + + func analyze(image: CGImage) async throws -> ScreenAnalysisResult { + guard let imageData = image.jpegData(quality: 0.85), !imageData.isEmpty else { + throw VLMProviderError.imageEncodingFailed + } + + let base64Image = imageData.base64EncodedString() + let imageSize = CGSize(width: image.width, height: image.height) + + logDebug("Image size: \(image.width)x\(image.height), JPEG bytes: \(imageData.count), base64 chars: \(base64Image.count)") + + // Use multi-turn conversation with continuation support + let vlmResponse = try await analyzeWithContinuation( + base64Image: base64Image, + imageSize: imageSize, + maxAttempts: maxContinuationAttempts + ) + + return vlmResponse.toScreenAnalysisResult(imageSize: imageSize) + } + + /// Detects if the provider is using a local endpoint (for lower max_tokens) + private var isLocalEndpoint: Bool { + let host = configuration.baseURL.host ?? "" + // IPv6 loopback + if host == "::1" { return true } + // IPv4 loopback + if host == "localhost" || host == "127.0.0.1" { return true } + // Private ranges: 10.x.x.x + if host.hasPrefix("10.") { return true } + // Private ranges: 192.168.x.x + if host.hasPrefix("192.168.") { return true } + // Private ranges: 172.16.x.x - 172.31.x.x + if host.hasPrefix("172.") { + let parts = host.split(separator: ".") + if parts.count >= 2, let second = Int(parts[1]), second >= 16, second <= 31 { + return true + } + } + return false + } + + /// Performs analysis with automatic continuation on truncation + private func analyzeWithContinuation( + base64Image: String, + imageSize: CGSize, + maxAttempts: Int + ) async throws -> VLMAnalysisResponse { + var allSegments: [VLMTextSegment] = [] + + var conversationMessages: [OpenAIChatMessage] = [ + OpenAIChatMessage( + role: "system", + content: .text(VLMPromptTemplate.systemPrompt) + ), + OpenAIChatMessage( + role: "user", + content: .vision([ + .text(VLMPromptTemplate.userPrompt), + .imageURL(OpenAIImageURL( + url: "data:image/jpeg;base64,\(base64Image)" + )), + ]) + ), + ] + + for attempt in 0.. 0 + let request = try buildRequest(messages: conversationMessages, isContinuation: isContinuation) + let responseData = try await executeRequest(request) + + let (content, isTruncated, finishReason) = try extractContentAndStatus(from: responseData) + + logDebug("Attempt \(attempt + 1)/\(maxAttempts): received \(content.count) chars, finish reason: \(finishReason ?? "unknown")") + + // Try to parse this response + do { + let response = try parseVLMContent(content) + + // For continuation requests, filter out duplicates from the beginning + // LLM often repeats some segments when continuing + let newSegments: [VLMTextSegment] + if attempt > 0 { + newSegments = filterDuplicateSegments( + existing: allSegments, + new: response.segments + ) + } else { + newSegments = response.segments + } + + allSegments.append(contentsOf: newSegments) + logDebug("Parsed \(response.segments.count) segments, added \(newSegments.count) new (attempt \(attempt + 1))") + + if !isTruncated { + // Complete - return merged result with deduplication + let deduplicated = deduplicateSegments(allSegments) + logDebug("Complete response received, \(allSegments.count) -> \(deduplicated.count) segments after dedup") + return VLMAnalysisResponse(segments: deduplicated) + } + } catch { + logWarning("Parse error on attempt \(attempt + 1) [\(String(describing: type(of: error)))]") + + // Try partial parsing for truncated response + if isTruncated { + if let partial = try? parsePartialVLMContent(content) { + // Filter duplicates for partial parse too + let newSegments = filterDuplicateSegments( + existing: allSegments, + new: partial.segments + ) + allSegments.append(contentsOf: newSegments) + logDebug("Partial parse recovered \(partial.segments.count) segments, added \(newSegments.count) new") + } + } + + // If not truncated but parse failed, this is a real error + if !isTruncated { + throw error + } + } + + // Response truncated, need to continue + logDebug("Response truncated, requesting continuation") + + // Add assistant's partial response to conversation + conversationMessages.append(OpenAIChatMessage( + role: "assistant", + content: .text(content) + )) + + // Request continuation - ask for remaining segments only + conversationMessages.append(OpenAIChatMessage( + role: "user", + content: .text("Continue with the remaining segments only. Do not repeat any segments you've already provided.") + )) + } + + // Final deduplication before returning + let deduplicated = deduplicateSegments(allSegments) + logWarning("Max continuation attempts reached, \(allSegments.count) -> \(deduplicated.count) segments after dedup") + return VLMAnalysisResponse(segments: deduplicated) + } + + /// Filters out segments from new array that already exist in existing array + private func filterDuplicateSegments( + existing: [VLMTextSegment], + new: [VLMTextSegment] + ) -> [VLMTextSegment] { + VLMTextDeduplicator.filterDuplicates(existing: existing, new: new) + } + + /// Removes duplicate segments from the final result + private func deduplicateSegments(_ segments: [VLMTextSegment]) -> [VLMTextSegment] { + VLMTextDeduplicator.deduplicate(segments) { length, count, threshold in + // Log only safe statistics, not plaintext content + logDebug("Detected overrepresented text: length=\(length), count=\(count), threshold=\(threshold)") + } + } + + /// Extracts content text and truncation status from OpenAI response + private func extractContentAndStatus(from data: Data) throws -> (content: String, isTruncated: Bool, finishReason: String?) { + logDebug("Received raw response payload: \(data.count) bytes") + + // Check for error response first + if let errorResponse = try? JSONDecoder().decode(OpenAIErrorResponse.self, from: data), + !errorResponse.error.message.isEmpty { + throw VLMProviderError.invalidResponse(errorResponse.error.message) + } + + // Try to parse as OpenAI response + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let openAIResponse: OpenAIChatResponse + do { + openAIResponse = try decoder.decode(OpenAIChatResponse.self, from: data) + } catch { + // If JSON decoding fails, try to extract content manually using regex + // This handles cases where the JSON structure is broken + if let rawJSON = String(data: data, encoding: .utf8), + let content = extractContentManually(from: rawJSON) { + let isTruncated = rawJSON.contains("\"finish_reason\":\"length\"") || + rawJSON.contains("\"finish_reason\": \"length\"") + logDebug("Manually extracted content (truncated: \(isTruncated))") + return (content, isTruncated, isTruncated ? "length" : nil) + } + + throw VLMProviderError.parsingFailed( + "Failed to decode OpenAI response: \(error.localizedDescription). Response length: \(data.count) bytes" + ) + } + + guard let choices = openAIResponse.choices, !choices.isEmpty else { + throw VLMProviderError.invalidResponse("No choices in response") + } + + let choice = choices[0] + + guard let message = choice.message else { + throw VLMProviderError.invalidResponse("No message in choice") + } + + guard let content = message.content else { + let reason = choice.finishReason ?? "unknown" + throw VLMProviderError.invalidResponse("No content in response (finish_reason: \(reason))") + } + + let isTruncated = choice.finishReason == "length" + return (content, isTruncated, choice.finishReason) + } + + /// Attempts to extract content field manually when JSON decoder fails + private func extractContentManually(from json: String) -> String? { + let patterns = ["\"content\":\"", "\"content\": \""] + + for pattern in patterns { + if let range = json.range(of: pattern) { + let start = range.upperBound + + var end = start + var escaped = false + var depth = 0 + var charCount = 0 + + for char in json[start...] { + charCount += 1 + if escaped { + escaped = false + end = json.index(after: end) + } else if char == "\\" { + escaped = true + end = json.index(after: end) + } else if char == "{" || char == "[" { + depth += 1 + end = json.index(after: end) + } else if char == "}" || char == "]" { + depth -= 1 + end = json.index(after: end) + if depth < 0 { break } + } else if char == "\"" && depth == 0 { + break + } else { + end = json.index(after: end) + } + } + + let content = String(json[start.. URLRequest { + let endpoint = configuration.baseURL.appendingPathComponent("chat/completions") + + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(configuration.apiKey)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = timeout + + // Use lower max_tokens for local models (they're slower and don't need as many) + let maxTokens: Int + if isLocalEndpoint { + maxTokens = isContinuation ? 2048 : 1024 + } else { + maxTokens = isContinuation ? 16384 : 8192 + } + + let requestBody = OpenAIChatRequest( + model: configuration.modelName, + messages: messages, + maxTokens: maxTokens, + temperature: 0.1 + ) + + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + request.httpBody = try encoder.encode(requestBody) + + return request + } + + /// Executes the HTTP request with timeout handling + private func executeRequest(_ request: URLRequest) async throws -> Data { + let (data, response): (Data, URLResponse) + + logDebug("Sending request to: \(request.url?.absoluteString ?? "unknown")") + if let body = request.httpBody { + logDebug("Request body size: \(body.count) bytes") + } + + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch let error as URLError { + logWarning("Network error: \(error.localizedDescription)") + switch error.code { + case .timedOut: + throw VLMProviderError.networkError("Request timed out") + case .notConnectedToInternet, .networkConnectionLost: + throw VLMProviderError.networkError("No internet connection") + default: + throw VLMProviderError.networkError(error.localizedDescription) + } + } catch { + logWarning("Unknown request error: \(error.localizedDescription)") + throw VLMProviderError.networkError(error.localizedDescription) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw VLMProviderError.invalidResponse("Invalid HTTP response") + } + + logDebug("HTTP status: \(httpResponse.statusCode), payload size: \(data.count) bytes") + + try handleHTTPStatus(httpResponse, data: data) + + return data + } + + /// Handles HTTP status codes and throws appropriate errors + private func handleHTTPStatus(_ response: HTTPURLResponse, data: Data) throws { + switch response.statusCode { + case 200 ... 299: + return + + case 401: + throw VLMProviderError.authenticationFailed + + case 429: + logWarning("429 rate limited from OpenAI endpoint") + let retryAfter = parseRetryAfter(from: response, data: data) + let errorMessage = parseErrorMessage(from: data) + throw VLMProviderError.rateLimited(retryAfter: retryAfter, message: errorMessage) + + case 404: + throw VLMProviderError.modelUnavailable(configuration.modelName) + + case 400: + let message = parseErrorMessage(from: data) ?? "Bad request" + logWarning("400 bad request from OpenAI endpoint") + throw VLMProviderError.invalidConfiguration(message) + + case 500 ... 599: + let message = parseErrorMessage(from: data) ?? "Server error" + throw VLMProviderError.networkError("Server error (\(response.statusCode)): \(message)") + + default: + let message = parseErrorMessage(from: data) ?? "Unknown error" + throw VLMProviderError.invalidResponse("HTTP \(response.statusCode): \(message)") + } + } + + /// Parses the retry-after value from rate limit response + private func parseRetryAfter(from response: HTTPURLResponse, data: Data) -> TimeInterval? { + if let headerValue = response.value(forHTTPHeaderField: "Retry-After"), + let seconds = Double(headerValue) + { + return seconds + } + + if let errorResponse = try? JSONDecoder().decode(OpenAIErrorResponse.self, from: data), + let retryAfter = errorResponse.error.retryAfter + { + return retryAfter + } + + return nil + } + + /// Parses error message from OpenAI error response + private func parseErrorMessage(from data: Data) -> String? { + guard let errorResponse = try? JSONDecoder().decode(OpenAIErrorResponse.self, from: data) else { + return nil + } + return errorResponse.error.message + } + + /// Parses the VLM JSON content from assistant message + private func parseVLMContent(_ content: String, wasTruncated: Bool = false) throws -> VLMAnalysisResponse { + var cleanedContent = extractJSON(from: content) + + // If response was truncated, try to repair the JSON by closing open brackets + if wasTruncated { + logDebug("Attempting to repair truncated JSON") + cleanedContent = attemptToRepairJSON(cleanedContent) + } + + guard let jsonData = cleanedContent.data(using: .utf8) else { + throw VLMProviderError.parsingFailed("Failed to convert content to data") + } + + do { + let response = try JSONDecoder().decode(VLMAnalysisResponse.self, from: jsonData) + return response + } catch { + // JSON parsing failed - try to handle plain text response from local models + logDebug("JSON parsing failed, attempting plain text fallback") + if let plainTextResponse = parsePlainTextResponse(content) { + logDebug("Successfully parsed plain text response with \(plainTextResponse.segments.count) segments") + return plainTextResponse + } + + if wasTruncated { + throw VLMProviderError.invalidResponse("Response was truncated due to token limit. Try selecting a smaller area or using a model with larger context window.") + } + throw VLMProviderError.parsingFailed( + "Failed to parse VLM response JSON: \(error.localizedDescription). Content length: \(cleanedContent.count) chars" + ) + } + } + + /// Parses plain text response (one text per line) from local models + private func parsePlainTextResponse(_ content: String) -> VLMAnalysisResponse? { + let rawLines = content + .trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + + // Filter out structural/noise lines + let lines = rawLines.filter { line in + guard !line.isEmpty else { return false } + // Skip code fence markers + if line == "```" || line == "`" || line.hasPrefix("```") { return false } + // Skip lone JSON structural tokens + if ["{", "}", "[", "]", "{ }", "[ ]"].contains(line) { return false } + // Skip JSON field names (quoted identifiers) + let metadataPatterns = ["\"segments\"", "\"boundingBox\"", "\"text\"", "\"confidence\"", "\"x\"", "\"y\"", "\"width\"", "\"height\""] + if metadataPatterns.contains(where: { line.contains($0) }) { return false } + return true + } + + guard !lines.isEmpty else { return nil } + + // Convert each line to a segment with placeholder bounding box + let segments = lines.enumerated().map { (index, text) in + VLMTextSegment( + text: text, + boundingBox: VLMBoundingBox( + x: 0.0, + y: CGFloat(index) / CGFloat(max(lines.count, 1)), + width: 1.0, + height: 1.0 / CGFloat(max(lines.count, 1)) + ), + confidence: nil // Unknown confidence for plain text fallback + ) + } + + return VLMAnalysisResponse(segments: segments) + } + + /// Attempts to parse partial/truncated VLM content by extracting valid JSON segments + private func parsePartialVLMContent(_ content: String) throws -> VLMAnalysisResponse { + let cleanedContent = extractJSON(from: content) + + // Try to find the last complete segment object + // Look for the last complete "}" that closes a segment + if let lastCompleteBlockEnd = cleanedContent.range(of: "}", options: .backwards) { + let truncatedContent = String(cleanedContent[.. String { + var repaired = json + + // Count unclosed brackets + let openBraces = repaired.filter { $0 == "{" }.count - repaired.filter { $0 == "}" }.count + let openBrackets = repaired.filter { $0 == "[" }.count - repaired.filter { $0 == "]" }.count + + // Close any open strings (if odd number of unescaped quotes) + let quoteCount = repaired.filter { $0 == "\"" }.count + if quoteCount % 2 != 0 { + repaired += "\"" + } + + // Close objects and arrays + for _ in 0.. String { + var text = content.trimmingCharacters(in: .whitespacesAndNewlines) + + // Handle markdown code blocks + if text.hasPrefix("```json") { + text = String(text.dropFirst(7)) + } else if text.hasPrefix("```") { + text = String(text.dropFirst(3)) + } + + if text.hasSuffix("```") { + text = String(text.dropLast(3)) + } + + // Handle case where response starts with text before JSON + if let jsonStart = text.firstIndex(of: "{"), jsonStart != text.startIndex { + text = String(text[jsonStart...]) + } + + // Handle case where there's text after the JSON + if let jsonEnd = text.lastIndex(of: "}") { + let nextIndex = text.index(after: jsonEnd) + if nextIndex < text.endIndex { + text = String(text[...jsonEnd]) + } + } + + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +// MARK: - OpenAI API Request/Response Models + +/// OpenAI Chat Completion request structure +private struct OpenAIChatRequest: Encodable, Sendable { + let model: String + let messages: [OpenAIChatMessage] + let maxTokens: Int + let temperature: Double + + enum CodingKeys: String, CodingKey { + case model, messages + case maxTokens = "max_tokens" + case temperature + } +} + +/// Chat message with support for vision content +private struct OpenAIChatMessage: Encodable, Sendable { + let role: String + let content: MessageContent + + enum MessageContent: Sendable { + case text(String) + case vision([VisionContent]) + } + + enum VisionContent: Sendable { + case text(String) + case imageURL(OpenAIImageURL) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(role, forKey: .role) + + switch content { + case .text(let text): + try container.encode(text, forKey: .content) + case .vision(let contents): + var contentArray = container.nestedUnkeyedContainer(forKey: .content) + for item in contents { + switch item { + case .text(let text): + try contentArray.encode(["type": "text", "text": text]) + case .imageURL(let imageURL): + var itemContainer = contentArray.nestedContainer(keyedBy: VisionCodingKeys.self) + try itemContainer.encode("image_url", forKey: .type) + try itemContainer.encode(imageURL, forKey: .imageUrl) + } + } + } + } + + enum CodingKeys: String, CodingKey { + case role, content + } + + enum VisionCodingKeys: String, CodingKey { + case type + case imageUrl = "image_url" + } +} + +/// Image URL structure for vision requests +private struct OpenAIImageURL: Encodable, Sendable { + let url: String + let detail: String + + init(url: String, detail: String = "high") { + self.url = url + self.detail = detail + } +} + +/// OpenAI Chat Completion response structure +private struct OpenAIChatResponse: Decodable, Sendable { + let id: String? + let choices: [OpenAIChatChoice]? + let usage: OpenAIUsage? +} + +private struct OpenAIChatChoice: Decodable, Sendable { + let index: Int? + let message: OpenAIResponseMessage? + let finishReason: String? + + enum CodingKeys: String, CodingKey { + case index, message + case finishReason = "finish_reason" + } +} + +private struct OpenAIResponseMessage: Decodable, Sendable { + let role: String? + let content: String? +} + +private struct OpenAIUsage: Decodable, Sendable { + let promptTokens: Int + let completionTokens: Int + let totalTokens: Int + + enum CodingKeys: String, CodingKey { + case promptTokens = "prompt_tokens" + case completionTokens = "completion_tokens" + case totalTokens = "total_tokens" + } +} + +/// OpenAI error response structure +private struct OpenAIErrorResponse: Decodable, Sendable { + let error: OpenAIError +} + +private struct OpenAIError: Decodable, Sendable { + let message: String + let type: String? + let code: String? + let retryAfter: TimeInterval? + + enum CodingKeys: String, CodingKey { + case message, type, code + case retryAfter = "retry_after" + } +} diff --git a/ScreenTranslate/Services/OverlayRenderer.swift b/ScreenTranslate/Services/OverlayRenderer.swift new file mode 100644 index 0000000..cd5444c --- /dev/null +++ b/ScreenTranslate/Services/OverlayRenderer.swift @@ -0,0 +1,387 @@ +import AppKit +import CoreGraphics +import CoreText +import Foundation + +/// Color theme for overlay rendering +/// Note: CGColor is not Sendable, but we use this safely by only accessing on main thread +struct OverlayTheme: Sendable { + let backgroundColor: CGColor + let textColor: CGColor + let separatorColor: CGColor + + /// Light theme (default) + static let light = OverlayTheme( + backgroundColor: CGColor(gray: 0.95, alpha: 1.0), + textColor: CGColor(gray: 0.1, alpha: 1.0), + separatorColor: CGColor(gray: 0.7, alpha: 1.0) + ) + + /// Dark theme + static let dark = OverlayTheme( + backgroundColor: CGColor(gray: 0.15, alpha: 1.0), + textColor: CGColor(gray: 0.9, alpha: 1.0), + separatorColor: CGColor(gray: 0.4, alpha: 1.0) + ) + + /// Get theme based on system appearance + /// Must be called from main thread + @MainActor + static var current: OverlayTheme { + // Check if system is in dark mode + if let appearance = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) { + return appearance == .darkAqua ? .dark : .light + } + return .light + } +} + +struct OverlayRenderer: Sendable { + private let style: OverlayStyle + + init(style: OverlayStyle = .default) { + self.style = style + } + + func render(image: CGImage, segments: [BilingualSegment], theme: OverlayTheme) -> CGImage? { + guard !segments.isEmpty else { + return image + } + + let originalWidth = CGFloat(image.width) + let originalHeight = CGFloat(image.height) + let aspectRatio = originalWidth / originalHeight + + // Determine layout based on aspect ratio + // Wide image (landscape): stack vertically (original on top, translation below) + // Tall image (portrait): side by side (original on left, translation on right) + let isWideImage = aspectRatio >= 1.0 + + if isWideImage { + return renderSideBySideVertical(image: image, segments: segments, theme: theme) + } else { + return renderSideBySideHorizontal(image: image, segments: segments, theme: theme) + } + } + + /// Renders wide images with original on top, translation list below + private func renderSideBySideVertical(image: CGImage, segments: [BilingualSegment], theme: OverlayTheme) -> CGImage? { + let originalWidth = CGFloat(image.width) + let originalHeight = CGFloat(image.height) + + // Calculate translation area height + let translationFontSize: CGFloat = max(24, originalHeight * 0.04) + let translationFont = createFont(size: translationFontSize) + let lineHeight = translationFontSize * 1.5 + + // Group segments by row for organized display + let rows = groupIntoRows(segments, imageHeight: originalHeight) + + // Calculate required height for translations using padded width + let padding: CGFloat = 10 + let maxTextWidth = originalWidth - padding * 2 + var totalTranslationHeight: CGFloat = 40 // Top padding + + for row in rows { + let rowText = row.segments.map { $0.translated }.joined(separator: " ") + let textHeight = calculateTextHeight(rowText, font: translationFont, maxWidth: maxTextWidth) + totalTranslationHeight += textHeight + lineHeight * 0.5 + } + totalTranslationHeight += 40 // Bottom padding + + let newHeight = originalHeight + totalTranslationHeight + + guard let colorSpace = image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: Int(originalWidth), + height: Int(newHeight), + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return nil + } + + // Fill background with theme color + context.setFillColor(theme.backgroundColor) + context.fill(CGRect(x: 0, y: 0, width: originalWidth, height: newHeight)) + + // Draw original image at top (unchanged) + let imageY = newHeight - originalHeight // In CG, Y=0 is bottom + context.draw(image, in: CGRect(x: 0, y: imageY, width: originalWidth, height: originalHeight)) + + // Draw separator line with theme color + let separatorY = imageY - 1 + context.setFillColor(theme.separatorColor) + context.fill(CGRect(x: 0, y: separatorY, width: originalWidth, height: 2)) + + // Draw translations below with padding + var currentY: CGFloat = separatorY - padding // Start below separator + + for row in rows { + let rowText = row.segments.map { $0.translated }.joined(separator: " ") + let textHeight = calculateTextHeight(rowText, font: translationFont, maxWidth: maxTextWidth) + + renderTranslationBlock( + rowText, + in: context, + at: CGRect(x: padding, y: currentY - textHeight, width: maxTextWidth, height: textHeight), + font: translationFont, + color: theme.textColor + ) + + currentY -= textHeight + lineHeight * 0.5 + } + + return context.makeImage() + } + + /// Renders tall images with original on left, translation on right + private func renderSideBySideHorizontal(image: CGImage, segments: [BilingualSegment], theme: OverlayTheme) -> CGImage? { + let originalWidth = CGFloat(image.width) + let originalHeight = CGFloat(image.height) + + // Translation area width matches original image width + let translationAreaWidth = originalWidth + let newWidth = originalWidth + translationAreaWidth + + guard let colorSpace = image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: Int(newWidth), + height: Int(originalHeight), + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return nil + } + + // Fill background with theme color + context.setFillColor(theme.backgroundColor) + context.fill(CGRect(x: 0, y: 0, width: newWidth, height: originalHeight)) + + // Draw original image on left (unchanged) + context.draw(image, in: CGRect(x: 0, y: 0, width: originalWidth, height: originalHeight)) + + // Draw separator line with theme color + let separatorX = originalWidth + context.setFillColor(theme.separatorColor) + context.fill(CGRect(x: separatorX, y: 0, width: 2, height: originalHeight)) + + // Calculate font size based on image height + let translationFontSize: CGFloat = max(24, originalHeight * 0.04) + let translationFont = createFont(size: translationFontSize) + + // Group segments by row + let rows = groupIntoRows(segments, imageHeight: originalHeight) + + // Draw translations on right side with 10px padding inside translation area + let padding: CGFloat = 10 + let textAreaX = separatorX + padding + let maxTextWidth = translationAreaWidth - padding * 2 + let lineHeight = translationFontSize * 1.8 + + var currentY: CGFloat = originalHeight - padding // Start from top with padding + + // Draw each translation row + for row in rows { + let rowText = row.segments.map { $0.translated }.joined(separator: " ") + let textHeight = calculateTextHeight(rowText, font: translationFont, maxWidth: maxTextWidth) + + // Check if we have enough space + if currentY - textHeight < 20 { + break // Stop if running out of space + } + + renderTranslationBlock( + rowText, + in: context, + at: CGRect(x: textAreaX, y: currentY - textHeight, width: maxTextWidth, height: textHeight), + font: translationFont, + color: theme.textColor + ) + + currentY -= textHeight + lineHeight * 0.8 + } + + return context.makeImage() + } + + /// Renders a block of translation text + private func renderTranslationBlock(_ text: String, in context: CGContext, at rect: CGRect, font: CTFont, color: CGColor) { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .left + paragraphStyle.lineBreakMode = .byWordWrapping + + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: NSColor(cgColor: color) ?? .black, + .paragraphStyle: paragraphStyle + ] + + let attrString = CFAttributedStringCreate( + nil, + text as CFString, + attributes as CFDictionary + )! + + let framesetter = CTFramesetterCreateWithAttributedString(attrString) + let path = CGPath(rect: rect, transform: nil) + let frame = CTFramesetterCreateFrame(framesetter, CFRange(location: 0, length: CFAttributedStringGetLength(attrString)), path, nil) + + CTFrameDraw(frame, context) + } + + private func groupIntoRows(_ segments: [BilingualSegment], imageHeight: CGFloat) -> [RowInfo] { + let sortedSegments = segments.sorted { seg1, seg2 in + let y1 = seg1.original.boundingBox.minY + let y2 = seg2.original.boundingBox.minY + return y1 < y2 + } + + var rows: [RowInfo] = [] + let rowThreshold: CGFloat = 0.03 + + for segment in sortedSegments { + let segmentY = segment.original.boundingBox.minY + + if let lastIndex = rows.indices.last, + abs(rows[lastIndex].normalizedY - segmentY) < rowThreshold { + rows[lastIndex].segments.append(segment) + let box = segment.original.boundingBox + let pixelTop = box.minY * imageHeight + let pixelBottom = (box.minY + box.height) * imageHeight + rows[lastIndex].topY = min(rows[lastIndex].topY, pixelTop) + rows[lastIndex].bottomY = max(rows[lastIndex].bottomY, pixelBottom) + } else { + let box = segment.original.boundingBox + let pixelTop = box.minY * imageHeight + let pixelBottom = (box.minY + box.height) * imageHeight + rows.append(RowInfo( + segments: [segment], + normalizedY: segmentY, + topY: pixelTop, + bottomY: pixelBottom + )) + } + } + + for i in rows.indices { + let heights = rows[i].segments.map { $0.original.boundingBox.height * imageHeight } + rows[i].avgHeight = heights.reduce(0, +) / CGFloat(heights.count) + } + + return rows + } + + private func createFont(size: CGFloat) -> CTFont { + CTFontCreateWithName("PingFang SC" as CFString, size, nil) + } + + private func calculateTextHeight(_ text: String, font: CTFont, maxWidth: CGFloat) -> CGFloat { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .left + paragraphStyle.lineBreakMode = .byWordWrapping + + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .paragraphStyle: paragraphStyle + ] + let attrString = NSAttributedString(string: text, attributes: attributes) + let framesetter = CTFramesetterCreateWithAttributedString(attrString) + let size = CTFramesetterSuggestFrameSizeWithConstraints( + framesetter, + CFRange(location: 0, length: attrString.length), + nil, + CGSize(width: maxWidth, height: .greatestFiniteMagnitude), + nil + ) + return size.height + } + + private func sampleTextColor(from image: CGImage, at rect: CGRect) -> CGColor? { + let imageWidth = CGFloat(image.width) + let imageHeight = CGFloat(image.height) + + guard let dataProvider = image.dataProvider, + let data = dataProvider.data, + let ptr = CFDataGetBytePtr(data) else { return nil } + + let bytesPerPixel = image.bitsPerPixel / 8 + let bytesPerRow = image.bytesPerRow + + let bgColor = sampleBackgroundColor(from: image) + let bgR = bgColor.flatMap { $0.components?[0] } ?? 0 + let bgG = bgColor.flatMap { $0.components?[1] } ?? 0 + let bgB = bgColor.flatMap { $0.components?[2] } ?? 0 + + var bestColor: CGColor? + var maxDistance: CGFloat = 0 + + let samplePoints: [(CGFloat, CGFloat)] = [ + (0.1, 0.3), (0.2, 0.5), (0.3, 0.3), + (0.4, 0.5), (0.5, 0.3), (0.6, 0.5), + (0.7, 0.3), (0.8, 0.5), (0.9, 0.3) + ] + + for (xRatio, yRatio) in samplePoints { + let sampleX = Int(rect.origin.x + rect.width * xRatio) + let sampleY = Int(rect.origin.y + rect.height * yRatio) + + let cgY = Int(imageHeight) - 1 - sampleY + + guard sampleX >= 0, sampleX < Int(imageWidth), + cgY >= 0, cgY < Int(imageHeight) else { continue } + + let offset = cgY * bytesPerRow + sampleX * bytesPerPixel + // ScreenCaptureKit uses BGRA format + let b = CGFloat(ptr[offset]) / 255.0 + let g = CGFloat(ptr[offset + 1]) / 255.0 + let r = CGFloat(ptr[offset + 2]) / 255.0 + + let distance = sqrt(pow(r - bgR, 2) + pow(g - bgG, 2) + pow(b - bgB, 2)) + + if distance > maxDistance { + maxDistance = distance + bestColor = CGColor(red: r, green: g, blue: b, alpha: 1.0) + } + } + + return bestColor + } + + private func sampleBackgroundColor(from image: CGImage) -> CGColor? { + guard let dataProvider = image.dataProvider, + let data = dataProvider.data, + let ptr = CFDataGetBytePtr(data) else { return nil } + + // ScreenCaptureKit uses BGRA format + let b = CGFloat(ptr[0]) / 255.0 + let g = CGFloat(ptr[1]) / 255.0 + let r = CGFloat(ptr[2]) / 255.0 + + return CGColor(red: r, green: g, blue: b, alpha: 1.0) + } + + private func drawDashedLine(in context: CGContext, at rect: CGRect, color: CGColor) { + context.setStrokeColor(color) + context.setLineWidth(1) + context.setLineDash(phase: 0, lengths: [4, 3]) + context.move(to: CGPoint(x: rect.minX, y: rect.midY)) + context.addLine(to: CGPoint(x: rect.maxX, y: rect.midY)) + context.strokePath() + context.setLineDash(phase: 0, lengths: []) + } +} + +private struct RowInfo { + var segments: [BilingualSegment] + var normalizedY: CGFloat + var topY: CGFloat + var bottomY: CGFloat + var avgHeight: CGFloat = 0 +} diff --git a/ScreenTranslate/Services/PaddleOCREngine.swift b/ScreenTranslate/Services/PaddleOCREngine.swift new file mode 100644 index 0000000..5afd758 --- /dev/null +++ b/ScreenTranslate/Services/PaddleOCREngine.swift @@ -0,0 +1,910 @@ +import Foundation +import CoreGraphics +import ImageIO +import UniformTypeIdentifiers +import os.log + +/// PaddleOCR engine implementation. +/// Communicates with PaddleOCR CLI for text recognition. +actor PaddleOCREngine { + // MARK: - Properties + + /// Shared instance for PaddleOCR operations + static let shared = PaddleOCREngine() + + /// Whether PaddleOCR is available on the system + var isAvailable: Bool { PaddleOCRChecker.isAvailable } + + /// Get executable path from checker + private var executablePath: String { + PaddleOCRChecker.executablePath ?? "/usr/local/bin/paddleocr" + } + + /// Maximum concurrent operations + private var isProcessing = false + + // MARK: - Configuration + + /// PaddleOCR configuration options + struct Configuration: Sendable { + /// Recognition languages (ch, en for mixed Chinese-English) + var languages: Set + + /// Minimum confidence threshold (0.0 to 1.0) + var minimumConfidence: Float + + /// Whether to use GPU acceleration + var useGPU: Bool + + /// Whether to use direction classification for rotated text + var useDirectionClassify: Bool + + /// Detection model type + var detectionModel: DetectionModel + + /// OCR mode: fast (ocr command) or precise (doc_parser VL-1.5) + var mode: PaddleOCRMode + + /// Whether to use cloud API + var useCloud: Bool + + /// Cloud API base URL + var cloudBaseURL: String + + /// Cloud API key + var cloudAPIKey: String + + /// Cloud API model ID + var cloudModelId: String + + /// Local VL model directory (for vllm backend) + var localVLModelDir: String + + static let `default` = Configuration( + languages: [.chinese, .english], + minimumConfidence: 0.0, + useGPU: false, + useDirectionClassify: true, + detectionModel: .default, + mode: .fast, + useCloud: false, + cloudBaseURL: "", + cloudAPIKey: "", + cloudModelId: "", + localVLModelDir: "" + ) + } + + /// Supported languages for PaddleOCR + enum Language: String, CaseIterable, Sendable { + case chinese = "ch" + case english = "en" + case french = "french" + case german = "german" + case korean = "korean" + case japanese = "japan" + + /// CLI argument value for language + var cliValue: String { + switch self { + case .chinese: return "ch" + case .english: return "en" + case .french: return "french" + case .german: return "german" + case .korean: return "korean" + case .japanese: return "japan" + } + } + + /// Localized display name + var localizedName: String { + switch self { + case .chinese: return NSLocalizedString("lang.chinese", comment: "") + case .english: return NSLocalizedString("lang.english", comment: "") + case .french: return NSLocalizedString("lang.french", comment: "") + case .german: return NSLocalizedString("lang.german", comment: "") + case .korean: return NSLocalizedString("lang.korean", comment: "") + case .japanese: return NSLocalizedString("lang.japanese", comment: "") + } + } + } + + /// Detection model types + enum DetectionModel: String, Sendable { + case `default` + case server + case mobile + } + + // MARK: - Initialization + + private init() {} + + // MARK: - Public API + + /// Performs OCR on a CGImage with the specified configuration. + /// - Parameters: + /// - image: The image to process + /// - config: OCR configuration (uses default if not specified) + /// - Returns: OCRResult containing all recognized text + /// - Throws: PaddleOCRError if recognition fails + func recognize( + _ image: CGImage, + config: Configuration = .default + ) async throws -> OCRResult { + // Prevent concurrent operations + guard !isProcessing else { + throw PaddleOCREngineError.operationInProgress + } + isProcessing = true + defer { isProcessing = false } + + // Validate image + guard image.width > 0 && image.height > 0 else { + throw PaddleOCREngineError.invalidImage + } + + // Use cloud API if configured + if config.useCloud { + return try await recognizeViaCloudAPI(image: image, config: config) + } + + // Local mode - check availability + guard isAvailable else { + throw PaddleOCREngineError.notInstalled + } + + // Save image to temporary file + let tempURL = try saveImageToTempFile(image) + + defer { + // Clean up temp file + try? FileManager.default.removeItem(at: tempURL) + } + + // Build PaddleOCR command arguments + let arguments = buildArguments(config: config, imagePath: tempURL.path) + + // Execute PaddleOCR + let result = try await executePaddleOCR(arguments: arguments) + + // Parse output + let observations = try parsePaddleOCROutput(result, imageSize: CGSize(width: image.width, height: image.height), mode: config.mode) + + // Filter by confidence + let filteredTexts = observations.filter { $0.confidence >= config.minimumConfidence } + + return OCRResult( + observations: filteredTexts, + imageSize: CGSize(width: image.width, height: image.height) + ) + } + + /// Performs OCR on a CGImage with default configuration. + /// - Parameter image: The image to process + /// - Returns: OCRResult containing all recognized text + /// - Throws: PaddleOCRError if recognition fails + func recognize(_ image: CGImage) async throws -> OCRResult { + try await recognize(image, config: .default) + } + + /// Performs OCR with specific languages. + /// - Parameters: + /// - image: The image to process + /// - languages: Set of languages to recognize + /// - Returns: OCRResult containing all recognized text + /// - Throws: PaddleOCRError if recognition fails + func recognize( + _ image: CGImage, + languages: Set + ) async throws -> OCRResult { + var config = Configuration.default + config.languages = languages + return try await recognize(image, config: config) + } + + // MARK: - Cloud API + + /// Performs OCR via cloud API (OpenAI-compatible format for vllm) + private func recognizeViaCloudAPI(image: CGImage, config: Configuration) async throws -> OCRResult { + guard !config.cloudBaseURL.isEmpty else { + throw PaddleOCREngineError.invalidConfiguration("Cloud API base URL is not configured") + } + + // Build URL for OpenAI-compatible endpoint + let apiURL: URL + if config.cloudBaseURL.hasSuffix("/v1") || config.cloudBaseURL.contains("/v1/") { + apiURL = URL(string: config.cloudBaseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")))! + .appendingPathComponent("chat/completions") + } else { + apiURL = URL(string: config.cloudBaseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")))! + .appendingPathComponent("v1") + .appendingPathComponent("chat") + .appendingPathComponent("completions") + } + + // Convert image to base64 data URL + guard let data = CFDataCreateMutable(nil, 0) else { + throw PaddleOCREngineError.failedToSaveImage + } + guard let destination = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil) else { + throw PaddleOCREngineError.failedToSaveImage + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + throw PaddleOCREngineError.failedToSaveImage + } + let base64Image = (data as Data).base64EncodedString() + let imageURL = "data:image/png;base64,\(base64Image)" + + // Build OpenAI-compatible request + var request = URLRequest(url: apiURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if !config.cloudAPIKey.isEmpty { + request.setValue("Bearer \(config.cloudAPIKey)", forHTTPHeaderField: "Authorization") + } + + // Build OpenAI chat completion format + let modelName = config.cloudModelId.isEmpty ? "default" : config.cloudModelId + let body: [String: Any] = [ + "model": modelName, + "messages": [ + [ + "role": "user", + "content": [ + ["type": "text", "text": "Please recognize all text in this image and return the results in JSON format with 'text', 'confidence', and 'box' (x, y, width, height) for each text region."], + ["type": "image_url", "image_url": ["url": imageURL]] + ] + ] + ], + "max_tokens": 4096 + ] + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + + Logger.ocr.info("PaddleOCR cloud API request to: \(apiURL.absoluteString)") + + // Execute request + let (responseData, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw PaddleOCREngineError.cloudAPIError("Invalid HTTP response") + } + + Logger.ocr.info("PaddleOCR cloud API response status: \(httpResponse.statusCode)") + + guard httpResponse.statusCode == 200 else { + let errorMessage = String(data: responseData, encoding: .utf8) ?? "Unknown error" + Logger.ocr.error("PaddleOCR cloud API error: \(errorMessage)") + throw PaddleOCREngineError.cloudAPIError("Cloud API error: HTTP \(httpResponse.statusCode) - \(errorMessage)") + } + + // Parse response + return try parseCloudAPIResponse(responseData, imageSize: CGSize(width: image.width, height: image.height)) + } + + /// Parses cloud API response into OCRResult (OpenAI chat completion format) + private func parseCloudAPIResponse(_ data: Data, imageSize: CGSize) throws -> OCRResult { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw PaddleOCREngineError.cloudAPIError("Invalid JSON response") + } + + Logger.ocr.debug("Parsing cloud API response") + + var observations: [OCRText] = [] + + // Parse OpenAI chat completion format + if let choices = json["choices"] as? [[String: Any]], + let firstChoice = choices.first, + let message = firstChoice["message"] as? [String: Any], + let content = message["content"] as? String { + // Try to parse JSON from the content + if let jsonData = extractJSON(from: content), + let results = try? JSONSerialization.jsonObject(with: jsonData) as? [[String: Any]] { + // Parse structured OCR results + for result in results { + guard let text = result["text"] as? String else { continue } + let confidence = Float(result["confidence"] as? Double ?? 1.0) + + var boundingBox = CGRect.zero + if let box = result["box"] as? [String: Double] { + boundingBox = CGRect( + x: box["x"] ?? 0, + y: box["y"] ?? 0, + width: box["width"] ?? 0, + height: box["height"] ?? 0 + ) + } + + observations.append(OCRText( + text: text, + boundingBox: boundingBox, + confidence: confidence + )) + } + } else { + // Fallback: treat entire content as single text block + observations.append(OCRText( + text: content, + boundingBox: CGRect(origin: .zero, size: imageSize), + confidence: 1.0 + )) + } + } else if let results = json["results"] as? [[String: Any]] { + // Legacy format with direct results array + for result in results { + guard let text = result["text"] as? String else { continue } + let confidence = Float(result["confidence"] as? Double ?? 1.0) + + var boundingBox = CGRect.zero + if let box = result["box"] as? [String: Double] { + boundingBox = CGRect( + x: box["x"] ?? 0, + y: box["y"] ?? 0, + width: box["width"] ?? 0, + height: box["height"] ?? 0 + ) + } + + observations.append(OCRText( + text: text, + boundingBox: boundingBox, + confidence: confidence + )) + } + } else if let text = json["text"] as? String { + // Simple text response + observations.append(OCRText( + text: text, + boundingBox: CGRect(origin: .zero, size: imageSize), + confidence: 1.0 + )) + } + + Logger.ocr.info("Parsed \(observations.count) text observations from cloud API") + return OCRResult(observations: observations, imageSize: imageSize) + } + + /// Extracts JSON array from text content (handles markdown code blocks) + private func extractJSON(from content: String) -> Data? { + // Try to extract JSON from markdown code block + if let range = content.range(of: "```json"), + let endRange = content.range(of: "```", range: content.index(range.upperBound, offsetBy: 0).. URL { + let tempDir = FileManager.default.temporaryDirectory + let tempURL = tempDir.appendingPathComponent( + "ocr_input_\(UUID().uuidString).png" + ) + + guard let destination = CGImageDestinationCreateWithURL( + tempURL as CFURL, + UTType.png.identifier as CFString, + 1, + nil + ) else { + throw PaddleOCREngineError.failedToSaveImage + } + + CGImageDestinationAddImage(destination, image, nil) + + guard CGImageDestinationFinalize(destination) else { + throw PaddleOCREngineError.failedToSaveImage + } + + return tempURL + } + + /// Builds command line arguments for PaddleOCR + private func buildArguments(config: Configuration, imagePath: String) -> [String] { + switch config.mode { + case .fast: + // Fast mode: use ocr command (~1s) + let langCode = config.languages.contains(.chinese) ? "ch" : "en" + return [ + "ocr", + "-i", imagePath, + "--lang", langCode, + "--use_angle_cls", config.useDirectionClassify ? "true" : "false" + ] + case .precise: + // Precise mode: use doc_parser with VL-1.5 + var args = [ + "doc_parser", + "-i", imagePath, + "--pipeline_version", "v1.5", + "--device", config.useGPU ? "gpu" : "cpu" + ] + + // Use native backend with local model (vllm) + if !config.localVLModelDir.isEmpty { + // Expand tilde in path (e.g., ~/.paddlex -> /Users/xxx/.paddlex) + let expandedPath = NSString(string: config.localVLModelDir).expandingTildeInPath + args += [ + "--vl_rec_backend", "native", + "--vl_rec_model_dir", expandedPath + ] + } + + return args + } + } + + /// Executes PaddleOCR with the given arguments + private func executePaddleOCR(arguments: [String]) async throws -> String { + let fullCommand = "\(executablePath) \(arguments.joined(separator: " "))" + Logger.ocr.info("Executing: \(fullCommand)") + + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/zsh") + task.arguments = ["-c", fullCommand] + + task.environment = [ + "PATH": "\(NSHomeDirectory())/.pyenv/shims:\(NSHomeDirectory())/.pyenv/bin:/usr/local/bin:/usr/bin:/bin", + "HOME": NSHomeDirectory(), + "PYENV_ROOT": "\(NSHomeDirectory())/.pyenv", + "PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK": "True" + ] + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + task.standardOutput = stdoutPipe + task.standardError = stderrPipe + + do { + try task.run() + Logger.ocr.debug("Process started, waiting...") + task.waitUntilExit() + Logger.ocr.debug("Process finished with exit code: \(task.terminationStatus)") + + let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + var stdout = String(data: stdoutData, encoding: .utf8) ?? "" + let stderr = String(data: stderrData, encoding: .utf8) ?? "" + + // PaddleOCR outputs result to stderr, extract JSON from it + if stdout.isEmpty, let resultRange = stderr.range(of: "{'res':") { + let resultStart = stderr[resultRange.lowerBound...] + // Find the matching closing brace + if let jsonEnd = findMatchingBrace(in: String(resultStart)) { + stdout = String(resultStart.prefix(jsonEnd + 1)) + // Remove ANSI color codes + let ansiPattern = "\u{001B}\\[[0-9;]*m" + stdout = stdout.replacingOccurrences(of: ansiPattern, with: "", options: .regularExpression) + Logger.ocr.debug("Extracted result from stderr") + } + } + + Logger.ocr.debug("output length: \(stdout.count)") + Logger.ocr.debug("output: \(stdout.prefix(1000))") + + let exitCode = task.terminationStatus + if exitCode != 0 { + let errorMsg = stderr.isEmpty ? "Exit code \(exitCode)" : stderr + throw PaddleOCREngineError.recognitionFailed(underlying: errorMsg) + } + + guard !stdout.isEmpty else { + Logger.ocr.error("No result found in output") + throw PaddleOCREngineError.invalidOutput + } + + return stdout + } catch let error as PaddleOCREngineError { + throw error + } catch { + Logger.ocr.error("Error: \(error.localizedDescription)") + throw PaddleOCREngineError.recognitionFailed(underlying: error.localizedDescription) + } + } + + private func findMatchingBrace(in string: String) -> Int? { + var depth = 0 + for (index, char) in string.enumerated() { + if char == "{" { + depth += 1 + } else if char == "}" { + depth -= 1 + if depth == 0 { return index } + } + } + return nil + } + + /// Parses PaddleOCR output into OCRText observations + private func parsePaddleOCROutput(_ output: String, imageSize: CGSize, mode: PaddleOCRMode) throws -> [OCRText] { + var observations: [OCRText] = [] + + guard let startIndex = output.firstIndex(of: "{"), + let endIndex = output.lastIndex(of: "}") else { + Logger.ocr.debug("No JSON found in output") + return observations + } + + let jsonLike = String(output[startIndex...endIndex]) + let cleanedJson = convertPythonDictToJson(jsonLike) + + Logger.ocr.debug("Cleaned JSON: \(cleanedJson.prefix(500))") + + guard let jsonData = cleanedJson.data(using: .utf8) else { + Logger.ocr.error("Failed to convert cleaned JSON to data") + return observations + } + + // Try to parse JSON and log detailed error + var json: [String: Any]? + do { + json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] + } catch { + Logger.ocr.error("JSON parse error: \(error.localizedDescription)") + // Log the problematic JSON (last 1000 chars to find the issue) + if let jsonStr = String(data: jsonData, encoding: .utf8) { + Logger.ocr.error("JSON end portion: ...\(jsonStr.suffix(500))") + } + return observations + } + + guard let json = json else { + Logger.ocr.error("Failed to parse JSON as dictionary") + return observations + } + + guard let res = json["res"] as? [String: Any] else { + Logger.ocr.error("No 'res' key in JSON. Keys: \(json.keys.joined(separator: ", "))") + return observations + } + + switch mode { + case .fast: + // Fast mode: parse rec_texts format + observations = try parseFastModeOutput(res: res, imageSize: imageSize) + case .precise: + // Precise mode: parse doc_parser output format: parsing_res_list + observations = try parsePreciseModeOutput(res: res, imageSize: imageSize) + } + + return observations + } + + /// Parse fast mode output (ocr command) + private func parseFastModeOutput(res: [String: Any], imageSize: CGSize) throws -> [OCRText] { + var observations: [OCRText] = [] + + // Fast mode output has parallel arrays: rec_texts, rec_scores, rec_boxes + guard let recTexts = res["rec_texts"] as? [String] else { + Logger.ocr.error("No rec_texts found in fast mode output. Keys: \(res.keys.joined(separator: ", "))") + return observations + } + + // Get rec_boxes and rec_scores (optional) + let recBoxes = res["rec_boxes"] as? [[Double]] + let recScores = res["rec_scores"] as? [Double] + + Logger.ocr.info("Found \(recTexts.count) text blocks from fast mode") + + for (index, text) in recTexts.enumerated() { + guard !text.isEmpty else { continue } + + // Get bounding box from rec_boxes (format: [[x1, y1, x2, y2], ...]) + var boundingBox: CGRect + if let boxes = recBoxes, index < boxes.count { + let box = boxes[index] + if box.count >= 4 { + let x = CGFloat(box[0]) + let y = CGFloat(box[1]) + let x2 = CGFloat(box[2]) + let y2 = CGFloat(box[3]) + boundingBox = CGRect( + x: x / imageSize.width, + y: y / imageSize.height, + width: (x2 - x) / imageSize.width, + height: (y2 - y) / imageSize.height + ) + } else { + boundingBox = CGRect(x: 0, y: CGFloat(index) * 0.1, width: 1, height: 0.1) + } + } else { + // Fallback: stack vertically + boundingBox = CGRect(x: 0, y: CGFloat(index) * 0.1, width: 1, height: 0.1) + } + + // Get confidence from rec_scores + let confidence: Float + if let scores = recScores, index < scores.count { + confidence = Float(scores[index]) + } else { + confidence = 0.9 + } + + let observation = OCRText( + text: text, + boundingBox: boundingBox, + confidence: confidence + ) + observations.append(observation) + Logger.ocr.debug("Fast mode block: '\(text)', box: \(String(describing: boundingBox))") + } + + return observations + } + + /// Parse precise mode output (doc_parser VL-1.5) + private func parsePreciseModeOutput(res: [String: Any], imageSize: CGSize) throws -> [OCRText] { + var observations: [OCRText] = [] + + // Log all keys in res for debugging + Logger.ocr.info("Precise mode res keys: \(res.keys.joined(separator: ", "))") + + guard let parsingResList = res["parsing_res_list"] as? [[String: Any]] else { + Logger.ocr.error("No parsing_res_list found in res. Available keys: \(res.keys.joined(separator: ", "))") + // Try to log the raw res for debugging + if let resData = try? JSONSerialization.data(withJSONObject: res), + let resStr = String(data: resData, encoding: .utf8) { + Logger.ocr.debug("Raw res content: \(resStr.prefix(1000))") + } + return observations + } + + Logger.ocr.info("Found \(parsingResList.count) blocks from doc_parser") + + for (index, block) in parsingResList.enumerated() { + guard let text = block["block_content"] as? String else { + continue + } + + // Skip non-text blocks (charts, seals, images, etc.) + if let label = block["block_label"] as? String { + let skipLabels = ["chart", "seal", "image", "table", "figure"] + if skipLabels.contains(where: { label.lowercased().contains($0) }) { + Logger.ocr.debug("Skipping non-text block: \(label)") + continue + } + } + + var boundingBox: CGRect + if let bbox = block["block_bbox"] as? [Double], bbox.count >= 4 { + let x = CGFloat(bbox[0]) + let y = CGFloat(bbox[1]) + let x2 = CGFloat(bbox[2]) + let y2 = CGFloat(bbox[3]) + boundingBox = CGRect( + x: x / imageSize.width, + y: y / imageSize.height, + width: (x2 - x) / imageSize.width, + height: (y2 - y) / imageSize.height + ) + } else { + boundingBox = CGRect(x: 0, y: CGFloat(index) * 0.1, width: 1, height: 0.1) + } + + // doc_parser doesn't provide confidence scores per block, use default + let confidence: Float = 0.9 + + let observation = OCRText( + text: text, + boundingBox: boundingBox, + confidence: confidence + ) + observations.append(observation) + Logger.ocr.debug("Block: '\(text)', box: \(String(describing: boundingBox))") + } + + return observations + } + + private func convertPythonDictToJson(_ pythonDict: String) -> String { + var result = pythonDict + result = result.replacingOccurrences(of: "None", with: "null") + result = result.replacingOccurrences(of: "True", with: "true") + result = result.replacingOccurrences(of: "False", with: "false") + result = result.replacingOccurrences(of: "'", with: "\"") + + result = convertNumpyArraysToJson(result) + + // Fix float format: "8." -> "8.0", "-5." -> "-5.0" (valid JSON) + let floatPattern = #"(-?\d+)\.\s*([,\]\}])"# + if let regex = try? NSRegularExpression(pattern: floatPattern) { + let range = NSRange(result.startIndex..., in: result) + result = regex.stringByReplacingMatches(in: result, options: [], range: range, withTemplate: "$1.0$2") + } + + return result + } + + private func convertNumpyArraysToJson(_ input: String) -> String { + var result = input + var searchStart = result.startIndex + + while let arrayStart = result.range(of: "array(", range: searchStart.. 0 { + let char = result[current] + if char == "(" { + depth += 1 + } else if char == ")" { + depth -= 1 + } + current = result.index(after: current) + } + + guard depth == 0 else { + searchStart = arrayStart.upperBound + continue + } + + let arrayContent = String(result[arrayStart.upperBound.. String { + var content = arrayContent + + // Remove shape and dtype info + if let shapeRange = content.range(of: ", shape=") { + content = String(content[.. CGRect? { + nil + } +} + +// MARK: - PaddleOCR Engine Errors + +/// Errors that can occur during PaddleOCR operations +enum PaddleOCREngineError: LocalizedError, Sendable { + /// PaddleOCR is not installed + case notInstalled + + /// OCR operation is already in progress + case operationInProgress + + /// The provided image is invalid or empty + case invalidImage + + /// Failed to save image to temporary file + case failedToSaveImage + + /// Text recognition failed with an underlying error + case recognitionFailed(underlying: String) + + /// Invalid output from PaddleOCR + case invalidOutput + + /// Invalid configuration + case invalidConfiguration(String) + + /// Cloud API error + case cloudAPIError(String) + + var errorDescription: String? { + switch self { + case .notInstalled: + return NSLocalizedString( + "error.paddleocr.not.installed", + comment: "PaddleOCR is not installed" + ) + case .operationInProgress: + return NSLocalizedString( + "error.ocr.in.progress", + comment: "OCR operation is already in progress" + ) + case .invalidImage: + return NSLocalizedString( + "error.ocr.invalid.image", + comment: "The provided image is invalid or empty" + ) + case .failedToSaveImage: + return NSLocalizedString( + "error.paddleocr.save.image", + comment: "Failed to save image for processing" + ) + case .recognitionFailed: + return NSLocalizedString( + "error.ocr.failed", + comment: "Text recognition failed" + ) + case .invalidOutput: + return NSLocalizedString( + "error.paddleocr.invalid.output", + comment: "Invalid output from PaddleOCR" + ) + case .invalidConfiguration(let message): + return message + case .cloudAPIError(let message): + return message + } + } + + var recoverySuggestion: String? { + switch self { + case .notInstalled: + return NSLocalizedString( + "error.paddleocr.not.installed.recovery", + comment: "Install PaddleOCR using: pip install paddleocr" + ) + case .operationInProgress: + return NSLocalizedString( + "error.ocr.in.progress.recovery", + comment: "Wait for the current operation to complete" + ) + case .invalidImage: + return NSLocalizedString( + "error.ocr.invalid.image.recovery", + comment: "Provide a valid image with non-zero dimensions" + ) + case .failedToSaveImage: + return NSLocalizedString( + "error.paddleocr.save.image.recovery", + comment: "Check disk space and permissions" + ) + case .recognitionFailed(let message): + return message + case .invalidOutput: + return NSLocalizedString( + "error.paddleocr.invalid.output.recovery", + comment: "Ensure PaddleOCR is correctly installed" + ) + case .invalidConfiguration: + return NSLocalizedString( + "error.paddleocr.invalid.config.recovery", + comment: "Check your PaddleOCR cloud API settings" + ) + case .cloudAPIError: + return NSLocalizedString( + "error.paddleocr.cloud.api.recovery", + comment: "Check your network connection and cloud API settings" + ) + } + } +} diff --git a/ScreenTranslate/Services/PaddleOCRVLMProvider.swift b/ScreenTranslate/Services/PaddleOCRVLMProvider.swift new file mode 100644 index 0000000..a4f8565 --- /dev/null +++ b/ScreenTranslate/Services/PaddleOCRVLMProvider.swift @@ -0,0 +1,255 @@ +// +// PaddleOCRVLMProvider.swift +// ScreenTranslate +// +// PaddleOCR as a VLM provider for local, free, offline text extraction. +// + +import CoreGraphics +import Foundation + +/// PaddleOCR-based VLM provider for local text extraction. +/// Uses PaddleOCREngine for OCR and converts results to ScreenAnalysisResult. +struct PaddleOCRVLMProvider: VLMProvider, Sendable { + // MARK: - VLMProvider Properties + + let id: String = "paddleocr" + let name: String = "PaddleOCR" + + /// Empty configuration (PaddleOCR doesn't need API keys or URLs) + let configuration: VLMProviderConfiguration + + /// Default base URL for local PaddleOCR (not used, but required by protocol) + private static let defaultBaseURL = URL(string: "http://localhost")! + + // MARK: - Initialization + + init() { + // Create an empty configuration for PaddleOCR + self.configuration = VLMProviderConfiguration( + apiKey: "", + baseURL: Self.defaultBaseURL, + modelName: "paddleocr" + ) + } + + // MARK: - VLMProvider Protocol + + var isAvailable: Bool { + get async { + // Check settings to determine mode + let useCloud = await MainActor.run { AppSettings.shared.paddleOCRUseCloud } + if useCloud { + // Cloud mode is available if base URL is configured + let baseURL = await MainActor.run { AppSettings.shared.paddleOCRCloudBaseURL } + return !baseURL.trimmingCharacters(in: .whitespaces).isEmpty + } else { + // Local mode requires PaddleOCR to be installed + return await PaddleOCREngine.shared.isAvailable + } + } + } + + func analyze(image: CGImage) async throws -> ScreenAnalysisResult { + // Build configuration from AppSettings first + let config = await buildConfiguration() + + // Check local availability only for local mode + if !config.useCloud { + guard await PaddleOCREngine.shared.isAvailable else { + throw VLMProviderError.invalidConfiguration( + NSLocalizedString("error.paddleocr.notInstalled", comment: "PaddleOCR not installed error") + ) + } + } + + // Perform OCR using PaddleOCREngine with settings + let ocrResult = try await PaddleOCREngine.shared.recognize(image, config: config) + + // Convert OCRResult to ScreenAnalysisResult + return convertToScreenAnalysisResult(ocrResult, mode: config.mode) + } + + // MARK: - Private Methods + + @MainActor + private func buildConfiguration() -> PaddleOCREngine.Configuration { + let settings = AppSettings.shared + var config = PaddleOCREngine.Configuration.default + config.mode = settings.paddleOCRMode + config.useCloud = settings.paddleOCRUseCloud + config.cloudBaseURL = settings.paddleOCRCloudBaseURL + config.cloudAPIKey = settings.paddleOCRCloudAPIKey + config.cloudModelId = settings.paddleOCRCloudModelId + config.localVLModelDir = settings.paddleOCRLocalVLModelDir + return config + } + + private func convertToScreenAnalysisResult(_ ocrResult: OCRResult, mode: PaddleOCRMode) -> ScreenAnalysisResult { + // For precise mode (doc_parser), the output is already in block format, no need to group + // For fast mode (ocr command), we need to group into lines + let segments: [TextSegment] + switch mode { + case .precise: + // Precise mode: already in block format, convert directly + segments = ocrResult.observations.map { observation in + TextSegment( + text: observation.text, + boundingBox: observation.boundingBox, + confidence: observation.confidence + ) + } + case .fast: + // Fast mode: group into lines based on vertical position + let lines = groupIntoLines(ocrResult.observations, imageSize: ocrResult.imageSize) + segments = lines.map { line -> TextSegment in + TextSegment( + text: line.text, + boundingBox: line.boundingBox, + confidence: line.confidence + ) + } + } + + return ScreenAnalysisResult( + segments: segments, + imageSize: ocrResult.imageSize + ) + } + + /// Groups OCR texts into lines based on vertical position overlap + private func groupIntoLines(_ observations: [OCRText], imageSize: CGSize) -> [MergedLine] { + guard !observations.isEmpty else { return [] } + + // Sort by Y position (top to bottom), then by X position (left to right) + let sortedObservations = observations.sorted { a, b in + let yTolerance = min(a.boundingBox.height, b.boundingBox.height) * 0.5 + if abs(a.boundingBox.minY - b.boundingBox.minY) > yTolerance { + return a.boundingBox.minY < b.boundingBox.minY + } + return a.boundingBox.minX < b.boundingBox.minX + } + + var lines: [MergedLine] = [] + var currentLine: MergedLine? + + for observation in sortedObservations { + if let line = currentLine { + // Check if this observation is on the same line (Y position overlap) + let yOverlap = max(0, + min(line.boundingBox.maxY, observation.boundingBox.maxY) - + max(line.boundingBox.minY, observation.boundingBox.minY) + ) + let minHeight = min(line.boundingBox.height, observation.boundingBox.height) + + // If there's significant Y overlap, add to current line + if yOverlap > minHeight * 0.3 { + currentLine = line.merged(with: observation) + } else { + // Start a new line + lines.append(line) + currentLine = MergedLine(from: observation) + } + } else { + currentLine = MergedLine(from: observation) + } + } + + // Don't forget the last line + if let line = currentLine { + lines.append(line) + } + + return lines + } +} + +/// Helper struct to merge OCR texts into lines +private struct MergedLine { + let text: String + let boundingBox: CGRect + let confidence: Float + + init(text: String, boundingBox: CGRect, confidence: Float) { + self.text = text + self.boundingBox = boundingBox + self.confidence = confidence + } + + init(from observation: OCRText) { + self.text = observation.text + self.boundingBox = observation.boundingBox + self.confidence = observation.confidence + } + + func merged(with other: OCRText) -> MergedLine { + // Combine texts with appropriate separator for CJK vs non-CJK + let separator = Self.separator(for: text, and: other.text) + let combinedText = text + separator + other.text + + // Merge bounding boxes + let mergedBox = boundingBox.union(other.boundingBox) + + // Average confidence weighted by text length + let totalLength = text.count + other.text.count + let weightedConfidence: Float + if totalLength == 0 { + // Edge case: both texts are empty, use average of confidences + weightedConfidence = (confidence + other.confidence) / 2.0 + } else { + weightedConfidence = ( + Float(text.count) * confidence + + Float(other.text.count) * other.confidence + ) / Float(totalLength) + } + + return MergedLine( + text: combinedText, + boundingBox: mergedBox, + confidence: weightedConfidence + ) + } + + /// Returns appropriate separator between two text segments based on CJK detection + /// Checks the last character of the first string and the first character of the second string + private static func separator(for first: String, and second: String) -> String { + // Check last character of first string and first character of second string + // This handles mixed-content cases like "Hello世界" correctly + guard let firstLast = first.last, + let secondFirst = second.first else { + return " " // Default to space if either string is empty + } + + let firstLastIsCJK = isCJKChar(firstLast) + let secondFirstIsCJK = isCJKChar(secondFirst) + // No space between CJK characters, space otherwise + return (firstLastIsCJK && secondFirstIsCJK) ? "" : " " + } + + /// Checks if a character is CJK (Chinese/Japanese/Korean) + private static func isCJKChar(_ char: Character) -> Bool { + // Check all unicode scalars to handle surrogate pairs correctly + for scalar in char.unicodeScalars { + let value = scalar.value + // CJK Unified Ideographs: U+4E00-U+9FFF + // CJK Unified Ideographs Extension A: U+3400-U+4DBF + // Hiragana: U+3040-U+309F + // Katakana: U+30A0-U+30FF + // Hangul Syllables: U+AC00-U+D7AF + // CJK Symbols and Punctuation: U+3000-U+303F + // Fullwidth Forms: U+FF00-U+FFEF + // CJK Extension B-F: U+20000-U+2FA1F + if (0x4E00...0x9FFF).contains(value) || + (0x3400...0x4DBF).contains(value) || + (0x3040...0x309F).contains(value) || + (0x30A0...0x30FF).contains(value) || + (0xAC00...0xD7AF).contains(value) || + (0x3000...0x303F).contains(value) || + (0xFF00...0xFFEF).contains(value) || + (0x20000...0x2FA1F).contains(value) { + return true + } + } + return false + } +} diff --git a/ScreenTranslate/Services/PermissionManager.swift b/ScreenTranslate/Services/PermissionManager.swift new file mode 100644 index 0000000..4e5c2fb --- /dev/null +++ b/ScreenTranslate/Services/PermissionManager.swift @@ -0,0 +1,317 @@ +// +// PermissionManager.swift +// ScreenTranslate +// +// Created for US-009 - Handle accessibility and input monitoring permissions +// + +import Foundation +import ApplicationServices +import AppKit +import Combine + +/// Manager for handling system permissions required by the app. +/// Centralizes permission checking, requesting, and caching logic. +@MainActor +final class PermissionManager: ObservableObject { + // MARK: - Singleton + + static let shared = PermissionManager() + + // MARK: - Published Properties + + /// Current accessibility permission status + @Published private(set) var hasAccessibilityPermission: Bool = false + + /// Current input monitoring permission status + @Published private(set) var hasInputMonitoringPermission: Bool = false + + // MARK: - Private Properties + + /// UserDefaults key for cached accessibility permission status + private let accessibilityCacheKey = "cachedAccessibilityPermission" + + /// UserDefaults key for cached input monitoring permission status + private let inputMonitoringCacheKey = "cachedInputMonitoringPermission" + + /// Last time permission status was checked (for throttling) + private var lastCheckTime: Date = .distantPast + + /// Minimum interval between permission checks (in seconds) + private let checkInterval: TimeInterval = 5.0 + + // MARK: - Initialization + + private init() { + // Load cached values + loadCachedPermissions() + + // Check actual permissions + refreshPermissionStatus() + + // Setup notification observers for app activation + setupNotificationObservers() + } + + // MARK: - Public API + + /// Checks and refreshes all permission statuses. + func refreshPermissionStatus() { + lastCheckTime = Date() + + hasAccessibilityPermission = AXIsProcessTrusted() + + // Check Input Monitoring permission (macOS 10.15+) + if #available(macOS 10.15, *) { + hasInputMonitoringPermission = checkInputMonitoringPermission() + } else { + hasInputMonitoringPermission = true + } + + // Cache the results + cachePermissions() + } + + /// Refreshes permission status with throttling to avoid excessive checks. + /// Only refreshes if at least `checkInterval` seconds have passed since the last check. + func refreshIfNeeded() { + let now = Date() + if now.timeIntervalSince(lastCheckTime) >= checkInterval { + refreshPermissionStatus() + } + } + + /// Requests accessibility permission directly via system prompt. + /// - Returns: Whether permission was granted after the prompt. + @discardableResult + func requestAccessibilityPermission() -> Bool { + // First check if already granted + if hasAccessibilityPermission { + return true + } + + // Directly trigger the system permission dialog + let options: CFDictionary = ["AXTrustedCheckOptionPrompt": true] as CFDictionary + let granted = AXIsProcessTrustedWithOptions(options) + + // Update our status + hasAccessibilityPermission = granted + cachePermissions() + + return granted + } + + /// Requests input monitoring permission by opening System Settings directly. + /// - Returns: Whether permission was granted. + @discardableResult + func requestInputMonitoringPermission() -> Bool { + // First check if already granted + if hasInputMonitoringPermission { + return true + } + + // Directly open System Settings to Privacy & Security > Input Monitoring + openInputMonitoringSettings() + + return hasInputMonitoringPermission + } + + /// Opens System Settings to the Accessibility pane. + func openAccessibilitySettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + NSWorkspace.shared.open(url) + } + } + + /// Opens System Settings to the Input Monitoring pane. + func openInputMonitoringSettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent") { + NSWorkspace.shared.open(url) + } + } + + /// Checks if text selection capture is possible (requires accessibility). + var canCaptureTextSelection: Bool { + hasAccessibilityPermission + } + + /// Checks if text insertion is possible (requires accessibility). + var canInsertText: Bool { + hasAccessibilityPermission + } + + /// Ensures accessibility permission is available, requesting if needed. + /// - Returns: Whether permission is available. + func ensureAccessibilityPermission() async -> Bool { + // Check current status + refreshPermissionStatus() + + if hasAccessibilityPermission { + return true + } + + // Request permission + return requestAccessibilityPermission() + } + + /// Ensures input monitoring permission is available, requesting if needed. + /// - Returns: Whether permission is available. + func ensureInputMonitoringPermission() async -> Bool { + // Check current status + refreshPermissionStatus() + + if hasInputMonitoringPermission { + return true + } + + // Request permission + return requestInputMonitoringPermission() + } + + // MARK: - Permission Checks + + /// Checks Input Monitoring permission status. + @available(macOS 10.15, *) + private func checkInputMonitoringPermission() -> Bool { + let options = ["AXTrustedCheckOptionPrompt": false] as CFDictionary + return AXIsProcessTrustedWithOptions(options) + } + + /// Shows a permission denied error in the translation popup. + func showPermissionDeniedError(for permissionType: PermissionType) { + let alert = NSAlert() + alert.alertStyle = .warning + + switch permissionType { + case .accessibility: + alert.messageText = NSLocalizedString( + "permission.accessibility.denied.title", + value: "Accessibility Permission Required", + comment: "Title for accessibility denied error" + ) + alert.informativeText = NSLocalizedString( + "permission.accessibility.denied.message", + value: "Text capture and insertion requires accessibility permission.\n\nPlease grant permission in System Settings > Privacy & Security > Accessibility.", + comment: "Message for accessibility denied error" + ) + alert.addButton(withTitle: NSLocalizedString( + "permission.open.settings", + value: "Open System Settings", + comment: "Button to open System Settings" + )) + alert.addButton(withTitle: NSLocalizedString( + "common.ok", + value: "OK", + comment: "OK button" + )) + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + openAccessibilitySettings() + } + + case .inputMonitoring: + alert.messageText = NSLocalizedString( + "permission.input.monitoring.denied.title", + value: "Input Monitoring Permission Required", + comment: "Title for input monitoring denied error" + ) + alert.informativeText = NSLocalizedString( + "permission.input.monitoring.denied.message", + value: "Text insertion requires input monitoring permission.\n\nPlease grant permission in System Settings > Privacy & Security > Input Monitoring.", + comment: "Message for input monitoring denied error" + ) + alert.addButton(withTitle: NSLocalizedString( + "permission.open.settings", + value: "Open System Settings", + comment: "Button to open System Settings" + )) + alert.addButton(withTitle: NSLocalizedString( + "common.ok", + value: "OK", + comment: "OK button" + )) + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + openInputMonitoringSettings() + } + } + } + + // MARK: - Permission Types + + enum PermissionType { + case accessibility + case inputMonitoring + } + + // MARK: - Caching + + /// Caches current permission status to UserDefaults. + private func cachePermissions() { + UserDefaults.standard.set(hasAccessibilityPermission, forKey: accessibilityCacheKey) + UserDefaults.standard.set(hasInputMonitoringPermission, forKey: inputMonitoringCacheKey) + } + + /// Loads cached permission status from UserDefaults. + private func loadCachedPermissions() { + // Note: These are just cached values for UI display + // Actual permission check happens in refreshPermissionStatus() + hasAccessibilityPermission = UserDefaults.standard.bool(forKey: accessibilityCacheKey) + hasInputMonitoringPermission = UserDefaults.standard.bool(forKey: inputMonitoringCacheKey) + } + + // MARK: - Permission Monitoring + + /// Sets up notification observers for app lifecycle events. + private func setupNotificationObservers() { + // Refresh permissions when app becomes active (user may have changed settings) + NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.refreshIfNeeded() + } + } + } + + /// Removes notification observers. + func stopPermissionMonitoring() { + NotificationCenter.default.removeObserver(self) + } +} + +// MARK: - Convenience Extensions + +extension PermissionManager { + /// Checks accessibility permission before performing a text operation. + /// Shows an error dialog if permission is not granted. + /// - Returns: Whether permission is available. + func checkAndPromptAccessibility() -> Bool { + refreshPermissionStatus() + + if hasAccessibilityPermission { + return true + } + + showPermissionDeniedError(for: .accessibility) + return false + } + + /// Checks input monitoring permission before performing a text insertion. + /// Shows an error dialog if permission is not granted. + /// - Returns: Whether permission is available. + func checkAndPromptInputMonitoring() -> Bool { + refreshPermissionStatus() + + if hasInputMonitoringPermission { + return true + } + + showPermissionDeniedError(for: .inputMonitoring) + return false + } +} diff --git a/ScreenTranslate/Services/Protocols/TextInsertServicing.swift b/ScreenTranslate/Services/Protocols/TextInsertServicing.swift new file mode 100644 index 0000000..6c981e9 --- /dev/null +++ b/ScreenTranslate/Services/Protocols/TextInsertServicing.swift @@ -0,0 +1,26 @@ +// +// TextInsertServicing.swift +// ScreenTranslate +// +// Protocol abstraction for TextInsertService to enable testing +// + +import Foundation + +/// Protocol for text insertion service operations. +/// Provides abstraction for testing and dependency injection. +protocol TextInsertServicing: Sendable { + /// Inserts text at the current cursor position + /// - Parameter text: The text to insert + /// - Throws: InsertError if insertion fails + func insertText(_ text: String) async throws + + /// Deletes the current selection and inserts text at that position + /// - Parameter text: The text to insert after deletion + /// - Throws: InsertError if the operation fails + func deleteSelectionAndInsert(_ text: String) async throws +} + +// MARK: - TextInsertService Conformance + +extension TextInsertService: TextInsertServicing {} diff --git a/ScreenTranslate/Services/Protocols/TextSelectionServicing.swift b/ScreenTranslate/Services/Protocols/TextSelectionServicing.swift new file mode 100644 index 0000000..a2b0e9c --- /dev/null +++ b/ScreenTranslate/Services/Protocols/TextSelectionServicing.swift @@ -0,0 +1,21 @@ +// +// TextSelectionServicing.swift +// ScreenTranslate +// +// Protocol abstraction for TextSelectionService to enable testing +// + +import Foundation + +/// Protocol for text selection service operations. +/// Provides abstraction for testing and dependency injection. +protocol TextSelectionServicing: Sendable { + /// Captures the currently selected text from the active application + /// - Returns: The captured text selection result + /// - Throws: CaptureError if capture fails + func captureSelectedText() async throws -> TextSelectionResult +} + +// MARK: - TextSelectionService Conformance + +extension TextSelectionService: TextSelectionServicing {} diff --git a/ScreenTranslate/Services/Protocols/TranslationServicing.swift b/ScreenTranslate/Services/Protocols/TranslationServicing.swift new file mode 100644 index 0000000..20bc858 --- /dev/null +++ b/ScreenTranslate/Services/Protocols/TranslationServicing.swift @@ -0,0 +1,112 @@ +// +// TranslationServicing.swift +// ScreenTranslate +// +// Protocol abstraction for TranslationService to enable testing +// + +import Foundation + +/// Protocol for translation service operations. +/// Provides abstraction for testing and dependency injection. +protocol TranslationServicing: Sendable { + /// Translates segments using the provided routing configuration + /// - Parameters: + /// - segments: Source texts to translate + /// - targetLanguage: Target language code + /// - preferredEngine: Primary engine for modes that need one + /// - sourceLanguage: Source language code (nil for auto-detect) + /// - mode: Engine selection mode + /// - fallbackEnabled: Whether fallback should be attempted + /// - parallelEngines: Engines to run in parallel mode + /// - sceneBindings: Scene-specific routing bindings + /// - Returns: Array of bilingual segments with source and translated text + func translate( + segments: [String], + to targetLanguage: String, + preferredEngine: TranslationEngineType, + from sourceLanguage: String?, + scene: TranslationScene?, + mode: EngineSelectionMode, + fallbackEnabled: Bool, + parallelEngines: [TranslationEngineType], + sceneBindings: [TranslationScene: SceneEngineBinding] + ) async throws -> [BilingualSegment] + + /// Translates segments and returns a full result bundle with per-engine details. + /// Use this when you need information about which engines succeeded/failed. + func translateBundle( + segments: [String], + to targetLanguage: String, + preferredEngine: TranslationEngineType, + from sourceLanguage: String?, + scene: TranslationScene?, + mode: EngineSelectionMode, + fallbackEnabled: Bool, + parallelEngines: [TranslationEngineType], + sceneBindings: [TranslationScene: SceneEngineBinding] + ) async throws -> TranslationResultBundle +} + +// MARK: - Default Implementation + +extension TranslationServicing { + func translateBundle( + segments: [String], + to targetLanguage: String, + preferredEngine: TranslationEngineType = .apple, + from sourceLanguage: String? = nil, + scene: TranslationScene? = nil, + mode: EngineSelectionMode = .primaryWithFallback, + fallbackEnabled: Bool = true, + parallelEngines: [TranslationEngineType] = [], + sceneBindings: [TranslationScene: SceneEngineBinding] = [:] + ) async throws -> TranslationResultBundle { + let bilingualSegments = try await translate( + segments: segments, + to: targetLanguage, + preferredEngine: preferredEngine, + from: sourceLanguage, + scene: scene, + mode: mode, + fallbackEnabled: fallbackEnabled, + parallelEngines: parallelEngines, + sceneBindings: sceneBindings + ) + return TranslationResultBundle( + results: [EngineResult(engine: preferredEngine, segments: bilingualSegments, latency: 0)], + primaryEngine: preferredEngine, + selectionMode: mode, + scene: scene + ) + } +} + +// MARK: - TranslationService Conformance + +@available(macOS 13.0, *) +extension TranslationService: TranslationServicing { + func translateBundle( + segments: [String], + to targetLanguage: String, + preferredEngine: TranslationEngineType = .apple, + from sourceLanguage: String? = nil, + scene: TranslationScene? = nil, + mode: EngineSelectionMode = .primaryWithFallback, + fallbackEnabled: Bool = true, + parallelEngines: [TranslationEngineType] = [], + sceneBindings: [TranslationScene: SceneEngineBinding] = [:] + ) async throws -> TranslationResultBundle { + return try await translate( + segments: segments, + to: targetLanguage, + from: sourceLanguage, + scene: scene, + mode: mode, + preferredEngine: preferredEngine, + fallbackEnabled: fallbackEnabled, + parallelEngines: parallelEngines, + sceneBindings: sceneBindings + ) + } +} diff --git a/ScreenTranslate/Services/ScreenCoderEngine.swift b/ScreenTranslate/Services/ScreenCoderEngine.swift new file mode 100644 index 0000000..d0279fb --- /dev/null +++ b/ScreenTranslate/Services/ScreenCoderEngine.swift @@ -0,0 +1,185 @@ +// +// ScreenCoderEngine.swift +// ScreenTranslate +// +// Created for US-008: ScreenCoder Engine +// + +import CoreGraphics +import Foundation + +// MARK: - ScreenCoder Engine Errors + +/// Errors specific to the ScreenCoder engine +enum ScreenCoderEngineError: LocalizedError, Sendable { + case noProviderConfigured + case providerNotAvailable(String) + case invalidConfiguration(String) + + var errorDescription: String? { + switch self { + case .noProviderConfigured: + return "No VLM provider is configured. Please configure a provider in Settings." + case .providerNotAvailable(let name): + return "The VLM provider '\(name)' is not available. Check your API key and network connection." + case .invalidConfiguration(let message): + return "Invalid configuration: \(message)" + } + } +} + +// MARK: - ScreenCoder Engine + +/// Unified engine for VLM-based screen analysis. +/// Manages multiple VLM providers and routes analysis requests to the currently selected provider. +/// +/// Usage: +/// ```swift +/// let engine = ScreenCoderEngine.shared +/// let result = try await engine.analyze(image: cgImage) +/// ``` +actor ScreenCoderEngine { + // MARK: - Singleton + + /// Shared instance for app-wide screen analysis operations + static let shared = ScreenCoderEngine() + + // MARK: - Properties + + /// Cached provider instances by type + private var providerCache: [VLMProviderType: any VLMProvider] = [:] + + /// Last known configuration hash for cache invalidation + private var lastConfigurationHash: Int = 0 + + // MARK: - Initialization + + private init() {} + + // MARK: - Public API + + /// Analyzes an image using the currently configured VLM provider. + /// - Parameter image: The CGImage to analyze + /// - Returns: ScreenAnalysisResult containing extracted text segments with positions + /// - Throws: ScreenCoderEngineError or VLMProviderError if analysis fails + func analyze(image: CGImage) async throws -> ScreenAnalysisResult { + let provider = try await currentProvider() + + guard await provider.isAvailable else { + throw ScreenCoderEngineError.providerNotAvailable(provider.name) + } + + return try await provider.analyze(image: image) + } + + /// Returns the currently configured VLM provider. + /// - Returns: The active VLMProvider instance + /// - Throws: ScreenCoderEngineError if no provider is configured + func currentProvider() async throws -> any VLMProvider { + let settings = await MainActor.run { AppSettings.shared } + let providerType = await MainActor.run { settings.vlmProvider } + + return try await provider(for: providerType) + } + + /// Returns a provider instance for the specified type. + /// Creates a new instance if not cached or if configuration has changed. + /// - Parameter type: The VLM provider type + /// - Returns: A configured VLMProvider instance + /// - Throws: ScreenCoderEngineError if configuration is invalid + func provider(for type: VLMProviderType) async throws -> any VLMProvider { + let currentHash = await configurationHash() + + if currentHash != lastConfigurationHash { + providerCache.removeAll() + lastConfigurationHash = currentHash + } + + if let cached = providerCache[type] { + return cached + } + + let newProvider = try await createProvider(for: type) + providerCache[type] = newProvider + return newProvider + } + + /// Checks if the current provider is available and properly configured. + /// - Returns: true if the provider is ready for use + func isCurrentProviderAvailable() async -> Bool { + guard let provider = try? await currentProvider() else { + return false + } + return await provider.isAvailable + } + + /// Clears the provider cache, forcing recreation on next use. + /// Call this when settings change significantly. + func invalidateCache() { + providerCache.removeAll() + lastConfigurationHash = 0 + } + + /// Returns all supported provider types. + var supportedProviderTypes: [VLMProviderType] { + VLMProviderType.allCases + } + + // MARK: - Private Methods + + /// Creates a provider instance for the given type using current settings. + private func createProvider(for type: VLMProviderType) async throws -> any VLMProvider { + let (apiKey, baseURLString, modelName, glmOCRMode) = await MainActor.run { + let settings = AppSettings.shared + return (settings.vlmAPIKey, settings.vlmBaseURL, settings.vlmModelName, settings.glmOCRMode) + } + + let effectiveBaseURL = baseURLString.isEmpty ? type.defaultBaseURL(glmOCRMode: glmOCRMode) : baseURLString + let effectiveModel = modelName.isEmpty ? type.defaultModelName(glmOCRMode: glmOCRMode) : modelName + + guard let baseURL = URL(string: effectiveBaseURL) else { + throw ScreenCoderEngineError.invalidConfiguration("Invalid base URL: \(effectiveBaseURL)") + } + + if type.requiresAPIKey(glmOCRMode: glmOCRMode) && apiKey.isEmpty { + throw ScreenCoderEngineError.invalidConfiguration( + "\(type.localizedName) requires an API key. Please configure it in Settings." + ) + } + + let configuration = VLMProviderConfiguration( + apiKey: apiKey, + baseURL: baseURL, + modelName: effectiveModel + ) + + switch type { + case .openai: + return OpenAIVLMProvider(configuration: configuration) + case .claude: + return ClaudeVLMProvider(configuration: configuration) + case .glmOCR: + return GLMOCRVLMProvider(configuration: configuration, mode: glmOCRMode) + case .ollama: + return OllamaVLMProvider(configuration: configuration) + case .paddleocr: + return PaddleOCRVLMProvider() + } + } + + /// Computes a hash of the current configuration for cache invalidation. + private func configurationHash() async -> Int { + let (providerType, apiKey, baseURL, modelName, glmOCRMode) = await MainActor.run { + let settings = AppSettings.shared + return (settings.vlmProvider, settings.vlmAPIKey, settings.vlmBaseURL, settings.vlmModelName, settings.glmOCRMode) + } + + var hasher = Hasher() + hasher.combine(providerType) + hasher.combine(glmOCRMode) + hasher.combine(apiKey) + hasher.combine(baseURL) + hasher.combine(modelName) + return hasher.finalize() + } +} diff --git a/ScreenTranslate/Services/Security/KeychainService.swift b/ScreenTranslate/Services/Security/KeychainService.swift new file mode 100644 index 0000000..de5a649 --- /dev/null +++ b/ScreenTranslate/Services/Security/KeychainService.swift @@ -0,0 +1,462 @@ +// +// KeychainService.swift +// ScreenTranslate +// +// Secure storage for API keys and credentials using macOS Keychain +// + +import Foundation +import Security +import os.log + +// MARK: - Keychain Service + +/// Actor-based service for secure credential storage using macOS Keychain +actor KeychainService { + /// Shared singleton instance + static let shared = KeychainService() + + /// Service identifier for Keychain items + static let serviceIdentifier = "com.screentranslate.credentials" + + /// PaddleOCR cloud account identifier + static let paddleOCRAccount = "paddleocr_cloud" + + /// Logger instance + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ScreenTranslate", category: "KeychainService") + + /// Internal service property for instance methods + private var service: String { Self.serviceIdentifier } + + private init() {} + + // MARK: - Public API + + /// Save credentials for a translation engine + /// - Parameters: + /// - apiKey: The API key to store + /// - engine: The engine type these credentials are for + /// - additionalData: Optional additional data (e.g., appID for Baidu) + func saveCredentials( + apiKey: String, + for engine: TranslationEngineType, + additionalData: [String: String]? = nil + ) throws { + let credentials = StoredCredentials( + apiKey: apiKey, + appID: additionalData?["appID"], + additional: additionalData + ) + + try saveCredentialsInternal( + credentials: credentials, + account: engine.rawValue, + label: engine.rawValue + ) + } + + /// Internal helper for saving credentials to keychain + /// - Parameters: + /// - credentials: The credentials to save + /// - account: The account identifier for the keychain item + /// - label: A descriptive label for logging + private func saveCredentialsInternal(credentials: StoredCredentials, account: String, label: String) throws { + guard let encodedData = try? JSONEncoder().encode(credentials) else { + throw KeychainError.invalidData + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + + // Check if item exists and update it, or add new if not found + let status = SecItemCopyMatching(query as CFDictionary, nil) + if status == errSecSuccess { + // Item exists - update it + let updateQuery: [String: Any] = [ + kSecValueData as String: encodedData, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked + ] + let updateStatus = SecItemUpdate(query as CFDictionary, updateQuery as CFDictionary) + guard updateStatus == errSecSuccess else { + logger.error("Failed to update credentials for \(label): \(updateStatus)") + throw KeychainError.unexpectedStatus(updateStatus) + } + logger.info("Updated credentials for \(label)") + } else if status == errSecItemNotFound { + // Item doesn't exist - add new + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: encodedData, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked + ] + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + guard addStatus == errSecSuccess else { + logger.error("Failed to save credentials for \(label): \(addStatus)") + throw KeychainError.unexpectedStatus(addStatus) + } + logger.info("Saved credentials for \(label)") + } else { + logger.error("Failed to check credentials for \(label): \(status)") + throw KeychainError.unexpectedStatus(status) + } + } + + /// Retrieve stored credentials for an engine + /// - Parameter engine: The engine type to get credentials for + /// - Returns: The stored credentials, or nil if not found + func getCredentials(for engine: TranslationEngineType) throws -> StoredCredentials? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: engine.rawValue, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { + if status == errSecItemNotFound { + logger.debug("No credentials found for \(engine.rawValue)") + return nil + } + logger.error("Failed to retrieve credentials for \(engine.rawValue): \(status)") + throw KeychainError.unexpectedStatus(status) + } + + guard let data = result as? Data else { + throw KeychainError.invalidData + } + + let credentials = try JSONDecoder().decode(StoredCredentials.self, from: data) + logger.debug("Retrieved credentials for \(engine.rawValue)") + return credentials + } + + /// Delete stored credentials for an engine + /// - Parameter engine: The engine type to delete credentials for + func deleteCredentials(for engine: TranslationEngineType) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: engine.rawValue + ] + + let status = SecItemDelete(query as CFDictionary) + + guard status == errSecSuccess || status == errSecItemNotFound else { + logger.error("Failed to delete credentials for \(engine.rawValue): \(status)") + throw KeychainError.unexpectedStatus(status) + } + + logger.info("Deleted credentials for \(engine.rawValue)") + } + + /// Check if credentials exist for an engine + /// - Parameter engine: The engine type to check + /// - Returns: True if credentials exist + func hasCredentials(for engine: TranslationEngineType) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: engine.rawValue, + kSecReturnData as String: false, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + return status == errSecSuccess + } + + /// Get only the API key for an engine (convenience method) + /// - Parameter engine: The engine type + /// - Returns: The API key, or nil if not found + func getAPIKey(for engine: TranslationEngineType) -> String? { + do { + return try getCredentials(for: engine)?.apiKey + } catch { + logger.error("Error getting API key for \(engine.rawValue): \(error.localizedDescription)") + return nil + } + } + + // MARK: - Compatible Engine Methods (String-based identifiers) + + /// Save credentials for a compatible engine instance + /// - Parameters: + /// - apiKey: The API key to store + /// - compatibleId: The compatible engine identifier (e.g., "custom:0", "custom:1") + func saveCredentials(apiKey: String, forCompatibleId compatibleId: String) throws { + let credentials = StoredCredentials(apiKey: apiKey) + try saveCredentialsInternal( + credentials: credentials, + account: compatibleId, + label: "compatible engine \(compatibleId)" + ) + } + + /// Retrieve stored credentials for a compatible engine instance + /// - Parameter compatibleId: The compatible engine identifier + /// - Returns: The stored credentials, or nil if not found + func getCredentials(forCompatibleId compatibleId: String) throws -> StoredCredentials? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: compatibleId, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { + if status == errSecItemNotFound { + logger.debug("No credentials found for \(compatibleId)") + return nil + } + logger.error("Failed to retrieve credentials for \(compatibleId): \(status)") + throw KeychainError.unexpectedStatus(status) + } + + guard let data = result as? Data else { + throw KeychainError.invalidData + } + + let credentials = try JSONDecoder().decode(StoredCredentials.self, from: data) + logger.debug("Retrieved credentials for \(compatibleId)") + return credentials + } + + /// Check if credentials exist for a compatible engine instance + /// - Parameter compatibleId: The compatible engine identifier + /// - Returns: True if credentials exist + func hasCredentials(forCompatibleId compatibleId: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: compatibleId, + kSecReturnData as String: false, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + return status == errSecSuccess + } + + /// Delete stored credentials for a compatible engine instance + /// - Parameter compatibleId: The compatible engine identifier + func deleteCredentials(forCompatibleId compatibleId: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: compatibleId + ] + + let status = SecItemDelete(query as CFDictionary) + + guard status == errSecSuccess || status == errSecItemNotFound else { + logger.error("Failed to delete credentials for \(compatibleId): \(status)") + throw KeychainError.unexpectedStatus(status) + } + + logger.info("Deleted credentials for compatible engine \(compatibleId)") + } + + /// Delete all stored credentials + func deleteAllCredentials() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service + ] + + let status = SecItemDelete(query as CFDictionary) + + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unexpectedStatus(status) + } + + logger.info("Deleted all credentials") + } + + // MARK: - PaddleOCR Cloud Methods + + /// Save PaddleOCR cloud API key + /// - Parameter apiKey: The API key to store + func savePaddleOCRCredentials(apiKey: String) throws { + let credentials = StoredCredentials(apiKey: apiKey) + try saveCredentialsInternal( + credentials: credentials, + account: Self.paddleOCRAccount, + label: "PaddleOCR cloud" + ) + } + + /// Retrieve stored PaddleOCR cloud API key + /// - Returns: The stored API key, or nil if not found + func getPaddleOCRCredentials() -> String? { + let account = Self.paddleOCRAccount + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { + if status == errSecItemNotFound { + logger.debug("No PaddleOCR cloud credentials found") + return nil + } + logger.error("Failed to retrieve PaddleOCR cloud credentials: \(status)") + return nil + } + + guard let data = result as? Data else { + return nil + } + + let credentials = try? JSONDecoder().decode(StoredCredentials.self, from: data) + return credentials?.apiKey + } + + /// Delete stored PaddleOCR cloud credentials + func deletePaddleOCRCredentials() throws { + let account = Self.paddleOCRAccount + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + + let status = SecItemDelete(query as CFDictionary) + + guard status == errSecSuccess || status == errSecItemNotFound else { + logger.error("Failed to delete PaddleOCR cloud credentials: \(status)") + throw KeychainError.unexpectedStatus(status) + } + + logger.info("Deleted PaddleOCR cloud credentials") + } +} + +// MARK: - Stored Credentials + +/// Structure for stored credentials +struct StoredCredentials: Codable, Sendable { + /// Primary API key + let apiKey: String + + /// Application ID (required for Baidu) + let appID: String? + + /// Additional data fields + let additional: [String: String]? + + init(apiKey: String, appID: String? = nil, additional: [String: String]? = nil) { + self.apiKey = apiKey + self.appID = appID + self.additional = additional + } +} + +// MARK: - Keychain Error + +/// Errors that can occur during Keychain operations +enum KeychainError: LocalizedError, Sendable { + /// The requested item was not found in Keychain + case itemNotFound + + /// An item with the same identifier already exists + case duplicateItem + + /// The data format is invalid or corrupted + case invalidData + + /// An unexpected OS status was returned + case unexpectedStatus(OSStatus) + + var errorDescription: String? { + switch self { + case .itemNotFound: + return NSLocalizedString( + "keychain.error.item_not_found", + comment: "Credentials not found in Keychain" + ) + case .duplicateItem: + return NSLocalizedString( + "keychain.error.duplicate_item", + comment: "Credentials already exist in Keychain" + ) + case .invalidData: + return NSLocalizedString( + "keychain.error.invalid_data", + comment: "Invalid credential data format" + ) + case .unexpectedStatus(let status): + return NSLocalizedString( + "keychain.error.unexpected_status", + comment: "Keychain operation failed with status: \(status)" + ) + " (\(status))" + } + } + + var recoverySuggestion: String? { + switch self { + case .itemNotFound: + return NSLocalizedString( + "keychain.error.item_not_found.recovery", + comment: "Please configure your API credentials in Settings" + ) + case .duplicateItem: + return NSLocalizedString( + "keychain.error.duplicate_item.recovery", + comment: "Try deleting existing credentials first" + ) + case .invalidData: + return NSLocalizedString( + "keychain.error.invalid_data.recovery", + comment: "Try re-entering your credentials" + ) + case .unexpectedStatus: + return NSLocalizedString( + "keychain.error.unexpected_status.recovery", + comment: "Please check your Keychain access permissions" + ) + } + } +} + +// MARK: - OSStatus Extension + +extension OSStatus { + /// Convert OSStatus to NSError for better error messages + var asNSError: NSError { + let domain = NSOSStatusErrorDomain + let code = Int(self) + let description = SecCopyErrorMessageString(self, nil) as String? + return NSError( + domain: domain, + code: code, + userInfo: [ + NSLocalizedDescriptionKey: description ?? "Unknown keychain error" + ] + ) + } +} diff --git a/ScreenTranslate/Services/TextInsertService.swift b/ScreenTranslate/Services/TextInsertService.swift new file mode 100644 index 0000000..41ffb6d --- /dev/null +++ b/ScreenTranslate/Services/TextInsertService.swift @@ -0,0 +1,356 @@ +// +// TextInsertService.swift +// ScreenTranslate +// +// Created for US-005: Add copy and insert buttons to translation popup +// + +import Foundation +import CoreGraphics +import ApplicationServices +import os + +/// Service for inserting text into the currently focused input field. +/// Uses CGEvent keyboard simulation to type text character by character. +actor TextInsertService { + private let logger = Logger.general + + // MARK: - Types + + /// Errors that can occur during text insertion + enum InsertError: LocalizedError, Sendable { + /// Failed to create keyboard event + case eventCreationFailed + /// Accessibility permission is required but not granted + case accessibilityPermissionDenied + /// Text contains characters that cannot be typed + case unsupportedCharacters(String) + + var errorDescription: String? { + switch self { + case .eventCreationFailed: + return NSLocalizedString( + "error.text.insert.event.failed", + value: "Failed to create keyboard event", + comment: "" + ) + case .accessibilityPermissionDenied: + return NSLocalizedString( + "error.text.insert.accessibility.denied", + value: "Accessibility permission is required to insert text", + comment: "" + ) + case .unsupportedCharacters(let chars): + return NSLocalizedString( + "error.text.insert.unsupported.chars", + value: "Cannot type characters: \(chars)", + comment: "" + ) + } + } + + var recoverySuggestion: String? { + switch self { + case .eventCreationFailed: + return NSLocalizedString( + "error.text.insert.event.failed.recovery", + value: "Please try again", + comment: "" + ) + case .accessibilityPermissionDenied: + return NSLocalizedString( + "error.text.insert.accessibility.denied.recovery", + value: "Grant accessibility permission in System Settings > Privacy & Security > Accessibility", + comment: "" + ) + case .unsupportedCharacters: + return NSLocalizedString( + "error.text.insert.unsupported.chars.recovery", + value: "Some characters cannot be typed with keyboard simulation", + comment: "" + ) + } + } + } + + // MARK: - Properties + + /// Delay between keystrokes in seconds (for reliability) + private let keystrokeDelay: TimeInterval + + // MARK: - Initialization + + init(keystrokeDelay: TimeInterval = 0.01) { + self.keystrokeDelay = keystrokeDelay + } + + // MARK: - Public API + + /// Inserts text into the currently focused input field by simulating keyboard input. + /// - Parameter text: The text to insert + /// - Throws: InsertError if the insertion fails + func insertText(_ text: String) async throws { + // Check accessibility permission + guard AXIsProcessTrusted() else { + throw InsertError.accessibilityPermissionDenied + } + + guard !text.isEmpty else { return } + + // Get the event source + guard let source = CGEventSource(stateID: .hidSystemState) else { + throw InsertError.eventCreationFailed + } + + // Type each character + for character in text { + try await typeCharacter(character, source: source) + // Small delay between characters for reliability + try await Task.sleep(nanoseconds: UInt64(keystrokeDelay * 1_000_000_000)) + } + } + + /// Deletes the currently selected text and inserts new text via Unicode events. + /// This bypasses the input method and inserts text directly. + /// - Parameter text: The text to insert after deleting the selection + /// - Throws: InsertError if the operation fails + func deleteSelectionAndInsert(_ text: String) async throws { + // Check accessibility permission + guard AXIsProcessTrusted() else { + throw InsertError.accessibilityPermissionDenied + } + + // Get the event source + guard let source = CGEventSource(stateID: .hidSystemState) else { + throw InsertError.eventCreationFailed + } + + // Step 1: Delete selected text by simulating Delete key + try postDeleteKey(source: source) + + // Longer delay after delete to ensure focus is maintained + try await Task.sleep(nanoseconds: 100_000_000) // 100ms + + // Step 2: Insert text using Unicode events (bypasses input method) + try await insertUnicodeText(text, source: source) + + #if DEBUG + logger.debug("Inserted \(text.count, privacy: .public) characters via Unicode events") + #endif + } + + /// Inserts text using Unicode keyboard events, bypassing input methods + private func insertUnicodeText(_ text: String, source: CGEventSource) async throws { + // Process text in chunks that can be sent via keyboardSetUnicodeString + // The maximum safe chunk is around 20 characters + let chunkSize = 20 + let characters = Array(text) + + #if DEBUG + logger.debug( + "Starting Unicode insertion of \(characters.count, privacy: .public) chars in \(max(1, (characters.count + chunkSize - 1) / chunkSize), privacy: .public) chunk(s)" + ) + #endif + + for i in stride(from: 0, to: characters.count, by: chunkSize) { + let endIndex = min(i + chunkSize, characters.count) + let chunk = characters[i..": 47, " ": 49, + "`": 50, "~": 50 + ] + + /// Returns the key code for a given ASCII character, or nil for non-ASCII. + /// + /// This method provides key codes based on the US keyboard layout for ASCII characters. + /// For non-ASCII characters (including international characters), the system falls back + /// to Unicode input via `postUnicodeEvent`, which works correctly regardless of the + /// current keyboard layout. + /// + /// - Parameter character: The character to get the key code for + /// - Returns: The CGKeyCode for the character, or nil if not an ASCII character + private func keyCodeForCharacter(_ character: Character) -> CGKeyCode? { + return Self.keyCodeMap[character] + } +} + +// MARK: - Shared Instance + +extension TextInsertService { + /// Shared instance for convenience + static let shared = TextInsertService() +} diff --git a/ScreenTranslate/Services/TextSelectionService.swift b/ScreenTranslate/Services/TextSelectionService.swift new file mode 100644 index 0000000..188d2d6 --- /dev/null +++ b/ScreenTranslate/Services/TextSelectionService.swift @@ -0,0 +1,337 @@ +import Foundation +import AppKit +import CoreGraphics +import ApplicationServices +import os + +/// Result of text selection capture +struct TextSelectionResult: Sendable { + /// The captured selected text + let text: String + /// The name of the source application (if available) + let sourceApplication: String? + /// The bundle identifier of the source application (if available) + let sourceBundleIdentifier: String? +} + +/// Service for capturing selected text from any application. +/// Uses clipboard-based capture with Cmd+C simulation to reliably get selected text. +actor TextSelectionService { + private let logger = Logger.capture + + // MARK: - Types + + /// Errors that can occur during text selection capture + enum CaptureError: LocalizedError, Sendable { + /// No text was selected in the active application + case noSelection + /// Failed to simulate keyboard shortcut + case keyboardSimulationFailed + /// Failed to access clipboard + case clipboardAccessFailed + /// Failed to restore original clipboard content + case clipboardRestoreFailed + /// The operation timed out + case timeout + /// Accessibility permission is required but not granted + case accessibilityPermissionDenied + + var errorDescription: String? { + switch self { + case .noSelection: + return NSLocalizedString( + "error.text.selection.no.selection", + value: "No text is currently selected", + comment: "" + ) + case .keyboardSimulationFailed: + return NSLocalizedString( + "error.text.selection.keyboard.failed", + value: "Failed to simulate keyboard shortcut", + comment: "" + ) + case .clipboardAccessFailed: + return NSLocalizedString( + "error.text.selection.clipboard.access.failed", + value: "Failed to access clipboard", + comment: "" + ) + case .clipboardRestoreFailed: + return NSLocalizedString( + "error.text.selection.clipboard.restore.failed", + value: "Failed to restore original clipboard content", + comment: "" + ) + case .timeout: + return NSLocalizedString( + "error.text.selection.timeout", + value: "Text capture operation timed out", + comment: "" + ) + case .accessibilityPermissionDenied: + return NSLocalizedString( + "error.text.selection.accessibility.denied", + value: "Accessibility permission is required to capture text", + comment: "" + ) + } + } + + var recoverySuggestion: String? { + switch self { + case .noSelection: + return NSLocalizedString( + "error.text.selection.no.selection.recovery", + value: "Select some text in any application and try again", + comment: "" + ) + case .keyboardSimulationFailed, .clipboardAccessFailed: + return NSLocalizedString( + "error.text.selection.general.recovery", + value: "Please try again", + comment: "" + ) + case .clipboardRestoreFailed: + return NSLocalizedString( + "error.text.selection.clipboard.restore.recovery", + value: "Your original clipboard content may have been replaced", + comment: "" + ) + case .timeout: + return NSLocalizedString( + "error.text.selection.timeout.recovery", + value: "The application may be busy. Please try again", + comment: "" + ) + case .accessibilityPermissionDenied: + return NSLocalizedString( + "error.text.selection.accessibility.denied.recovery", + value: "Grant accessibility permission in System Settings > Privacy & Security > Accessibility", + comment: "" + ) + } + } + } + + // MARK: - Properties + + /// Time to wait for clipboard to be updated after Cmd+C + private let clipboardWaitTimeout: TimeInterval + + /// Number of times to check clipboard before giving up + private let clipboardCheckRetries: Int + + /// Delay between clipboard checks + private let clipboardCheckInterval: TimeInterval + + // MARK: - Initialization + + init( + clipboardWaitTimeout: TimeInterval = 2.0, + clipboardCheckRetries: Int = 20, + clipboardCheckInterval: TimeInterval = 0.1 + ) { + self.clipboardWaitTimeout = clipboardWaitTimeout + self.clipboardCheckRetries = clipboardCheckRetries + self.clipboardCheckInterval = clipboardCheckInterval + } + + // MARK: - Public API + + /// Captures currently selected text from the active application. + /// - Returns: A TextSelectionResult containing the selected text and source application info + /// - Throws: CaptureError if the capture fails + func captureSelectedText() async throws -> TextSelectionResult { + // Check accessibility permission + guard AXIsProcessTrusted() else { + throw CaptureError.accessibilityPermissionDenied + } + + // Get source application info before capturing + let sourceAppInfo = getActiveApplicationInfo() + + // Save current clipboard content + let savedClipboard = try saveClipboardContent() + + // Clear clipboard to detect when new content is pasted + clearClipboard() + + // Simulate Cmd+C to copy selected text + try simulateCopyShortcut() + + // Wait for clipboard to be updated with selected text + let capturedText = try await waitForClipboardUpdate(previousChangeCount: savedClipboard?.changeCount ?? -1) + + // Restore original clipboard content + if let saved = savedClipboard { + do { + try restoreClipboardContent(saved) + } catch { + // Log but don't fail - we still got the text + logger.warning("Failed to restore clipboard: \(error.localizedDescription, privacy: .private(mask: .hash))") + } + } + + // Validate we got some text + guard !capturedText.isEmpty else { + throw CaptureError.noSelection + } + + return TextSelectionResult( + text: capturedText, + sourceApplication: sourceAppInfo.name, + sourceBundleIdentifier: sourceAppInfo.bundleIdentifier + ) + } + + /// Checks if text selection capture is likely to work. + /// Returns false if accessibility permission is not granted. + var canCapture: Bool { + AXIsProcessTrusted() + } + + // MARK: - Private Helpers + + /// Information about the active application + private struct ApplicationInfo { + let name: String? + let bundleIdentifier: String? + } + + /// Gets information about the currently active application. + private func getActiveApplicationInfo() -> ApplicationInfo { + let workspace = NSWorkspace.shared + let frontmostApp = workspace.frontmostApplication + + return ApplicationInfo( + name: frontmostApp?.localizedName, + bundleIdentifier: frontmostApp?.bundleIdentifier + ) + } + + /// Represents saved clipboard content + private struct SavedClipboardContent: Sendable { + let string: String? + let items: [[String: Data]] + let changeCount: Int + } + + /// Saves the current clipboard content for later restoration. + private func saveClipboardContent() throws -> SavedClipboardContent? { + let pasteboard = NSPasteboard.general + let changeCount = pasteboard.changeCount + + guard let types = pasteboard.types, !types.isEmpty else { + return nil + } + + let stringContent = pasteboard.string(forType: .string) + + var items: [[String: Data]] = [] + for item in pasteboard.pasteboardItems ?? [] { + var itemData: [String: Data] = [:] + for type in item.types { + if let data = item.data(forType: type) { + itemData[type.rawValue] = data + } + } + if !itemData.isEmpty { + items.append(itemData) + } + } + + return SavedClipboardContent( + string: stringContent, + items: items, + changeCount: changeCount + ) + } + + /// Clears the clipboard content. + private func clearClipboard() { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + } + + /// Simulates Cmd+C keyboard shortcut to copy selected text. + private func simulateCopyShortcut() throws { + // Create Cmd+C key down event + guard let keyDownEvent = CGEvent( + keyboardEventSource: nil, + virtualKey: 8, // kVK_ANSI_C + keyDown: true + ) else { + throw CaptureError.keyboardSimulationFailed + } + + // Create Cmd+C key up event + guard let keyUpEvent = CGEvent( + keyboardEventSource: nil, + virtualKey: 8, // kVK_ANSI_C + keyDown: false + ) else { + throw CaptureError.keyboardSimulationFailed + } + + // Set Command flag for both events + let cmdFlag = CGEventFlags.maskCommand + keyDownEvent.flags = cmdFlag + keyUpEvent.flags = cmdFlag + + // Post events + let loc = CGEventTapLocation.cghidEventTap + keyDownEvent.post(tap: loc) + keyUpEvent.post(tap: loc) + } + + /// Waits for clipboard to be updated with new content. + /// - Parameter previousChangeCount: The previous clipboard change count + /// - Returns: The new clipboard content + /// - Throws: CaptureError if timeout or no change detected + private func waitForClipboardUpdate(previousChangeCount: Int) async throws -> String { + for _ in 0.. TranslationResult { + guard !text.isEmpty else { + throw TranslationProviderError.emptyInput + } + + guard let credentials = try await keychain.getCredentials(for: .baidu), + let appID = credentials.appID else { + throw TranslationProviderError.invalidConfiguration("AppID or Secret Key not configured") + } + + let start = Date() + let salt = String(Int.random(in: 100000...999999)) + let sign = generateSign(query: text, appID: appID, salt: salt, secretKey: credentials.apiKey) + + // Build URL with query parameters (Baidu uses GET) + var components = URLComponents(string: baseURL)! + components.queryItems = [ + URLQueryItem(name: "q", value: text), + URLQueryItem(name: "from", value: mapLanguageCode(sourceLanguage)), + URLQueryItem(name: "to", value: mapLanguageCode(targetLanguage)), + URLQueryItem(name: "appid", value: appID), + URLQueryItem(name: "salt", value: salt), + URLQueryItem(name: "sign", value: sign) + ] + + var request = URLRequest(url: components.url!) + request.httpMethod = "GET" + request.timeoutInterval = config.options?.timeout ?? 30 + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw TranslationProviderError.connectionFailed("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + // Log status code only to avoid exposing user text in logs + logger.error("Baidu API error status=\(httpResponse.statusCode)") + throw TranslationProviderError.translationFailed("API error: \(httpResponse.statusCode)") + } + + let result = try parseResponse(data) + let latency = Date().timeIntervalSince(start) + + logger.info("Baidu translation completed in \(latency)s") + + return TranslationResult( + sourceText: text, + translatedText: result.translatedText, + sourceLanguage: result.sourceLanguage, + targetLanguage: result.targetLanguage + ) + } + + func translate( + texts: [String], + from sourceLanguage: String?, + to targetLanguage: String + ) async throws -> [TranslationResult] { + guard !texts.isEmpty else { return [] } + + // Baidu requires separate requests for each text + var results: [TranslationResult] = [] + results.reserveCapacity(texts.count) + + for text in texts { + let result = try await translate( + text: text, + from: sourceLanguage, + to: targetLanguage + ) + results.append(result) + } + + return results + } + + func checkConnection() async -> Bool { + do { + _ = try await translate(text: "test", from: "en", to: "zh") + return true + } catch { + logger.error("Baidu connection check failed: \(error.localizedDescription)") + return false + } + } + + // MARK: - Private Methods + + /// Generate MD5 sign for Baidu API + private func generateSign(query: String, appID: String, salt: String, secretKey: String) -> String { + let input = appID + query + salt + secretKey + return input.md5 + } + + /// Map language codes to Baidu format + private func mapLanguageCode(_ code: String?) -> String { + guard let code = code else { return "auto" } + + let mapping: [String: String] = [ + "auto": "auto", + "en": "en", + "zh": "zh", + "zh-Hans": "zh", + "zh-CN": "zh", + "zh-Hant": "cht", + "zh-TW": "cht", + "ja": "jp", + "ko": "kor", + "fr": "fra", + "de": "de", + "es": "spa", + "pt": "pt", + "ru": "ru", + "it": "it" + ] + + return mapping[code] ?? code + } + + private func parseResponse(_ data: Data) throws -> (translatedText: String, sourceLanguage: String, targetLanguage: String) { + // Check for error response + if let errorResponse = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errorCode = errorResponse["error_code"] as? String, + let errorMsg = errorResponse["error_msg"] as? String { + logger.error("Baidu API error: \(errorCode) - \(errorMsg)") + + if errorCode == "54003" { + throw TranslationProviderError.rateLimited(retryAfter: nil) + } else if errorCode == "54004" { + throw TranslationProviderError.invalidConfiguration("Invalid AppID or Secret Key") + } + + throw TranslationProviderError.translationFailed("Baidu error: \(errorMsg)") + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let transResult = json["trans_result"] as? [[String: Any]], + let firstResult = transResult.first, + let translatedText = firstResult["dst"] as? String else { + throw TranslationProviderError.translationFailed("Failed to parse Baidu response") + } + + let from = (json["from"] as? String) ?? "auto" + let to = (json["to"] as? String) ?? "zh" + + return (translatedText, from, to) + } +} + +// MARK: - String MD5 Extension + +extension String { + /// MD5 hash of the string + var md5: String { + let inputData = Data(self.utf8) + let hashed = Insecure.MD5.hash(data: inputData) + return hashed.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/ScreenTranslate/Services/Translation/Providers/CompatibleTranslationProvider.swift b/ScreenTranslate/Services/Translation/Providers/CompatibleTranslationProvider.swift new file mode 100644 index 0000000..0b501ab --- /dev/null +++ b/ScreenTranslate/Services/Translation/Providers/CompatibleTranslationProvider.swift @@ -0,0 +1,350 @@ +// +// CompatibleTranslationProvider.swift +// ScreenTranslate +// +// OpenAI-compatible custom translation provider +// + +import Foundation +import os.log + +/// OpenAI-compatible translation provider for custom endpoints +actor CompatibleTranslationProvider: TranslationProvider, TranslationPromptConfigurable, TranslationPromptContextProviding { + // MARK: - Properties + + nonisolated let id: String + nonisolated let name: String + nonisolated let configHash: Int + + private let config: TranslationEngineConfig + private let compatibleConfig: CompatibleConfig + private let promptConfigID: String? + private let keychain: KeychainService + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "ScreenTranslate", + category: "CompatibleTranslationProvider" + ) + + // MARK: - Configuration + + struct CompatibleConfig: Codable, Equatable, Sendable, Identifiable { + var id: UUID + var displayName: String + var baseURL: String + var modelName: String + var hasAPIKey: Bool + + init( + id: UUID = UUID(), + displayName: String, + baseURL: String, + modelName: String, + hasAPIKey: Bool = true + ) { + self.id = id + self.displayName = displayName + self.baseURL = baseURL + self.modelName = modelName + self.hasAPIKey = hasAPIKey + } + + static var `default`: CompatibleConfig { + CompatibleConfig( + displayName: "Custom", + baseURL: "http://localhost:8000/v1", + modelName: "default", + hasAPIKey: false + ) + } + + var keychainId: String { + return "custom:\(id.uuidString)" + } + + var configHash: Int { + var hasher = Hasher() + hasher.combine(baseURL) + hasher.combine(modelName) + hasher.combine(hasAPIKey) + return hasher.finalize() + } + } + + // MARK: - Initialization + + init(config: TranslationEngineConfig, keychain: KeychainService) async throws { + self.config = config + self.keychain = keychain + + // Parse compatible config from customName or create default + let resolvedCompatibleConfig: CompatibleConfig + if let customName = config.customName, + let jsonData = customName.data(using: .utf8), + let compatibleConfig = try? JSONDecoder().decode(CompatibleConfig.self, from: jsonData) { + resolvedCompatibleConfig = compatibleConfig + } else { + resolvedCompatibleConfig = .default + } + + let resolvedPromptConfigID = await MainActor.run { + AppSettings.shared.compatibleProviderConfigs.contains(where: { $0.id == resolvedCompatibleConfig.id }) + ? resolvedCompatibleConfig.id.uuidString + : nil + } + self.compatibleConfig = resolvedCompatibleConfig + self.promptConfigID = resolvedPromptConfigID + + self.id = "custom" + self.name = self.compatibleConfig.displayName + self.configHash = self.compatibleConfig.configHash + } + + init( + config: TranslationEngineConfig, + compatibleConfig: CompatibleConfig, + keychain: KeychainService + ) async throws { + self.config = config + self.compatibleConfig = compatibleConfig + self.promptConfigID = compatibleConfig.id.uuidString + self.keychain = keychain + self.id = compatibleConfig.keychainId + self.name = compatibleConfig.displayName + self.configHash = compatibleConfig.configHash + } + + // MARK: - TranslationProvider Protocol + + var isAvailable: Bool { + get async { + if compatibleConfig.hasAPIKey { + let keychainId = compatibleConfig.keychainId + return await keychain.hasCredentials(forCompatibleId: keychainId) + } + return true + } + } + + func translate( + text: String, + from sourceLanguage: String?, + to targetLanguage: String + ) async throws -> TranslationResult { + try await translate( + text: text, + from: sourceLanguage, + to: targetLanguage, + promptTemplate: nil + ) + } + + func translate( + texts: [String], + from sourceLanguage: String?, + to targetLanguage: String + ) async throws -> [TranslationResult] { + try await translate( + texts: texts, + from: sourceLanguage, + to: targetLanguage, + promptTemplate: nil + ) + } + + func translate( + texts: [String], + from sourceLanguage: String?, + to targetLanguage: String, + promptTemplate: String? + ) async throws -> [TranslationResult] { + guard !texts.isEmpty else { return [] } + + // Combine texts for efficiency + let combinedText = texts.joined(separator: "\n---\n") + let combinedResult = try await translate( + text: combinedText, + from: sourceLanguage, + to: targetLanguage, + promptTemplate: promptTemplate + ) + + let translatedTexts = combinedResult.translatedText.components(separatedBy: "\n---\n") + + if translatedTexts.count == texts.count { + return zip(texts, translatedTexts).map { source, translated in + TranslationResult( + sourceText: source, + translatedText: translated.trimmingCharacters(in: .whitespacesAndNewlines), + sourceLanguage: combinedResult.sourceLanguage, + targetLanguage: combinedResult.targetLanguage + ) + } + } + + // Split failed - translate individually to ensure correct mapping + logger.warning("Batch split failed, falling back to individual translations") + var results: [TranslationResult] = [] + results.reserveCapacity(texts.count) + for text in texts { + let result = try await translate( + text: text, + from: sourceLanguage, + to: targetLanguage, + promptTemplate: promptTemplate + ) + results.append(result) + } + return results + } + + func checkConnection() async -> Bool { + do { + _ = try await translate(text: "Hello", from: "en", to: "zh") + return true + } catch { + logger.error("Connection check failed: \(error.localizedDescription)") + return false + } + } + + func compatiblePromptIdentifier() async -> String? { + promptConfigID + } + + // MARK: - Private Methods + + private func buildPrompt( + text: String, + sourceLanguage: String?, + targetLanguage: String, + promptTemplate: String? + ) -> String { + let source = TranslationLanguage.promptDisplayName(for: sourceLanguage) + let target = TranslationLanguage.promptDisplayName(for: targetLanguage) + + if let template = promptTemplate { + return template + .replacingOccurrences(of: "{source_language}", with: source) + .replacingOccurrences(of: "{target_language}", with: target) + .replacingOccurrences(of: "{text}", with: text) + } + + return """ + Translate the following text from \(source) to \(target). + Provide ONLY the translated text without any explanations or additional text. + + Text to translate: + \(text) + """ + } + + private func callAPI( + prompt: String, + credentials: StoredCredentials? + ) async throws -> String { + let baseURL = compatibleConfig.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard let url = URL(string: baseURL) else { + throw TranslationProviderError.invalidConfiguration("Invalid base URL") + } + + // Build OpenAI-compatible endpoint: baseURL/chat/completions + let apiURL = url.appendingPathComponent("chat/completions") + + var request = URLRequest(url: apiURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = config.options?.timeout ?? 60 + + // Add authorization if API key is configured + if let apiKey = credentials?.apiKey { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + + // Build OpenAI-compatible request body + let body: [String: Any] = [ + "model": compatibleConfig.modelName, + "messages": [ + ["role": "user", "content": prompt] + ], + "temperature": config.options?.temperature ?? 0.3, + "max_tokens": config.options?.maxTokens ?? 2048 + ] + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw TranslationProviderError.connectionFailed("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + // Log status code only to avoid exposing user text in logs + logger.error("API error status=\(httpResponse.statusCode)") + + if httpResponse.statusCode == 401 { + throw TranslationProviderError.invalidConfiguration("Invalid API key") + } else if httpResponse.statusCode == 429 { + throw TranslationProviderError.rateLimited(retryAfter: nil) + } + + throw TranslationProviderError.translationFailed("API error: \(httpResponse.statusCode)") + } + + return try parseResponse(data) + } + + private func parseResponse(_ data: Data) throws -> String { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let firstChoice = choices.first, + let message = firstChoice["message"] as? [String: Any], + let content = message["content"] as? String else { + throw TranslationProviderError.translationFailed("Failed to parse response") + } + + return content.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func translate( + text: String, + from sourceLanguage: String?, + to targetLanguage: String, + promptTemplate: String? + ) async throws -> TranslationResult { + guard !text.isEmpty else { + throw TranslationProviderError.emptyInput + } + + let keychainId = compatibleConfig.keychainId + let credentials: StoredCredentials? + if compatibleConfig.hasAPIKey { + guard let creds = try await keychain.getCredentials(forCompatibleId: keychainId) else { + throw TranslationProviderError.invalidConfiguration("API key required but not found for \(compatibleConfig.displayName)") + } + credentials = creds + } else { + credentials = nil + } + + let prompt = buildPrompt( + text: text, + sourceLanguage: sourceLanguage, + targetLanguage: targetLanguage, + promptTemplate: promptTemplate + ) + + let start = Date() + let translatedText = try await callAPI(prompt: prompt, credentials: credentials) + let latency = Date().timeIntervalSince(start) + + logger.info("Custom translation completed in \(latency)s") + + return TranslationResult( + sourceText: text, + translatedText: translatedText, + sourceLanguage: sourceLanguage ?? "Auto", + targetLanguage: targetLanguage + ) + } +} diff --git a/ScreenTranslate/Services/Translation/Providers/DeepLTranslationProvider.swift b/ScreenTranslate/Services/Translation/Providers/DeepLTranslationProvider.swift new file mode 100644 index 0000000..bc8d7c1 --- /dev/null +++ b/ScreenTranslate/Services/Translation/Providers/DeepLTranslationProvider.swift @@ -0,0 +1,208 @@ +// +// DeepLTranslationProvider.swift +// ScreenTranslate +// +// DeepL Translation API provider +// + +import Foundation +import os.log + +/// DeepL Translation API provider +actor DeepLTranslationProvider: TranslationProvider { + // MARK: - Properties + + nonisolated let id: String = "deepl" + nonisolated let name: String = "DeepL" + + private let config: TranslationEngineConfig + private let keychain: KeychainService + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "ScreenTranslate", + category: "DeepLTranslationProvider" + ) + + private var baseURL: String { + // Use free tier URL if configured, otherwise pro tier + if let customURL = config.options?.baseURL, !customURL.isEmpty { + return customURL + } + return "https://api.deepl.com/v2/translate" + } + + // MARK: - Initialization + + init(config: TranslationEngineConfig, keychain: KeychainService) async throws { + self.config = config + self.keychain = keychain + } + + // MARK: - TranslationProvider Protocol + + var isAvailable: Bool { + get async { + await keychain.hasCredentials(for: .deepl) + } + } + + func translate( + text: String, + from sourceLanguage: String?, + to targetLanguage: String + ) async throws -> TranslationResult { + guard !text.isEmpty else { + throw TranslationProviderError.emptyInput + } + + guard let credentials = try await keychain.getCredentials(for: .deepl) else { + throw TranslationProviderError.invalidConfiguration("API key not configured") + } + + let start = Date() + + guard let url = URL(string: baseURL) else { + throw TranslationProviderError.invalidConfiguration("Invalid DeepL base URL: \(baseURL)") + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("DeepL-Auth-Key \(credentials.apiKey)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = config.options?.timeout ?? 30 + + var body: [String: Any] = [ + "text": [text], + "target_lang": targetLanguage.uppercased() + ] + + if let source = sourceLanguage { + body["source_lang"] = source.uppercased() + } + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw TranslationProviderError.connectionFailed("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + logger.error("DeepL API error (\(httpResponse.statusCode)): \(errorMessage)") + + if httpResponse.statusCode == 401 { + throw TranslationProviderError.invalidConfiguration("Invalid API key") + } else if httpResponse.statusCode == 429 { + throw TranslationProviderError.rateLimited(retryAfter: nil) + } else if httpResponse.statusCode == 456 { + throw TranslationProviderError.translationFailed("Quota exceeded") + } + + throw TranslationProviderError.translationFailed("API error: \(httpResponse.statusCode)") + } + + let translatedText = try parseResponse(data) + let latency = Date().timeIntervalSince(start) + + logger.info("DeepL translation completed in \(latency)s") + + return TranslationResult( + sourceText: text, + translatedText: translatedText, + sourceLanguage: sourceLanguage ?? "auto", + targetLanguage: targetLanguage + ) + } + + func translate( + texts: [String], + from sourceLanguage: String?, + to targetLanguage: String + ) async throws -> [TranslationResult] { + guard !texts.isEmpty else { return [] } + + // DeepL supports batch translation with multiple texts + guard let credentials = try await keychain.getCredentials(for: .deepl) else { + throw TranslationProviderError.invalidConfiguration("API key not configured") + } + + guard let url = URL(string: baseURL) else { + throw TranslationProviderError.invalidConfiguration("Invalid DeepL base URL: \(baseURL)") + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("DeepL-Auth-Key \(credentials.apiKey)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = config.options?.timeout ?? 30 + + var body: [String: Any] = [ + "text": texts, + "target_lang": targetLanguage.uppercased() + ] + + if let source = sourceLanguage { + body["source_lang"] = source.uppercased() + } + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw TranslationProviderError.connectionFailed("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + logger.error("DeepL API error (\(httpResponse.statusCode)): \(errorMessage)") + throw TranslationProviderError.translationFailed("API error: \(httpResponse.statusCode)") + } + + let translations = try parseBatchResponse(data) + + guard translations.count == texts.count else { + throw TranslationProviderError.translationFailed("DeepL batch response count mismatch: expected \(texts.count), got \(translations.count)") + } + + return zip(texts, translations).map { source, translated in + TranslationResult( + sourceText: source, + translatedText: translated, + sourceLanguage: sourceLanguage ?? "auto", + targetLanguage: targetLanguage + ) + } + } + + func checkConnection() async -> Bool { + do { + _ = try await translate(text: "test", from: "en", to: "zh") + return true + } catch { + logger.error("DeepL connection check failed: \(error.localizedDescription)") + return false + } + } + + // MARK: - Private Methods + + private func parseResponse(_ data: Data) throws -> String { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let translations = json["translations"] as? [[String: Any]], + let firstTranslation = translations.first, + let text = firstTranslation["text"] as? String else { + throw TranslationProviderError.translationFailed("Failed to parse DeepL response") + } + + return text + } + + private func parseBatchResponse(_ data: Data) throws -> [String] { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let translations = json["translations"] as? [[String: Any]] else { + throw TranslationProviderError.translationFailed("Failed to parse DeepL response") + } + + return translations.compactMap { $0["text"] as? String } + } +} diff --git a/ScreenTranslate/Services/Translation/Providers/GoogleTranslationProvider.swift b/ScreenTranslate/Services/Translation/Providers/GoogleTranslationProvider.swift new file mode 100644 index 0000000..d57d03f --- /dev/null +++ b/ScreenTranslate/Services/Translation/Providers/GoogleTranslationProvider.swift @@ -0,0 +1,219 @@ +// +// GoogleTranslationProvider.swift +// ScreenTranslate +// +// Google Cloud Translation API provider +// + +import Foundation +import os.log + +/// Google Cloud Translation API provider +actor GoogleTranslationProvider: TranslationProvider { + // MARK: - Properties + + nonisolated let id: String = "google" + nonisolated let name: String = "Google Translate" + + private let config: TranslationEngineConfig + private let keychain: KeychainService + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "ScreenTranslate", + category: "GoogleTranslationProvider" + ) + + private let baseURL = "https://translation.googleapis.com/language/translate/v2" + + // MARK: - Initialization + + init(config: TranslationEngineConfig, keychain: KeychainService) async throws { + self.config = config + self.keychain = keychain + } + + // MARK: - TranslationProvider Protocol + + var isAvailable: Bool { + get async { + await keychain.hasCredentials(for: .google) + } + } + + func translate( + text: String, + from sourceLanguage: String?, + to targetLanguage: String + ) async throws -> TranslationResult { + guard !text.isEmpty else { + throw TranslationProviderError.emptyInput + } + + guard let credentials = try await keychain.getCredentials(for: .google) else { + throw TranslationProviderError.invalidConfiguration("API key not configured") + } + + let start = Date() + + // Google Cloud Translation API v2 uses query parameter for API key + var urlComponents = URLComponents(string: baseURL)! + urlComponents.queryItems = [URLQueryItem(name: "key", value: credentials.apiKey)] + + guard let url = urlComponents.url else { + throw TranslationProviderError.invalidConfiguration("Invalid Google Translation API URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = config.options?.timeout ?? 30 + + var body: [String: Any] = [ + "q": text, + "target": targetLanguage, + "format": "text" + ] + + if let source = sourceLanguage { + body["source"] = source + } + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw TranslationProviderError.connectionFailed("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + // Log status code only to avoid exposing user text in logs + logger.error("Google API error status=\(httpResponse.statusCode)") + + if httpResponse.statusCode == 401 { + throw TranslationProviderError.invalidConfiguration("Invalid API key") + } else if httpResponse.statusCode == 429 { + throw TranslationProviderError.rateLimited(retryAfter: nil) + } + + throw TranslationProviderError.translationFailed("API error: \(httpResponse.statusCode)") + } + + let translatedText = try parseResponse(data) + let latency = Date().timeIntervalSince(start) + + logger.info("Google translation completed in \(latency)s") + + return TranslationResult( + sourceText: text, + translatedText: translatedText, + sourceLanguage: sourceLanguage ?? "auto", + targetLanguage: targetLanguage + ) + } + + func translate( + texts: [String], + from sourceLanguage: String?, + to targetLanguage: String + ) async throws -> [TranslationResult] { + guard !texts.isEmpty else { return [] } + + // Google supports batch translation with multiple 'q' values + guard let credentials = try await keychain.getCredentials(for: .google) else { + throw TranslationProviderError.invalidConfiguration("API key not configured") + } + + // Google Cloud Translation API v2 uses query parameter for API key + var urlComponents = URLComponents(string: baseURL)! + urlComponents.queryItems = [URLQueryItem(name: "key", value: credentials.apiKey)] + + guard let url = urlComponents.url else { + throw TranslationProviderError.invalidConfiguration("Invalid Google Translation API URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = config.options?.timeout ?? 30 + + var body: [String: Any] = [ + "q": texts, + "target": targetLanguage, + "format": "text" + ] + + if let source = sourceLanguage { + body["source"] = source + } + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw TranslationProviderError.connectionFailed("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + // Log status code only to avoid exposing user text in logs + logger.error("Google API error status=\(httpResponse.statusCode)") + + if httpResponse.statusCode == 401 { + throw TranslationProviderError.invalidConfiguration("Invalid API key") + } else if httpResponse.statusCode == 429 { + throw TranslationProviderError.rateLimited(retryAfter: nil) + } + + throw TranslationProviderError.translationFailed("API error: \(httpResponse.statusCode)") + } + + let translations = try parseBatchResponse(data) + + guard translations.count == texts.count else { + throw TranslationProviderError.translationFailed("Google batch response count mismatch: expected \(texts.count), got \(translations.count)") + } + + return zip(texts, translations).map { source, translated in + TranslationResult( + sourceText: source, + translatedText: translated, + sourceLanguage: sourceLanguage ?? "auto", + targetLanguage: targetLanguage + ) + } + } + + func checkConnection() async -> Bool { + do { + _ = try await translate(text: "test", from: "en", to: "zh") + return true + } catch { + logger.error("Google connection check failed: \(error.localizedDescription)") + return false + } + } + + // MARK: - Private Methods + + private func parseResponse(_ data: Data) throws -> String { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let responseData = json["data"] as? [String: Any], + let translations = responseData["translations"] as? [[String: Any]], + let firstTranslation = translations.first, + let translatedText = firstTranslation["translatedText"] as? String else { + throw TranslationProviderError.translationFailed("Failed to parse Google response") + } + + return translatedText + } + + private func parseBatchResponse(_ data: Data) throws -> [String] { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let responseData = json["data"] as? [String: Any], + let translations = responseData["translations"] as? [[String: Any]] else { + throw TranslationProviderError.translationFailed("Failed to parse Google response") + } + + return translations.compactMap { $0["translatedText"] as? String } + } +} diff --git a/ScreenTranslate/Services/Translation/Providers/LLMTranslationProvider.swift b/ScreenTranslate/Services/Translation/Providers/LLMTranslationProvider.swift new file mode 100644 index 0000000..7a843b5 --- /dev/null +++ b/ScreenTranslate/Services/Translation/Providers/LLMTranslationProvider.swift @@ -0,0 +1,350 @@ +// +// LLMTranslationProvider.swift +// ScreenTranslate +// +// LLM-based translation provider for OpenAI, Claude, and Ollama +// + +import Foundation +import os.log + +/// LLM-based translation provider supporting OpenAI, Claude, and Ollama +actor LLMTranslationProvider: TranslationProvider, TranslationPromptConfigurable { + // MARK: - Properties + + nonisolated let id: String + nonisolated let name: String + let engineType: TranslationEngineType + let config: TranslationEngineConfig + + private let keychain: KeychainService + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "ScreenTranslate", + category: "LLMTranslationProvider" + ) + + // MARK: - Initialization + + init( + type: TranslationEngineType, + config: TranslationEngineConfig, + keychain: KeychainService + ) async throws { + self.engineType = type + self.id = type.rawValue + self.config = config + self.keychain = keychain + + switch type { + case .openai: + self.name = "OpenAI Translation" + case .claude: + self.name = "Claude Translation" + case .gemini: + self.name = "Gemini Translation" + case .ollama: + self.name = "Ollama Translation" + default: + throw TranslationProviderError.invalidConfiguration("Invalid LLM type: \(type.rawValue)") + } + } + + // MARK: - TranslationProvider Protocol + + var isAvailable: Bool { + get async { + // Ollama doesn't need API key + if engineType == .ollama { + return true + } + // Check for API key in keychain + return await keychain.hasCredentials(for: engineType) + } + } + + func translate( + text: String, + from sourceLanguage: String?, + to targetLanguage: String + ) async throws -> TranslationResult { + let results = try await translate( + texts: [text], + from: sourceLanguage, + to: targetLanguage, + promptTemplate: nil + ) + + guard let result = results.first else { + throw TranslationProviderError.translationFailed("No translation returned") + } + return result + } + + func translate( + texts: [String], + from sourceLanguage: String?, + to targetLanguage: String, + promptTemplate: String? + ) async throws -> [TranslationResult] { + guard !texts.isEmpty else { return [] } + + // For multiple texts, combine into single request for efficiency + let combinedText = texts.joined(separator: "\n---\n") + let credentials = try await getCredentials() + let prompt = buildPrompt( + text: combinedText, + sourceLanguage: sourceLanguage, + targetLanguage: targetLanguage, + promptTemplate: promptTemplate + ) + + let start = Date() + let translatedText = try await callLLMAPI( + prompt: prompt, + credentials: credentials + ) + let latency = Date().timeIntervalSince(start) + + logger.info("Translation completed in \(latency)s") + + let combinedResult = TranslationResult( + sourceText: combinedText, + translatedText: translatedText, + sourceLanguage: sourceLanguage ?? "Auto", + targetLanguage: targetLanguage + ) + + let translatedTexts = combinedResult.translatedText.components(separatedBy: "\n---\n") + + if translatedTexts.count == texts.count { + return zip(texts, translatedTexts).map { source, translated in + TranslationResult( + sourceText: source, + translatedText: translated.trimmingCharacters(in: .whitespacesAndNewlines), + sourceLanguage: combinedResult.sourceLanguage, + targetLanguage: combinedResult.targetLanguage + ) + } + } + + logger.warning("Batch split failed, falling back to individual translations") + var results: [TranslationResult] = [] + results.reserveCapacity(texts.count) + for text in texts { + let prompt = buildPrompt( + text: text, + sourceLanguage: sourceLanguage, + targetLanguage: targetLanguage, + promptTemplate: promptTemplate + ) + let translatedText = try await callLLMAPI( + prompt: prompt, + credentials: credentials + ) + results.append( + TranslationResult( + sourceText: text, + translatedText: translatedText, + sourceLanguage: sourceLanguage ?? "Auto", + targetLanguage: targetLanguage + ) + ) + } + return results + } + + func translate( + texts: [String], + from sourceLanguage: String?, + to targetLanguage: String + ) async throws -> [TranslationResult] { + try await translate( + texts: texts, + from: sourceLanguage, + to: targetLanguage, + promptTemplate: nil + ) + } + + func checkConnection() async -> Bool { + do { + _ = try await translate( + text: "Hello", + from: "en", + to: "zh" + ) + return true + } catch { + logger.error("Connection check failed: \(error.localizedDescription)") + return false + } + } + + // MARK: - Custom Prompt + + // MARK: - Private Methods + + private func getCredentials() async throws -> StoredCredentials? { + guard engineType.requiresAPIKey else { return nil } + guard let credentials = try await keychain.getCredentials(for: engineType) else { + throw TranslationProviderError.invalidConfiguration("API key required for \(engineType.rawValue)") + } + return credentials + } + + private func buildPrompt( + text: String, + sourceLanguage: String?, + targetLanguage: String, + promptTemplate: String? + ) -> String { + let source = TranslationLanguage.promptDisplayName(for: sourceLanguage) + let target = TranslationLanguage.promptDisplayName(for: targetLanguage) + + if let template = promptTemplate { + return template + .replacingOccurrences(of: "{source_language}", with: source) + .replacingOccurrences(of: "{target_language}", with: target) + .replacingOccurrences(of: "{text}", with: text) + } + + // Default prompt + return """ + Translate the following text from \(source) to \(target). + Provide ONLY the translated text without any explanations, notes, or formatting. + + Text to translate: + \(text) + """ + } + + private func callLLMAPI( + prompt: String, + credentials: StoredCredentials? + ) async throws -> String { + let baseURL = try getBaseURL() + let modelName = getModelName() + + // Build endpoint and headers based on engine type + let endpoint: URL + var headers: [String: String] = ["Content-Type": "application/json"] + + switch engineType { + case .claude: + // Claude uses /v1/messages endpoint + endpoint = baseURL.appendingPathComponent("v1/messages") + if let apiKey = credentials?.apiKey { + headers["x-api-key"] = apiKey + headers["anthropic-version"] = "2023-06-01" + } + default: + // OpenAI, Gemini, Ollama use /chat/completions endpoint + endpoint = baseURL.appendingPathComponent("chat/completions") + if let apiKey = credentials?.apiKey { + headers["Authorization"] = "Bearer \(apiKey)" + } + } + + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.timeoutInterval = config.options?.timeout ?? 30 + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + + // Build request body based on engine type + let body: [String: Any] + switch engineType { + case .claude: + // Claude API format + body = [ + "model": modelName, + "max_tokens": config.options?.maxTokens ?? 2048, + "messages": [ + ["role": "user", "content": prompt] + ] + ] + default: + // OpenAI/Gemini/Ollama format + body = [ + "model": modelName, + "messages": [ + ["role": "user", "content": prompt] + ], + "temperature": config.options?.temperature ?? 0.3, + "max_tokens": config.options?.maxTokens ?? 2048 + ] + } + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + // Execute request + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw TranslationProviderError.connectionFailed("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + // Log status code only to avoid exposing user text in logs + logger.error("API error status=\(httpResponse.statusCode)") + + if httpResponse.statusCode == 401 { + throw TranslationProviderError.invalidConfiguration("Invalid API key") + } else if httpResponse.statusCode == 429 { + throw TranslationProviderError.rateLimited(retryAfter: nil) + } + + throw TranslationProviderError.translationFailed("API error: \(httpResponse.statusCode)") + } + + // Parse response based on engine type + return try parseResponse(data, for: engineType) + } + + private func parseResponse(_ data: Data, for engineType: TranslationEngineType) throws -> String { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw TranslationProviderError.translationFailed("Failed to parse response") + } + + let content: String? + + switch engineType { + case .claude: + content = (json["content"] as? [[String: Any]])? + .first?["text"] as? String + default: + content = ((json["choices"] as? [[String: Any]])? + .first?["message"] as? [String: Any])?["content"] as? String + } + + guard let text = content else { + throw TranslationProviderError.translationFailed("Failed to parse response") + } + + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func getBaseURL() throws -> URL { + if let customURL = config.options?.baseURL { + guard let url = URL(string: customURL) else { + throw TranslationProviderError.invalidConfiguration("Invalid custom baseURL: \(customURL)") + } + return url + } + + if let defaultURL = engineType.defaultBaseURL, + let url = URL(string: defaultURL) { + return url + } + + guard let url = URL(string: "https://api.openai.com/v1") else { + throw TranslationProviderError.invalidConfiguration("Failed to create API URL") + } + return url + } + + private func getModelName() -> String { + return config.options?.modelName ?? engineType.defaultModelName ?? "gpt-4o-mini" + } +} diff --git a/ScreenTranslate/Services/Translation/TranslationEngineRegistry.swift b/ScreenTranslate/Services/Translation/TranslationEngineRegistry.swift new file mode 100644 index 0000000..02eb7d6 --- /dev/null +++ b/ScreenTranslate/Services/Translation/TranslationEngineRegistry.swift @@ -0,0 +1,270 @@ +// +// TranslationEngineRegistry.swift +// ScreenTranslate +// +// Registry for managing translation engine providers +// + +import Foundation +import os.log + +/// Actor-based registry for managing translation engine providers +actor TranslationEngineRegistry { + /// Shared singleton instance + static let shared = TranslationEngineRegistry() + + /// Registered providers keyed by engine type + private var providers: [TranslationEngineType: any TranslationProvider] = [:] + + /// Compatible engine providers keyed by composite ID (e.g., "custom:0", "custom:1") + private var compatibleProviders: [String: CompatibleTranslationProvider] = [:] + + /// Keychain service for credential access + private let keychain = KeychainService.shared + + /// Logger instance + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "ScreenTranslate", + category: "TranslationEngineRegistry" + ) + + init(registerBuiltInProviders: Bool = true) { + if registerBuiltInProviders { + // Cannot call async method in init, so we register synchronously. + // Built-in providers don't need async setup. + let appleProvider = AppleTranslationProvider() + providers[.apple] = appleProvider + + let mtranProvider = MTranServerEngine.shared + providers[.mtranServer] = mtranProvider + + logger.info("Registered 2 built-in providers") + } + } + + // MARK: - Registration + + /// Register a provider for an engine type + func register(_ provider: any TranslationProvider, for type: TranslationEngineType) { + providers[type] = provider + logger.info("Registered provider for \(type.rawValue)") + } + + /// Unregister a provider for an engine type + func unregister(_ type: TranslationEngineType) { + providers.removeValue(forKey: type) + logger.info("Unregistered provider for \(type.rawValue)") + } + + /// Get the provider for an engine type + func provider(for type: TranslationEngineType) -> (any TranslationProvider)? { + return providers[type] + } + + // MARK: - Availability + + /// List all registered engine types + func registeredEngines() -> [TranslationEngineType] { + Array(providers.keys) + } + + /// List all available engines (registered and configured) + func availableEngines() async -> [TranslationEngineType] { + let results = await withTaskGroup(of: (TranslationEngineType, Bool).self) { group in + for (type, provider) in providers { + group.addTask { await (type, provider.isAvailable) } + } + var availability: [TranslationEngineType: Bool] = [:] + for await (type, isAvailable) in group { + availability[type] = isAvailable + } + return availability + } + return results + .filter { $0.value } + .keys + .sorted { $0.rawValue < $1.rawValue } + } + + /// Check if an engine is configured (has required credentials) + func isEngineConfigured(_ type: TranslationEngineType) async -> Bool { + // Built-in engines don't need credentials + if !type.requiresAPIKey { + return await providers[type]?.isAvailable ?? false + } + + // Check if credentials exist in Keychain + return await keychain.hasCredentials(for: type) + } + + /// Check if an engine is available for use + func isEngineAvailable(_ type: TranslationEngineType) async -> Bool { + guard let provider = providers[type] else { return false } + return await provider.isAvailable + } +} + +// MARK: - Provider Creation + +extension TranslationEngineRegistry { + /// Create and register a provider for an engine type + /// This is used for engines that require configuration (LLM, cloud services) + /// - Parameters: + /// - type: The engine type to create + /// - config: The engine configuration + /// - forceRefresh: Force recreation even if cached + /// - Returns: The created provider + func createProvider( + for type: TranslationEngineType, + config: TranslationEngineConfig, + forceRefresh: Bool = false + ) async throws -> any TranslationProvider { + // Check if already registered and not forcing refresh + if !forceRefresh, let existing = providers[type] { + return existing + } + + let provider: any TranslationProvider + + switch type { + case .apple: + if providers[.apple] != nil { + throw RegistryError.alreadyRegistered + } + provider = AppleTranslationProvider() + + case .mtranServer: + if providers[.mtranServer] != nil { + throw RegistryError.alreadyRegistered + } + provider = MTranServerEngine.shared + + case .openai, .claude, .gemini, .ollama: + provider = try await LLMTranslationProvider( + type: type, + config: config, + keychain: keychain + ) + + case .google: + provider = try await GoogleTranslationProvider( + config: config, + keychain: keychain + ) + + case .deepl: + provider = try await DeepLTranslationProvider( + config: config, + keychain: keychain + ) + + case .baidu: + provider = try await BaiduTranslationProvider( + config: config, + keychain: keychain + ) + + case .custom: + provider = try await CompatibleTranslationProvider( + config: config, + keychain: keychain + ) + } + + register(provider, for: type) + return provider + } + + /// Create and cache a compatible engine provider for a specific instance + /// - Parameters: + /// - compatibleConfig: The compatible engine configuration + /// - forceRefresh: Force recreation even if cached + /// - Returns: The created provider + func createCompatibleProvider( + compatibleConfig: CompatibleTranslationProvider.CompatibleConfig, + forceRefresh: Bool = false + ) async throws -> CompatibleTranslationProvider { + let compositeId = compatibleConfig.keychainId + + if !forceRefresh, let existing = compatibleProviders[compositeId] { + if existing.configHash == compatibleConfig.configHash { + return existing + } + compatibleProviders.removeValue(forKey: compositeId) + } + + let engineConfig = TranslationEngineConfig.default(for: .custom) + let provider = try await CompatibleTranslationProvider( + config: engineConfig, + compatibleConfig: compatibleConfig, + keychain: keychain + ) + + compatibleProviders[compositeId] = provider + logger.info("Created compatible provider for \(compositeId)") + return provider + } + + /// Get a cached compatible engine provider + /// - Parameter compositeId: The composite identifier (e.g., "custom:0") + /// - Returns: The cached provider, or nil + func getCompatibleProvider(for compositeId: String) -> CompatibleTranslationProvider? { + return compatibleProviders[compositeId] + } + + /// Remove a cached compatible engine provider + /// - Parameter compositeId: The composite identifier + func removeCompatibleProvider(for compositeId: String) { + compatibleProviders.removeValue(forKey: compositeId) + logger.info("Removed compatible provider for \(compositeId)") + } + + /// Clear all cached compatible providers + func clearCompatibleProviders() { + compatibleProviders.removeAll() + logger.info("Cleared all compatible providers") + } +} + +// MARK: - Registry Errors + +enum RegistryError: LocalizedError, Sendable { + case alreadyRegistered + case notRegistered(TranslationEngineType) + case configurationMissing(TranslationEngineType) + case credentialsNotFound(TranslationEngineType) + + var errorDescription: String? { + switch self { + case .alreadyRegistered: + return NSLocalizedString( + "registry.error.already_registered", + comment: "Provider is already registered" + ) + case .notRegistered(let type): + return String( + format: NSLocalizedString( + "registry.error.not_registered", + comment: "No provider registered for %@" + ), + type.localizedName + ) + case .configurationMissing(let type): + return String( + format: NSLocalizedString( + "registry.error.config_missing", + comment: "Configuration missing for %@" + ), + type.localizedName + ) + case .credentialsNotFound(let type): + return String( + format: NSLocalizedString( + "registry.error.credentials_not_found", + comment: "Credentials not found for %@" + ), + type.localizedName + ) + } + } +} diff --git a/ScreenTranslate/Services/TranslationEngine.swift b/ScreenTranslate/Services/TranslationEngine.swift new file mode 100644 index 0000000..5923bf5 --- /dev/null +++ b/ScreenTranslate/Services/TranslationEngine.swift @@ -0,0 +1,559 @@ +import Foundation +import Translation +import os.signpost +import os.log + +// MARK: - Translation Language (Shared Type) + +/// Translation languages supported by Translation framework +/// Defined at module level for use in AppSettings without direct coupling +enum TranslationLanguage: String, CaseIterable, Sendable, Codable { + case auto = "auto" + case english = "en" + case chineseSimplified = "zh-Hans" + case chineseTraditional = "zh-Hant" + case japanese = "ja" + case korean = "ko" + case french = "fr" + case german = "de" + case spanish = "es" + case italian = "it" + case portuguese = "pt" + case russian = "ru" + case arabic = "ar" + case hindi = "hi" + case thai = "th" + case vietnamese = "vi" + case dutch = "nl" + case polish = "pl" + case turkish = "tr" + case ukrainian = "uk" + case czech = "cs" + case swedish = "sv" + case danish = "da" + case finnish = "fi" + case norwegian = "no" + case greek = "el" + case hebrew = "he" + case indonesian = "id" + case malay = "ms" + case romanian = "ro" + + /// The Locale.Language identifier for this language. + /// Uses the full rawValue (BCP 47 tag) to preserve script subtags + /// (e.g. "zh-Hans", "zh-Hant") which Apple's Translation framework requires. + var localeLanguage: Locale.Language { + if self == .auto { + return Locale.Language(identifier: "en") + } + return Locale.Language(identifier: rawValue) + } + + /// Localized display name + var localizedName: String { + Self.displayName( + for: rawValue, + locale: .current, + autoDisplayName: NSLocalizedString("translation.auto", comment: "") + ) + } + + /// BCP 47 language tag + var bcp47Tag: String { + rawValue + } + + static func fromTranslationCode(_ code: String?) -> TranslationLanguage? { + guard let code, + !code.isEmpty, + code.lowercased() != TranslationLanguage.auto.rawValue else { + return nil + } + + // Normalize underscores to hyphens + let normalized = code.replacingOccurrences(of: "_", with: "-") + + // Exact match + if let match = TranslationLanguage(rawValue: normalized) { + return match + } + + // Fuzzy match: map bare language codes (e.g. "zh") to their default script + let lowercased = normalized.lowercased() + switch lowercased { + case "zh", "zh-cn", "zh-sg": + return .chineseSimplified + case "zh-tw", "zh-hk", "zh-mo": + return .chineseTraditional + default: + break + } + + // Prefix match (e.g. "en-US" → .english, "zh-Hans-CN" → .chineseSimplified) + return TranslationLanguage.allCases.first(where: { lowercased.hasPrefix($0.rawValue.lowercased()) }) + } + + static func promptDisplayName(for code: String?) -> String { + displayName( + for: code, + locale: Locale(identifier: "en"), + autoDisplayName: "Auto Detect" + ) + } + + static func displayName( + for code: String?, + locale: Locale, + autoDisplayName: String + ) -> String { + guard let code, + !code.isEmpty, + code.lowercased() != TranslationLanguage.auto.rawValue else { + return autoDisplayName + } + + let normalized = code.replacingOccurrences(of: "_", with: "-") + + if let fullName = locale.localizedString(forIdentifier: normalized), !fullName.isEmpty { + return normalizedDisplayName(fullName) + } + + let baseLanguageCode = normalized.components(separatedBy: "-").first ?? normalized + if let languageName = locale.localizedString(forLanguageCode: baseLanguageCode), + !languageName.isEmpty { + return normalizedDisplayName(languageName) + } + + return normalized + } + + private static func normalizedDisplayName(_ name: String) -> String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + let commaSeparatedParts = trimmed + .split(separator: ",", maxSplits: 1, omittingEmptySubsequences: true) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + + guard commaSeparatedParts.count == 2, + !commaSeparatedParts[0].isEmpty, + !commaSeparatedParts[1].isEmpty else { + return trimmed + } + + return "\(commaSeparatedParts[0]) (\(commaSeparatedParts[1]))" + } +} + +/// Actor responsible for translating text using the Translation framework (macOS 12+). +/// Thread-safe, async translation with support for multiple languages. +@available(macOS 13.0, *) +actor TranslationEngine { + // MARK: - Performance Logging + + private static let performanceLog = OSLog( + subsystem: Bundle.main.bundleIdentifier ?? "ScreenCapture", + category: .pointsOfInterest + ) + + private static let signpostID = OSSignpostID(log: performanceLog) + + // MARK: - Properties + + /// Shared instance for app-wide translation operations + static let shared = TranslationEngine() + + /// Whether a translation operation is currently in progress + private var isProcessing = false + + // MARK: - Internal Error Types + + private struct TranslationTimeout: Error {} + private struct AppleTranslationError: Error { + let nsError: NSError + } + + // MARK: - Configuration + + /// Translation configuration options + struct Configuration: Sendable { + /// Explicit source language for translation. nil means fallback behavior. + var sourceLanguage: TranslationLanguage? + + /// Target language for translation (nil for system default) + var targetLanguage: TranslationLanguage? + + /// Request timeout in seconds + var timeout: TimeInterval + + /// Whether to automatically detect source language + var autoDetectSourceLanguage: Bool + + static let `default` = Configuration( + sourceLanguage: nil, + targetLanguage: nil, + timeout: 10.0, + autoDetectSourceLanguage: true + ) + } + + // MARK: - Initialization + + private init() {} + + // MARK: - Public API + + /// Translates text using the Translation framework. + /// - Parameters: + /// - text: The text to translate + /// - config: Translation configuration (uses default if not specified) + /// - Returns: TranslationResult containing translated text + /// - Throws: TranslationEngineError if translation fails + func translate( + _ text: String, + config: Configuration = .default + ) async throws -> TranslationResult { + // Prevent concurrent translation operations + guard !isProcessing else { + throw TranslationEngineError.operationInProgress + } + isProcessing = true + defer { isProcessing = false } + + // Validate input + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw TranslationEngineError.emptyInput + } + + // Determine target language (auto means use system default) + let effectiveTargetLanguage: TranslationLanguage + if let target = config.targetLanguage, target != .auto { + effectiveTargetLanguage = target + } else { + effectiveTargetLanguage = Self.systemTargetLanguage() + } + + // Perform translation with signpost for profiling + os_signpost(.begin, log: Self.performanceLog, name: "Translation", signpostID: Self.signpostID) + let startTime = CFAbsoluteTimeGetCurrent() + + do { + let response = try await performTranslation( + text: text, + source: config.sourceLanguage, + target: effectiveTargetLanguage, + timeout: config.timeout + ) + + let duration = (CFAbsoluteTimeGetCurrent() - startTime) * 1000 + os_signpost(.end, log: Self.performanceLog, name: "Translation", signpostID: Self.signpostID) + + #if DEBUG + os_log("Translation completed in %.1fms", log: OSLog.default, type: .info, duration) + #endif + + return TranslationResult( + sourceText: response.sourceText, + translatedText: response.targetText, + sourceLanguage: response.sourceLanguage.minimalIdentifier, + targetLanguage: response.targetLanguage.minimalIdentifier + ) + } catch { + os_signpost(.end, log: Self.performanceLog, name: "Translation", signpostID: Self.signpostID) + throw mapTranslationError(error, targetLanguage: effectiveTargetLanguage) + } + } + + /// Translates text with automatic language detection. + /// - Parameter text: The text to translate + /// - Returns: TranslationResult containing translated text + /// - Throws: TranslationEngineError if translation fails + func translate(_ text: String) async throws -> TranslationResult { + try await translate(text, config: .default) + } + + /// Translates text to a specific target language. + /// - Parameters: + /// - text: The text to translate + /// - targetLanguage: The target translation language + /// - Returns: TranslationResult containing translated text + /// - Throws: TranslationEngineError if translation fails + func translate( + _ text: String, + to targetLanguage: TranslationLanguage + ) async throws -> TranslationResult { + var config = Configuration.default + config.targetLanguage = targetLanguage + return try await translate(text, config: config) + } + + // MARK: - Private Methods + + /// Validates if the target language is available and installed + private func validateLanguageAvailability(for language: TranslationLanguage) async throws { + try await validateLanguageAvailability(for: language, sourceLanguage: nil, text: nil) + } + + /// Validates if the target language is available and installed for the selected source language. + private func validateLanguageAvailability( + for language: TranslationLanguage, + sourceLanguage: TranslationLanguage?, + text: String? + ) async throws { + let languageStatus: LanguageAvailabilityStatus + + if let sourceLocaleLanguage = Self.sourceLocaleLanguage(for: sourceLanguage) { + languageStatus = await Self.checkLanguageAvailability( + source: sourceLocaleLanguage, + target: language.localeLanguage + ) + } else if let text, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + languageStatus = await Self.checkLanguageAvailability( + text: text, + target: language.localeLanguage + ) + } else { + return + } + + switch languageStatus { + case .installed: + break + case .supported(let languageName): + throw TranslationEngineError.languageNotInstalled( + language: languageName, + downloadInstructions: NSLocalizedString( + "error.translation.language.download.instructions", + comment: "" + ) + ) + case .unsupported(let languageName): + throw TranslationEngineError.unsupportedLanguagePair( + source: NSLocalizedString("translation.auto.detected", comment: ""), + target: languageName + ) + } + } + + /// Performs the actual translation with a timeout + private func performTranslation( + text: String, + source: TranslationLanguage?, + target: TranslationLanguage, + timeout: TimeInterval + ) async throws -> TranslationSession.Response { + try await withThrowingTaskGroup( + of: Result.self + ) { group in + group.addTask { [text, source, target] in + do { + // The current TranslationSession initializer exposed by this SDK + // still requires an installed source language. + let session = TranslationSession( + installedSource: (source ?? .english).localeLanguage, + target: target.localeLanguage + ) + let result = try await session.translate(text) + return .success(result) + } catch let error as NSError { + if error.domain == "TranslationErrorDomain" { + return .failure(AppleTranslationError(nsError: error)) + } + return .failure(error) + } catch { + return .failure(error) + } + } + + _ = group.addTaskUnlessCancelled { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + return .failure(TranslationTimeout()) + } + + guard let result = try await group.next() else { + throw TranslationTimeout() + } + group.cancelAll() + return try result.get() + } + } + + /// Maps internal and framework errors to TranslationEngineError + private func mapTranslationError(_ error: Error, targetLanguage: TranslationLanguage) -> Error { + if error is TranslationTimeout { + return TranslationEngineError.timeout + } + + if let appleError = error as? AppleTranslationError { + if appleError.nsError.code == 16 { + return TranslationEngineError.languageNotInstalled( + language: targetLanguage.localizedName, + downloadInstructions: NSLocalizedString( + "error.translation.language.download.instructions", + comment: "" + ) + ) + } + return TranslationEngineError.translationFailed(underlying: appleError.nsError) + } + + return TranslationEngineError.translationFailed(underlying: error) + } + + /// Returns the system's target language based on user preferences + private static func systemTargetLanguage() -> TranslationLanguage { + let systemLanguage = Locale.current.language.languageCode?.identifier ?? "en" + let systemRegion = Locale.current.language.region?.identifier ?? "" + + let bcp47 = systemRegion.isEmpty ? systemLanguage : "\(systemLanguage)-\(systemRegion)" + + // Find exact match + if let match = TranslationLanguage(rawValue: bcp47) { + return match + } + + // Try language-only match + if let match = TranslationLanguage.allCases.first(where: { $0.rawValue.hasPrefix(systemLanguage) }) { + return match + } + + return .english + } + + /// Checks if a language pair is supported for translation + func isLanguagePairSupported( + source: TranslationLanguage, + target: TranslationLanguage + ) -> Bool { + // Auto is always valid (will use system default) + guard source != .auto, target != .auto else { return true } + + // Most common pairs are supported; this is a simplified check + return source != target + } + + // MARK: - Language Availability + + /// Represents the availability status of a translation language + enum LanguageAvailabilityStatus { + case installed + case supported(languageName: String) + case unsupported(languageName: String) + } + + /// Checks if the target language is available for translation + /// - Parameter target: The target language to check + /// - Returns: The availability status of the language + static func sourceLocaleLanguage(for sourceLanguage: TranslationLanguage?) -> Locale.Language? { + guard let sourceLanguage, sourceLanguage != .auto else { + return nil + } + return sourceLanguage.localeLanguage + } + + private static func checkLanguageAvailability( + source: Locale.Language, + target: Locale.Language + ) async -> LanguageAvailabilityStatus { + let availability = LanguageAvailability() + let status = await availability.status(from: source, to: target) + return languageAvailabilityStatus(from: status, target: target) + } + + private static func checkLanguageAvailability( + text: String, + target: Locale.Language + ) async -> LanguageAvailabilityStatus { + let availability = LanguageAvailability() + let status: LanguageAvailability.Status + do { + status = try await availability.status(for: text, to: target) + } catch { + return .unsupported(languageName: fullIdentifier(for: target)) + } + return languageAvailabilityStatus(from: status, target: target) + } + + /// Builds a full BCP 47 identifier from a Locale.Language (e.g. "zh-Hans") + /// Unlike minimalIdentifier which strips script/region to just "zh" + private static func fullIdentifier(for language: Locale.Language) -> String { + var components: [String] = [] + if let code = language.languageCode?.identifier { components.append(code) } + if let script = language.script?.identifier { components.append(script) } + return components.joined(separator: "-") + } + + private static func languageAvailabilityStatus( + from status: LanguageAvailability.Status, + target: Locale.Language + ) -> LanguageAvailabilityStatus { + // Build full identifier (e.g. "zh-Hans") instead of minimalIdentifier ("zh") + let languageName = fullIdentifier(for: target) + + switch status { + case .installed: + return .installed + case .supported: + return .supported(languageName: languageName) + case .unsupported: + return .unsupported(languageName: languageName) + @unknown default: + return .unsupported(languageName: languageName) + } + } +} + +// MARK: - Translation Engine Errors + +/// Errors that can occur during translation operations +enum TranslationEngineError: LocalizedError, Sendable { + /// Translation operation is already in progress + case operationInProgress + + /// The input text is empty + case emptyInput + + /// Translation operation timed out + case timeout + + /// The requested language pair is not supported + case unsupportedLanguagePair(source: String, target: String) + + /// Translation language not installed (needs download) + case languageNotInstalled(language: String, downloadInstructions: String) + + /// Translation failed with an underlying error + case translationFailed(underlying: any Error) + + var errorDescription: String? { + switch self { + case .operationInProgress: + return NSLocalizedString("error.translation.in.progress", comment: "") + case .emptyInput: + return NSLocalizedString("error.translation.empty.input", comment: "") + case .timeout: + return NSLocalizedString("error.translation.timeout", comment: "") + case .unsupportedLanguagePair(let source, let target): + return String(format: NSLocalizedString("error.translation.unsupported.pair", comment: ""), source, target) + case .languageNotInstalled(let language, _): + return String(format: NSLocalizedString("error.translation.language.not.installed", comment: ""), language) + case .translationFailed: + return NSLocalizedString("error.translation.failed", comment: "") + } + } + + var recoverySuggestion: String? { + switch self { + case .operationInProgress: + return NSLocalizedString("error.translation.in.progress.recovery", comment: "") + case .emptyInput: + return NSLocalizedString("error.translation.empty.input.recovery", comment: "") + case .timeout: + return NSLocalizedString("error.translation.timeout.recovery", comment: "") + case .unsupportedLanguagePair: + return NSLocalizedString("error.translation.unsupported.pair.recovery", comment: "") + case .languageNotInstalled(_, let instructions): + return instructions + case .translationFailed: + return NSLocalizedString("error.translation.failed.recovery", comment: "") + } + } +} diff --git a/ScreenTranslate/Services/TranslationProvider.swift b/ScreenTranslate/Services/TranslationProvider.swift new file mode 100644 index 0000000..ed765bd --- /dev/null +++ b/ScreenTranslate/Services/TranslationProvider.swift @@ -0,0 +1,155 @@ +// +// TranslationProvider.swift +// ScreenTranslate +// +// Created for US-009: 扩展 MTransServerProvider 翻译能力 +// + +import Foundation + +// MARK: - Translation Provider Protocol + +/// Protocol defining a translation service provider +/// Implementations can wrap different translation APIs (Apple Translation, MTransServer, etc.) +protocol TranslationProvider: Sendable { + /// Unique identifier for this provider + var id: String { get } + + /// Human-readable name for display + var name: String { get } + + /// Whether the provider is currently available (configured and reachable) + var isAvailable: Bool { get async } + + /// Translate a single text + /// - Parameters: + /// - text: The text to translate + /// - sourceLanguage: Source language code (nil for auto-detect) + /// - targetLanguage: Target language code + /// - Returns: Translation result + func translate( + text: String, + from sourceLanguage: String?, + to targetLanguage: String + ) async throws -> TranslationResult + + /// Translate multiple texts in batch + /// - Parameters: + /// - texts: Array of texts to translate + /// - sourceLanguage: Source language code (nil for auto-detect) + /// - targetLanguage: Target language code + /// - Returns: Array of translation results in the same order as input + func translate( + texts: [String], + from sourceLanguage: String?, + to targetLanguage: String + ) async throws -> [TranslationResult] + + /// Check connection status to the translation service + /// - Returns: true if the service is reachable and operational + func checkConnection() async -> Bool +} + +/// Providers that can execute a translation request with a request-scoped prompt template. +protocol TranslationPromptConfigurable: Sendable { + func translate( + texts: [String], + from sourceLanguage: String?, + to targetLanguage: String, + promptTemplate: String? + ) async throws -> [TranslationResult] +} + +/// Providers that can expose prompt-selection context, such as compatible-engine identifiers. +protocol TranslationPromptContextProviding: Sendable { + func compatiblePromptIdentifier() async -> String? +} + +// MARK: - Translation Provider Errors + +/// Errors that can occur during translation provider operations +enum TranslationProviderError: LocalizedError, Sendable { + case notAvailable + case connectionFailed(String) + case invalidConfiguration(String) + case translationFailed(String) + case emptyInput + case unsupportedLanguage(String) + case timeout + case rateLimited(retryAfter: TimeInterval?) + + var errorDescription: String? { + switch self { + case .notAvailable: + return "Translation provider is not available." + case .connectionFailed(let message): + return "Connection failed: \(message)" + case .invalidConfiguration(let message): + return "Invalid configuration: \(message)" + case .translationFailed(let message): + return "Translation failed: \(message)" + case .emptyInput: + return "Cannot translate empty text." + case .unsupportedLanguage(let language): + return "Unsupported language: \(language)" + case .timeout: + return "Translation request timed out." + case .rateLimited(let retryAfter): + if let seconds = retryAfter { + return "Rate limited. Retry after \(Int(seconds)) seconds." + } + return "Rate limited. Please try again later." + } + } + + var recoverySuggestion: String? { + switch self { + case .notAvailable: + return NSLocalizedString("translation.provider.recovery.notAvailable", comment: "") + case .connectionFailed: + return NSLocalizedString("translation.provider.recovery.connectionFailed", comment: "") + case .invalidConfiguration: + return NSLocalizedString("translation.provider.recovery.invalidConfiguration", comment: "") + case .translationFailed: + return NSLocalizedString("translation.provider.recovery.translationFailed", comment: "") + case .emptyInput: + return NSLocalizedString("translation.provider.recovery.emptyInput", comment: "") + case .unsupportedLanguage: + return NSLocalizedString("translation.provider.recovery.unsupportedLanguage", comment: "") + case .timeout: + return NSLocalizedString("translation.provider.recovery.timeout", comment: "") + case .rateLimited: + return NSLocalizedString("translation.provider.recovery.rateLimited", comment: "") + } + } +} + +// MARK: - Default Implementation + +extension TranslationProvider { + /// Default batch translation implementation that calls single translate sequentially + /// Providers can override this with more efficient batch implementations + func translate( + texts: [String], + from sourceLanguage: String?, + to targetLanguage: String + ) async throws -> [TranslationResult] { + guard !texts.isEmpty else { + return [] + } + + var results: [TranslationResult] = [] + results.reserveCapacity(texts.count) + + for text in texts { + let result = try await translate( + text: text, + from: sourceLanguage, + to: targetLanguage + ) + results.append(result) + } + + return results + } +} diff --git a/ScreenTranslate/Services/TranslationService.swift b/ScreenTranslate/Services/TranslationService.swift new file mode 100644 index 0000000..d8191b9 --- /dev/null +++ b/ScreenTranslate/Services/TranslationService.swift @@ -0,0 +1,503 @@ +// +// TranslationService.swift +// ScreenTranslate +// +// Created for US-010: 创建 TranslationService 编排层 +// Updated for multi-engine support +// + +import Foundation +import os.log + +/// Orchestrates multiple translation providers with various selection modes +@available(macOS 13.0, *) +actor TranslationService { + static let shared = TranslationService() + + private let registry: TranslationEngineRegistry + private let keychain = KeychainService.shared + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "ScreenTranslate", + category: "TranslationService" + ) + + // Prompt configuration + private var promptConfig: TranslationPromptConfig = TranslationPromptConfig() + + init(registry: TranslationEngineRegistry = .shared) { + self.registry = registry + } + + // MARK: - Main Translation API + + /// Translate text using specified selection mode + /// - Parameters: + /// - segments: Source texts to translate + /// - targetLanguage: Target language code + /// - sourceLanguage: Source language code (nil for auto-detect) + /// - scene: Translation scene (for scene binding mode) + /// - mode: Engine selection mode + /// - preferredEngine: Primary engine (for modes that need it) + /// - fallbackEnabled: Whether to use fallback + /// - parallelEngines: Engines to run in parallel mode + /// - sceneBindings: Scene-to-engine bindings + /// - Returns: Bundle with results from all engines + func translate( + segments: [String], + to targetLanguage: String, + from sourceLanguage: String? = nil, + scene: TranslationScene? = nil, + mode: EngineSelectionMode, + preferredEngine: TranslationEngineType = .apple, + fallbackEnabled: Bool = true, + parallelEngines: [TranslationEngineType] = [], + sceneBindings: [TranslationScene: SceneEngineBinding] = [:] + ) async throws -> TranslationResultBundle { + guard !segments.isEmpty else { + return TranslationResultBundle( + results: [], + primaryEngine: preferredEngine, + selectionMode: mode, + scene: scene + ) + } + + switch mode { + case .primaryWithFallback: + return try await translateWithFallback( + segments: segments, + to: targetLanguage, + from: sourceLanguage, + primaryEngine: preferredEngine, + fallbackEnabled: fallbackEnabled, + scene: scene + ) + + case .parallel: + let effectiveParallelEngines = await filterEnabledEngines( + parallelEngines.isEmpty ? [preferredEngine] : parallelEngines + ) + return try await translateParallel( + segments: segments, + to: targetLanguage, + from: sourceLanguage, + engines: effectiveParallelEngines, + scene: scene + ) + + case .quickSwitch: + return try await translateForQuickSwitch( + segments: segments, + to: targetLanguage, + from: sourceLanguage, + primaryEngine: preferredEngine, + scene: scene + ) + + case .sceneBinding: + return try await translateByScene( + segments: segments, + to: targetLanguage, + from: sourceLanguage, + scene: scene ?? .screenshot, + bindings: sceneBindings, + preferredEngine: preferredEngine + ) + } + } + + // MARK: - Selection Mode Implementations + + /// Primary with fallback mode + private func translateWithFallback( + segments: [String], + to targetLanguage: String, + from sourceLanguage: String?, + primaryEngine: TranslationEngineType, + fallbackEnabled: Bool, + fallbackEngine: TranslationEngineType? = nil, + scene: TranslationScene? + ) async throws -> TranslationResultBundle { + var errors: [Error] = [] + + // Try primary engine + do { + let result = try await translateWithEngine( + primaryEngine, + segments: segments, + to: targetLanguage, + from: sourceLanguage, + scene: scene, + mode: .primaryWithFallback + ) + return result + } catch { + errors.append(error) + logger.warning("Primary engine \(primaryEngine.rawValue) failed: \(error.localizedDescription)") + } + + // Try fallback if enabled + if fallbackEnabled { + let actualFallback: TranslationEngineType + if let engine = fallbackEngine { + actualFallback = engine + } else if let scene = scene { + actualFallback = SceneEngineBinding.default(for: scene).fallbackEngine ?? .mtranServer + } else { + actualFallback = primaryEngine == .apple ? .mtranServer : .apple + } + + // Skip fallback if the engine is not explicitly enabled in user settings + let enabledFallbacks = await filterEnabledEngines([actualFallback]) + guard !enabledFallbacks.isEmpty else { + logger.warning("Fallback engine \(actualFallback.rawValue) is not enabled, skipping") + throw MultiEngineError.allEnginesFailed(errors) + } + + do { + let result = try await translateWithEngine( + actualFallback, + segments: segments, + to: targetLanguage, + from: sourceLanguage, + scene: scene, + mode: .primaryWithFallback + ) + let failedPrimary = EngineResult.failed(engine: primaryEngine, error: errors[0]) + let mergedResults = [failedPrimary] + result.results + logger.info("Fallback to \(actualFallback.rawValue) succeeded") + return TranslationResultBundle( + results: mergedResults, + primaryEngine: result.primaryEngine, + selectionMode: .primaryWithFallback, + scene: scene + ) + } catch { + errors.append(error) + logger.warning("Fallback engine \(actualFallback.rawValue) also failed: \(error.localizedDescription)") + } + } + + throw MultiEngineError.allEnginesFailed(errors) + } + + /// Parallel mode - run multiple engines simultaneously + private func translateParallel( + segments: [String], + to targetLanguage: String, + from sourceLanguage: String?, + engines: [TranslationEngineType], + scene: TranslationScene? + ) async throws -> TranslationResultBundle { + let primaryEngine = engines.first ?? .apple + + let results = await withTaskGroup(of: EngineResult.self, returning: [EngineResult].self) { group in + for engine in engines { + group.addTask { + do { + let start = Date() + let provider = try await self.resolvedProvider(for: engine) + + let providerResults = try await self.translateWithResolvedPrompt( + provider: provider, + engine: engine, + texts: segments, + from: sourceLanguage, + to: targetLanguage, + scene: scene + ) + let bilingualSegments = providerResults.map { BilingualSegment(from: $0) } + + // Treat empty results as failure + guard !bilingualSegments.isEmpty else { + return EngineResult.failed( + engine: engine, + error: TranslationProviderError.translationFailed( + "\(provider.name) returned no results" + ) + ) + } + + return EngineResult( + engine: engine, + segments: bilingualSegments, + latency: Date().timeIntervalSince(start) + ) + } catch { + return EngineResult.failed(engine: engine, error: error) + } + } + } + + var collectedResults: [EngineResult] = [] + for await result in group { + collectedResults.append(result) + } + return collectedResults + } + + // If all engines failed (no successful results), throw instead of silently returning empty results + let failedErrors = results.compactMap { $0.error } + let hasSuccess = results.contains { $0.isSuccess } + if !hasSuccess { + throw MultiEngineError.allEnginesFailed(failedErrors) + } + + return TranslationResultBundle( + results: results, + primaryEngine: primaryEngine, + selectionMode: .parallel, + scene: scene + ) + } + + /// Quick switch mode - start with primary, others load on demand + private func translateForQuickSwitch( + segments: [String], + to targetLanguage: String, + from sourceLanguage: String?, + primaryEngine: TranslationEngineType, + scene: TranslationScene? + ) async throws -> TranslationResultBundle { + // For now, behaves like primary without fallback + // UI layer will handle switching to other engines + return try await translateWithEngine( + primaryEngine, + segments: segments, + to: targetLanguage, + from: sourceLanguage, + scene: scene, + mode: .quickSwitch + ) + } + + /// Scene binding mode - use engine configured for the scene + private func translateByScene( + segments: [String], + to targetLanguage: String, + from sourceLanguage: String?, + scene: TranslationScene, + bindings: [TranslationScene: SceneEngineBinding], + preferredEngine: TranslationEngineType + ) async throws -> TranslationResultBundle { + let binding = bindings[scene] ?? SceneEngineBinding.default(for: scene) + + return try await translateWithFallback( + segments: segments, + to: targetLanguage, + from: sourceLanguage, + primaryEngine: binding.primaryEngine, + fallbackEnabled: binding.fallbackEnabled, + fallbackEngine: binding.fallbackEngine, + scene: scene + ) + } + + // MARK: - Helper Methods + + /// Translate with a specific engine + private func translateWithEngine( + _ engine: TranslationEngineType, + segments: [String], + to targetLanguage: String, + from sourceLanguage: String?, + scene: TranslationScene?, + mode: EngineSelectionMode = .primaryWithFallback + ) async throws -> TranslationResultBundle { + let start = Date() + let provider = try await resolvedProvider(for: engine) + + guard await provider.isAvailable else { + throw TranslationProviderError.notAvailable + } + + let results = try await translateWithResolvedPrompt( + provider: provider, + engine: engine, + texts: segments, + from: sourceLanguage, + to: targetLanguage, + scene: scene + ) + + let bilingualSegments = results.map { BilingualSegment(from: $0) } + + // Treat empty results as failure so callers can trigger fallback + guard !bilingualSegments.isEmpty else { + throw TranslationProviderError.translationFailed( + "\(provider.name) returned no results" + ) + } + + let latency = Date().timeIntervalSince(start) + + return TranslationResultBundle.single( + engine: engine, + segments: bilingualSegments, + latency: latency, + selectionMode: mode, + scene: scene + ) + } + + /// Update prompt configuration + func updatePromptConfig(_ config: TranslationPromptConfig) { + self.promptConfig = config + } + + /// Get current prompt configuration + func getPromptConfig() -> TranslationPromptConfig { + return promptConfig + } + + private func translateWithResolvedPrompt( + provider: any TranslationProvider, + engine: TranslationEngineType, + texts: [String], + from sourceLanguage: String?, + to targetLanguage: String, + scene: TranslationScene? + ) async throws -> [TranslationResult] { + guard let promptConfigurableProvider = provider as? TranslationPromptConfigurable else { + return try await provider.translate( + texts: texts, + from: sourceLanguage, + to: targetLanguage + ) + } + + let promptTemplate = await resolvedPromptTemplate( + for: provider, + engine: engine, + scene: scene + ) + + return try await promptConfigurableProvider.translate( + texts: texts, + from: sourceLanguage, + to: targetLanguage, + promptTemplate: promptTemplate + ) + } + + private func resolvedPromptTemplate( + for provider: any TranslationProvider, + engine: TranslationEngineType, + scene: TranslationScene? + ) async -> String? { + let sceneToUse = scene ?? .screenshot + let compatiblePromptID = await (provider as? TranslationPromptContextProviding)?.compatiblePromptIdentifier() + + let resolvedPrompt = promptConfig.promptPreview( + for: engine, + scene: sceneToUse, + compatiblePromptID: compatiblePromptID + ) + + if resolvedPrompt == TranslationPromptConfig.defaultPrompt { + return nil + } + + return resolvedPrompt + } + + /// Filters engine list to only include engines that are explicitly enabled in user settings. + /// Apple is treated as always enabled (it's the default built-in engine). + private func filterEnabledEngines(_ engines: [TranslationEngineType]) async -> [TranslationEngineType] { + let configs = await MainActor.run { + AppSettings.shared.engineConfigs + } + return engines.filter { engine in + engine == .apple || configs[engine]?.isEnabled == true + } + } + + private func resolvedProvider(for engine: TranslationEngineType) async throws -> any TranslationProvider { + if let provider = await registry.provider(for: engine) { + return provider + } + + let engineConfig = await MainActor.run { + AppSettings.shared.engineConfigs[engine] ?? .default(for: engine) + } + + return try await registry.createProvider(for: engine, config: engineConfig) + } + + // MARK: - Legacy API (Backward Compatible) + + /// Translates segments using the preferred engine with automatic fallback + /// - Parameters: + /// - segments: Source texts to translate + /// - targetLanguage: Target language code + /// - preferredEngine: User's preferred translation engine + /// - sourceLanguage: Source language code (nil for auto-detect) + /// - Returns: Array of bilingual segments with source and translated text + func translate( + segments: [String], + to targetLanguage: String, + preferredEngine: TranslationEngineType = .apple, + from sourceLanguage: String? = nil, + scene: TranslationScene? = nil, + mode: EngineSelectionMode = .primaryWithFallback, + fallbackEnabled: Bool = true, + parallelEngines: [TranslationEngineType] = [], + sceneBindings: [TranslationScene: SceneEngineBinding] = [:] + ) async throws -> [BilingualSegment] { + guard !segments.isEmpty else { return [] } + + let bundle = try await translate( + segments: segments, + to: targetLanguage, + from: sourceLanguage, + scene: scene, + mode: mode, + preferredEngine: preferredEngine, + fallbackEnabled: fallbackEnabled, + parallelEngines: parallelEngines, + sceneBindings: sceneBindings + ) + + let result = bundle.primaryResult + + // If no engine produced results, propagate the actual errors + guard !result.isEmpty else { + if bundle.successfulEngines.isEmpty { + let errors = bundle.results.compactMap { $0.error } + throw MultiEngineError.allEnginesFailed(errors) + } + throw MultiEngineError.noResults + } + + return result + } + + // MARK: - Connection Testing + + /// Test connection to a specific engine + func testConnection(for engine: TranslationEngineType) async -> Bool { + // First try to get existing provider + if let provider = await registry.provider(for: engine) { + return await provider.checkConnection() + } + + // If provider doesn't exist, create it for engines that need credentials + // (Google, DeepL, Baidu, LLM providers, etc.) + guard engine.requiresAPIKey else { + // Built-in engines (apple, mtranServer) should already be registered in init + // If missing, log warning but return true to avoid false failure in UI + logger.warning("Built-in engine \(engine.rawValue) provider not found in registry") + return true + } + + let provider: any TranslationProvider + do { + provider = try await resolvedProvider(for: engine) + } catch { + logger.error("Failed to resolve provider for \(engine.rawValue): \(error.localizedDescription)") + return false + } + + return await provider.checkConnection() + } +} diff --git a/ScreenTranslate/Services/VLMProvider.swift b/ScreenTranslate/Services/VLMProvider.swift new file mode 100644 index 0000000..46836b7 --- /dev/null +++ b/ScreenTranslate/Services/VLMProvider.swift @@ -0,0 +1,197 @@ +// +// VLMProvider.swift +// ScreenTranslate +// +// Created for US-004: VLM Provider Protocol +// + +import Foundation +import CoreGraphics + +// MARK: - VLM Provider Configuration + +/// Configuration for VLM provider connections +struct VLMProviderConfiguration: Sendable, Equatable { + let apiKey: String + let baseURL: URL + let modelName: String + + init(apiKey: String, baseURL: URL, modelName: String) { + self.apiKey = apiKey + self.baseURL = baseURL + self.modelName = modelName + } + + init(apiKey: String, baseURLString: String, modelName: String) throws { + guard let url = URL(string: baseURLString) else { + throw VLMProviderError.invalidConfiguration("Invalid base URL: \(baseURLString)") + } + self.apiKey = apiKey + self.baseURL = url + self.modelName = modelName + } +} + +// MARK: - VLM Provider Protocol + +/// Protocol defining a Vision Language Model provider for screen analysis +/// Implementations can wrap different VLM APIs (OpenAI GPT-4V, Claude Vision, Gemini, etc.) +protocol VLMProvider: Sendable { + /// Unique identifier for this provider + var id: String { get } + + /// Human-readable name for display + var name: String { get } + + /// Whether the provider is currently available (configured and reachable) + var isAvailable: Bool { get async } + + /// Current configuration + var configuration: VLMProviderConfiguration { get } + + /// Analyze an image and extract text segments with bounding boxes + /// - Parameter image: The image to analyze + /// - Returns: Analysis result containing text segments with positions + /// - Throws: VLMProviderError if analysis fails + func analyze(image: CGImage) async throws -> ScreenAnalysisResult +} + +// MARK: - VLM Provider Errors + +/// Errors that can occur during VLM provider operations +enum VLMProviderError: LocalizedError, Sendable { + case invalidConfiguration(String) + case networkError(String) + case authenticationFailed + case rateLimited(retryAfter: TimeInterval?, message: String? = nil) + case invalidResponse(String) + case modelUnavailable(String) + case imageEncodingFailed + case parsingFailed(String) + + var errorDescription: String? { + switch self { + case .invalidConfiguration(let message): + return "Invalid configuration: \(message)" + case .networkError(let message): + return "Network error: \(message)" + case .authenticationFailed: + return "Authentication failed. Please check your API key." + case .rateLimited(let retryAfter, let message): + if let msg = message { + return msg + } + if let seconds = retryAfter { + return "Rate limited. Retry after \(Int(seconds)) seconds." + } + return "Rate limited. Please try again later." + case .invalidResponse(let message): + return "Invalid response from server: \(message)" + case .modelUnavailable(let model): + return "Model '\(model)' is not available." + case .imageEncodingFailed: + return "Failed to encode image for upload." + case .parsingFailed(let message): + return "Failed to parse VLM response: \(message)" + } + } +} + +// MARK: - VLM Prompt Template + +/// Standard prompt template for VLM screen analysis +/// Designed to extract text with bounding boxes in a structured JSON format +enum VLMPromptTemplate { + + /// System prompt establishing the VLM's role + static let systemPrompt = """ + You are an OCR assistant. Recognize ALL text in the image. + Return JSON: {"segments":[{"text":"...","boundingBox":{"x":0.0,"y":0.0,"width":0.0,"height":0.0},"confidence":0.95}]} + """ + + /// User prompt requesting text extraction + static let userPrompt = """ + Please recognize all text in this image and return the results in JSON format. + Use this format: {"segments":[{"text":"...","boundingBox":{"x":0.0,"y":0.0,"width":0.0,"height":0.0},"confidence":0.95}]} + + - x,y: top-left corner (0.0-1.0 normalized to image size) + - width,height: box dimensions (0.0-1.0) + - confidence: 0.0-1.0 + """ + + /// Local model prompts (same as cloud - use unified prompt) + static let localModelSystemPrompt = systemPrompt + static let localModelUserPrompt = userPrompt + + /// JSON schema description for documentation and API configuration + /// Used to configure VLM APIs that support structured output (e.g., OpenAI's response_format) + static let responseSchemaDescription = """ + { + "type": "object", + "properties": { + "segments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": {"type": "string"}, + "boundingBox": { + "type": "object", + "properties": { + "x": {"type": "number", "minimum": 0, "maximum": 1}, + "y": {"type": "number", "minimum": 0, "maximum": 1}, + "width": {"type": "number", "minimum": 0, "maximum": 1}, + "height": {"type": "number", "minimum": 0, "maximum": 1} + }, + "required": ["x", "y", "width", "height"] + }, + "confidence": {"type": "number", "minimum": 0, "maximum": 1} + }, + "required": ["text", "boundingBox", "confidence"] + } + } + }, + "required": ["segments"] + } + """ +} + +// MARK: - VLM Response Parsing + +/// Response structure from VLM for parsing +struct VLMAnalysisResponse: Codable, Sendable { + let segments: [VLMTextSegment] +} + +struct VLMTextSegment: Codable, Sendable { + let text: String + let boundingBox: VLMBoundingBox + let confidence: Float? +} + +struct VLMBoundingBox: Codable, Sendable { + let x: CGFloat + let y: CGFloat + let width: CGFloat + let height: CGFloat + + var cgRect: CGRect { + CGRect(x: x, y: y, width: width, height: height) + } +} + +// MARK: - Response Conversion Extension + +extension VLMAnalysisResponse { + /// Convert VLM response to ScreenAnalysisResult + func toScreenAnalysisResult(imageSize: CGSize) -> ScreenAnalysisResult { + let textSegments = segments.map { segment in + TextSegment( + text: segment.text, + boundingBox: segment.boundingBox.cgRect, + confidence: segment.confidence ?? 1.0 + ) + } + return ScreenAnalysisResult(segments: textSegments, imageSize: imageSize) + } +} diff --git a/ScreenTranslate/Services/VLMTextDeduplicator.swift b/ScreenTranslate/Services/VLMTextDeduplicator.swift new file mode 100644 index 0000000..d58e2f1 --- /dev/null +++ b/ScreenTranslate/Services/VLMTextDeduplicator.swift @@ -0,0 +1,127 @@ +// +// VLMTextDeduplicator.swift +// ScreenTranslate +// +// Shared component for deduplicating VLM text segments +// Detects hallucinations and removes duplicate segments +// + +import Foundation +import CoreGraphics + +/// Shared component for deduplicating VLM text segments +/// Used by both ClaudeVLMProvider and OpenAIVLMProvider +enum VLMTextDeduplicator { + + /// Configuration for deduplication behavior + struct Configuration: Sendable { + /// Minimum count threshold for detecting overrepresented texts (hallucinations) + var minCountThreshold: Int + + /// Percentage of total segments to use as threshold (0.0-1.0) + var percentageMultiplier: Double + + /// Position rounding precision for signature matching + var positionPrecision: Double + + static let `default` = Configuration( + minCountThreshold: 5, + percentageMultiplier: 0.1, + positionPrecision: 100.0 + ) + } + + /// Removes duplicate segments using a two-pass strategy: + /// 1. First pass: detect overrepresented texts (hallucinations) and keep only first occurrence + /// 2. Second pass: remove segments with identical text+position signatures + /// - Parameters: + /// - segments: The segments to deduplicate + /// - config: Deduplication configuration + /// - logger: Optional logging closure for detected hallucinations + /// - Returns: Deduplicated segments + static func deduplicate( + _ segments: [VLMTextSegment], + config: Configuration = .default, + logger: ((Int, Int, Int) -> Void)? = nil + ) -> [VLMTextSegment] { + guard !segments.isEmpty else { return segments } + + // Count text frequency to detect hallucinations + var textCounts: [String: Int] = [:] + for segment in segments { + let normalizedText = segment.text.trimmingCharacters(in: .whitespacesAndNewlines) + textCounts[normalizedText, default: 0] += 1 + } + + // Calculate threshold: max of minCountThreshold or percentage of total + // Clamp percentageMultiplier to valid range (0, 1] to prevent division issues + let total = segments.count + let validatedMultiplier = max(0.01, min(1.0, config.percentageMultiplier)) + let percentageThreshold = max(config.minCountThreshold, Int(Double(total) * validatedMultiplier)) + + // First pass: build a set of texts that are over-represented (likely hallucinations) + var overrepresentedTexts = Set() + for (text, count) in textCounts { + if count > percentageThreshold { + overrepresentedTexts.insert(text) + // Log only safe statistics: length, count, threshold + logger?(text.count, count, percentageThreshold) + } + } + + // Second pass: deduplicate + var seenTexts = Set() // For overrepresented texts, only keep first + var seenSignatures = Set() // For normal texts, use position-based signature + var result: [VLMTextSegment] = [] + + for segment in segments { + let normalizedText = segment.text.trimmingCharacters(in: .whitespacesAndNewlines) + + if overrepresentedTexts.contains(normalizedText) { + // For overrepresented texts, only keep the first occurrence + if !seenTexts.contains(normalizedText) { + seenTexts.insert(normalizedText) + result.append(segment) + } + } else { + // For normal texts, use position-based deduplication + let signature = segmentSignature(segment, precision: config.positionPrecision) + if !seenSignatures.contains(signature) { + seenSignatures.insert(signature) + result.append(segment) + } + } + } + + return result + } + + /// Filters out segments from new array that already exist in existing array + /// - Parameters: + /// - existing: Existing segments to check against + /// - new: New segments to filter + /// - config: Deduplication configuration + /// - Returns: Filtered segments that don't exist in existing array + static func filterDuplicates( + existing: [VLMTextSegment], + new: [VLMTextSegment], + config: Configuration = .default + ) -> [VLMTextSegment] { + let existingSignatures = Set(existing.map { segmentSignature($0, precision: config.positionPrecision) }) + return new.filter { !existingSignatures.contains(segmentSignature($0, precision: config.positionPrecision)) } + } + + /// Creates a unique signature for a segment based on text and approximate position + /// - Parameters: + /// - segment: The segment to create a signature for + /// - precision: Position rounding precision (e.g., 100 = 2 decimal places) + /// - Returns: A unique signature string + private static func segmentSignature(_ segment: VLMTextSegment, precision: Double) -> String { + let normalizedText = segment.text.trimmingCharacters(in: .whitespacesAndNewlines) + // Guard against zero or negative precision to prevent division issues + let safePrecision = max(1.0, precision) + let roundedX = (segment.boundingBox.x * safePrecision).rounded() / safePrecision + let roundedY = (segment.boundingBox.y * safePrecision).rounded() / safePrecision + return "\(normalizedText)|\(roundedX)|\(roundedY)" + } +} diff --git a/ScreenTranslate/Services/WindowDetector.swift b/ScreenTranslate/Services/WindowDetector.swift new file mode 100644 index 0000000..1d567ca --- /dev/null +++ b/ScreenTranslate/Services/WindowDetector.swift @@ -0,0 +1,360 @@ +import Foundation +import CoreGraphics +import AppKit + +/// Represents a window detected by CGWindowListCopyWindowInfo +struct WindowInfo: Identifiable, Hashable, Sendable { + /// Unique identifier for Identifiable conformance (uses windowID as UInt) + var id: UInt { UInt(windowID) } + + /// System window identifier (CGWindowID) + let windowID: CGWindowID + + /// Window position and size in global screen coordinates + let frame: CGRect + + /// Name of the application that owns this window + let ownerName: String + + /// Title of the window (may be empty for some windows) + let windowName: String + + /// Window layer level (0 = normal windows, >0 = floating windows, <0 = desktop elements) + let windowLayer: Int + + /// Whether this window is the main/key window of its application + let isOnScreen: Bool + + /// Alpha value of the window (0.0 - 1.0) + let alpha: Double + + // MARK: - Computed Properties + + /// User-visible display name (uses window name if available, otherwise owner name) + var displayName: String { + if !windowName.isEmpty { + return windowName + } + return ownerName + } + + /// Whether this is a normal application window (layer 0) + var isNormalWindow: Bool { + windowLayer == 0 + } + + // MARK: - Hashable + + func hash(into hasher: inout Hasher) { + hasher.combine(windowID) + } + + static func == (lhs: WindowInfo, rhs: WindowInfo) -> Bool { + lhs.windowID == rhs.windowID + } +} + +// MARK: - Window Detection Service + +/// Service responsible for detecting windows under the cursor using CGWindowListCopyWindowInfo. +/// Provides synchronous window enumeration for high-frequency mouse tracking scenarios. +actor WindowDetector { + // MARK: - Types + + /// Error types specific to window detection + enum Error: Swift.Error, LocalizedError { + case windowListUnavailable + case invalidWindowInfo + + var errorDescription: String? { + switch self { + case .windowListUnavailable: + return "Unable to retrieve window list" + case .invalidWindowInfo: + return "Invalid window information received" + } + } + } + + // MARK: - Properties + + /// Shared instance for app-wide window detection + static let shared = WindowDetector() + + /// Cached window list from last enumeration + private var cachedWindows: [WindowInfo] = [] + + /// Last time windows were enumerated + private var lastEnumerationTime: Date? + + /// Cache validity duration (100ms for high-frequency updates) + private let cacheValidityDuration: TimeInterval = 0.1 + + /// Bundle identifier of the current app (used to filter own windows) + private let ownBundleIdentifier: String + + // MARK: - Initialization + + private init() { + self.ownBundleIdentifier = Bundle.main.bundleIdentifier ?? "com.screentranslate" + } + + // MARK: - Public API + + /// Returns all visible windows sorted by Z-order (front to back). + /// Filters out system windows (Dock, Menu Bar) and own app's overlay windows. + /// - Returns: Array of WindowInfo for all visible windows + func visibleWindows() async -> [WindowInfo] { + // Check cache validity + if let lastTime = lastEnumerationTime, + Date().timeIntervalSince(lastTime) < cacheValidityDuration, + !cachedWindows.isEmpty { + return cachedWindows + } + + // Get window list from Core Graphics + guard let windowList = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], + kCGNullWindowID + ) as? [[String: Any]] else { + return [] + } + + // Parse and filter windows + let windows = parseWindowList(windowList) + + // Update cache + cachedWindows = windows + lastEnumerationTime = Date() + + return windows + } + + /// Returns the topmost visible window at the given point. + /// Searches through visible windows in Z-order and returns the first match. + /// - Parameter point: Point in global screen coordinates (Quartz coordinate system) + /// - Returns: WindowInfo for the window at the point, or nil if none found + func windowUnderPoint(_ point: CGPoint) async -> WindowInfo? { + let windows = await visibleWindows() + + // Search in Z-order (already sorted front to back) + return windows.first { window in + // Check if point is within window bounds + // CGWindowList returns coordinates in Quartz space (origin at top-left) + // which matches NSEvent.mouseLocation, so no conversion needed + window.frame.contains(point) + } + } + + /// Returns all windows that intersect with the given rect. + /// Useful for finding windows within a selection area. + /// - Parameter rect: Rectangle in global screen coordinates + /// - Returns: Array of WindowInfo intersecting the rect, sorted by Z-order + func windowsIntersecting(_ rect: CGRect) async -> [WindowInfo] { + let windows = await visibleWindows() + + return windows.filter { window in + window.frame.intersects(rect) + } + } + + /// Returns the window with the specified window ID. + /// - Parameter windowID: The CGWindowID to find + /// - Returns: WindowInfo for the specified window, or nil if not found + func window(withID windowID: CGWindowID) async -> WindowInfo? { + let windows = await visibleWindows() + return windows.first { $0.windowID == windowID } + } + + /// Clears the window cache, forcing a fresh enumeration on next call. + func invalidateCache() { + cachedWindows = [] + lastEnumerationTime = nil + } + + // MARK: - Private Methods + + /// Parses the raw window list from CGWindowListCopyWindowInfo. + /// - Parameter windowList: Raw array of window dictionaries + /// - Returns: Array of parsed WindowInfo, filtered and sorted + private func parseWindowList(_ windowList: [[String: Any]]) -> [WindowInfo] { + var windows: [WindowInfo] = [] + + for windowDict in windowList { + guard let windowInfo = parseWindowInfo(windowDict) else { + continue + } + + // Filter out system windows (layer != 0) + guard windowInfo.isNormalWindow else { + continue + } + + // Filter out own app's windows (overlay windows) + guard !isOwnWindow(windowInfo) else { + continue + } + + // Filter out windows with zero alpha (invisible) + guard windowInfo.alpha > 0 else { + continue + } + + // Filter out windows with empty frames + guard windowInfo.frame.width > 0 && windowInfo.frame.height > 0 else { + continue + } + + windows.append(windowInfo) + } + + // Windows are already in Z-order from CGWindowListCopyWindowInfo + // (front to back), so no additional sorting needed + return windows + } + + /// Parses a single window dictionary into WindowInfo. + /// - Parameter dict: Window dictionary from CGWindowListCopyWindowInfo + /// - Returns: Parsed WindowInfo, or nil if parsing fails + private func parseWindowInfo(_ dict: [String: Any]) -> WindowInfo? { + // Extract window ID + guard let windowID = dict[kCGWindowNumber as String] as? CGWindowID else { + return nil + } + + // Extract window bounds + guard let boundsDict = dict[kCGWindowBounds as String] as? [String: Any], + let x = boundsDict["X"] as? CGFloat, + let y = boundsDict["Y"] as? CGFloat, + let width = boundsDict["Width"] as? CGFloat, + let height = boundsDict["Height"] as? CGFloat else { + return nil + } + + let frame = CGRect(x: x, y: y, width: width, height: height) + + // Extract owner name (application name) + let ownerName = dict[kCGWindowOwnerName as String] as? String ?? "" + + // Extract window name (title) + let windowName = dict[kCGWindowName as String] as? String ?? "" + + // Extract window layer + let windowLayer = dict[kCGWindowLayer as String] as? Int ?? 0 + + // Extract on-screen status + let isOnScreen = dict[kCGWindowIsOnscreen as String] as? Bool ?? true + + // Extract alpha value + let alpha = dict[kCGWindowAlpha as String] as? Double ?? 1.0 + + return WindowInfo( + windowID: windowID, + frame: frame, + ownerName: ownerName, + windowName: windowName, + windowLayer: windowLayer, + isOnScreen: isOnScreen, + alpha: alpha + ) + } + + /// Checks if a window belongs to this application. + /// - Parameter windowInfo: The window to check + /// - Returns: True if the window is from this app + private func isOwnWindow(_ windowInfo: WindowInfo) -> Bool { + // Check if owner name matches our app name + let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + let displayName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + + if let appName = appName, windowInfo.ownerName == appName { + return true + } + + if let displayName = displayName, windowInfo.ownerName == displayName { + return true + } + + // Additional check: compare with process name + let processName = ProcessInfo.processInfo.processName + if windowInfo.ownerName == processName { + return true + } + + return false + } +} + +// MARK: - Coordinate Conversion Helpers + +extension WindowDetector { + /// Converts a point from Cocoa coordinate system (origin at bottom-left) + /// to Quartz coordinate system (origin at top-left). + /// - Parameters: + /// - point: Point in Cocoa coordinates + /// - screenHeight: The screen height for conversion + /// - Returns: Point in Quartz coordinates + static func cocoaToQuartz(_ point: CGPoint, screenHeight: CGFloat) -> CGPoint { + CGPoint(x: point.x, y: screenHeight - point.y) + } + + /// Converts a point from Quartz coordinate system (origin at top-left) + /// to Cocoa coordinate system (origin at bottom-left). + /// - Parameters: + /// - point: Point in Quartz coordinates + /// - screenHeight: The screen height for conversion + /// - Returns: Point in Cocoa coordinates + static func quartzToCocoa(_ point: CGPoint, screenHeight: CGFloat) -> CGPoint { + CGPoint(x: point.x, y: screenHeight - point.y) + } + + /// Converts a rect from Cocoa coordinate system to Quartz coordinate system. + /// - Parameters: + /// - rect: Rectangle in Cocoa coordinates + /// - screenHeight: The screen height for conversion + /// - Returns: Rectangle in Quartz coordinates + static func cocoaToQuartz(_ rect: CGRect, screenHeight: CGFloat) -> CGRect { + let y = screenHeight - rect.maxY + return CGRect(x: rect.minX, y: y, width: rect.width, height: rect.height) + } + + /// Converts a rect from Quartz coordinate system to Cocoa coordinate system. + /// - Parameters: + /// - rect: Rectangle in Quartz coordinates + /// - screenHeight: The screen height for conversion + /// - Returns: Rectangle in Cocoa coordinates + static func quartzToCocoa(_ rect: CGRect, screenHeight: CGFloat) -> CGRect { + let y = screenHeight - rect.maxY + return CGRect(x: rect.minX, y: y, width: rect.width, height: rect.height) + } + + // MARK: - Deprecated MainActor Methods + + /// Converts a point from Cocoa coordinate system to Quartz coordinate system. + /// - Parameters: + /// - point: Point in Cocoa coordinates + /// - screen: The screen for reference (uses main screen if nil) + /// - Returns: Point in Quartz coordinates + @MainActor + static func cocoaToQuartz(_ point: CGPoint, on screen: NSScreen?) -> CGPoint { + let targetScreen = screen ?? NSScreen.main + guard let targetScreen = targetScreen else { + return point + } + return cocoaToQuartz(point, screenHeight: targetScreen.frame.height) + } + + /// Converts a point from Quartz coordinate system to Cocoa coordinate system. + /// - Parameters: + /// - point: Point in Quartz coordinates + /// - screen: The screen for reference (uses main screen if nil) + /// - Returns: Point in Cocoa coordinates + @MainActor + static func quartzToCocoa(_ point: CGPoint, on screen: NSScreen?) -> CGPoint { + let targetScreen = screen ?? NSScreen.main + guard let targetScreen = targetScreen else { + return point + } + return quartzToCocoa(point, screenHeight: targetScreen.frame.height) + } +} diff --git a/ScreenCapture/Supporting Files/Info.plist b/ScreenTranslate/Supporting Files/Info.plist similarity index 73% rename from ScreenCapture/Supporting Files/Info.plist rename to ScreenTranslate/Supporting Files/Info.plist index 6f72c61..7a17ace 100644 --- a/ScreenCapture/Supporting Files/Info.plist +++ b/ScreenTranslate/Supporting Files/Info.plist @@ -20,6 +20,8 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.productivity LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) LSUIElement @@ -29,6 +31,10 @@ NSPrincipalClass NSApplication NSScreenCaptureUsageDescription - ScreenCapture needs access to record your screen to capture screenshots. + ScreenTranslate needs access to record your screen to capture and translate text. + SUFeedURL + https://github.com/hubo1989/ScreenTranslate/releases/latest/download/appcast.xml + SUPublicEDKey + 9QcgQ1StP4EbmhcOASM65vPQ64/QHGGFFLC39ww95DI= diff --git a/ScreenCapture/Supporting Files/ScreenCapture.entitlements b/ScreenTranslate/Supporting Files/ScreenTranslate.entitlements similarity index 55% rename from ScreenCapture/Supporting Files/ScreenCapture.entitlements rename to ScreenTranslate/Supporting Files/ScreenTranslate.entitlements index 19afff1..0c67376 100644 --- a/ScreenCapture/Supporting Files/ScreenCapture.entitlements +++ b/ScreenTranslate/Supporting Files/ScreenTranslate.entitlements @@ -1,10 +1,5 @@ - - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-write - - + diff --git a/ScreenTranslate/Utilities/Logging.swift b/ScreenTranslate/Utilities/Logging.swift new file mode 100644 index 0000000..1640eb1 --- /dev/null +++ b/ScreenTranslate/Utilities/Logging.swift @@ -0,0 +1,13 @@ +import os +import Foundation + +extension Logger { + private static let subsystem = Bundle.main.bundleIdentifier ?? "com.screentranslate" + + static let general = Logger(subsystem: subsystem, category: "general") + static let ocr = Logger(subsystem: subsystem, category: "ocr") + static let translation = Logger(subsystem: subsystem, category: "translation") + static let capture = Logger(subsystem: subsystem, category: "capture") + static let ui = Logger(subsystem: subsystem, category: "ui") + static let settings = Logger(subsystem: subsystem, category: "settings") +} diff --git a/ScreenTranslateTests/GLMOCRVLMProviderTests.swift b/ScreenTranslateTests/GLMOCRVLMProviderTests.swift new file mode 100644 index 0000000..d0ef579 --- /dev/null +++ b/ScreenTranslateTests/GLMOCRVLMProviderTests.swift @@ -0,0 +1,119 @@ +import CoreGraphics +import XCTest +@testable import ScreenTranslate + +final class GLMOCRVLMProviderTests: XCTestCase { + func testParseResponseMapsLayoutDetailsToScreenSegments() throws { + let json = """ + { + "id": "task_123", + "model": "GLM-OCR", + "layout_details": [ + [ + { + "index": 1, + "label": "text", + "bbox_2d": [0.1, 0.2, 0.4, 0.3], + "content": "Hello world", + "width": 1200, + "height": 800 + }, + { + "index": 2, + "label": "table", + "bbox_2d": [0.5, 0.2, 0.9, 0.6], + "content": "
Total42
", + "width": 1200, + "height": 800 + }, + { + "index": 3, + "label": "image", + "bbox_2d": [0.0, 0.0, 0.2, 0.2], + "content": "https://example.com/image.png", + "width": 1200, + "height": 800 + } + ] + ], + "data_info": { + "num_pages": 1, + "pages": [ + { + "width": 1200, + "height": 800 + } + ] + } + } + """ + + let result = try GLMOCRVLMProvider.parseResponse( + Data(json.utf8), + fallbackImageSize: CGSize(width: 100, height: 100) + ) + + XCTAssertEqual(result.imageSize, CGSize(width: 1200, height: 800)) + XCTAssertEqual(result.segments.map(\.text), ["Hello world", "Total 42"]) + XCTAssertEqual(result.segments.count, 2) + guard result.segments.count == 2 else { return } + assertRect(result.segments[0].boundingBox, equals: CGRect(x: 0.1, y: 0.2, width: 0.3, height: 0.1)) + assertRect(result.segments[1].boundingBox, equals: CGRect(x: 0.5, y: 0.2, width: 0.4, height: 0.4)) + } + + func testParseLocalResponseMapsOpenAICompatibleContentToScreenSegments() throws { + let json = """ + { + "choices": [ + { + "message": { + "content": "{\\"segments\\":[{\\"text\\":\\"Local OCR\\",\\"boundingBox\\":{\\"x\\":0.2,\\"y\\":0.3,\\"width\\":0.4,\\"height\\":0.1},\\"confidence\\":0.98}]}" + } + } + ] + } + """ + + let result = try GLMOCRVLMProvider.parseLocalResponse( + Data(json.utf8), + fallbackImageSize: CGSize(width: 640, height: 480) + ) + + XCTAssertEqual(result.imageSize, CGSize(width: 640, height: 480)) + XCTAssertEqual(result.segments.map(\.text), ["Local OCR"]) + XCTAssertEqual(result.segments.count, 1) + guard result.segments.count == 1 else { return } + assertRect(result.segments[0].boundingBox, equals: CGRect(x: 0.2, y: 0.3, width: 0.4, height: 0.1)) + } + + func testParseLocalResponseSupportsTextOnlyPayload() throws { + let json = #""" + { + "choices": [ + { + "message": { + "content": "{\"Text\":\"返回 200\\n-scheme ScreenTranslate -destination 'platform=macOS'\"}" + } + } + ] + } + """# + + let result = try GLMOCRVLMProvider.parseLocalResponse( + Data(json.utf8), + fallbackImageSize: CGSize(width: 640, height: 480) + ) + + XCTAssertEqual( + result.segments.map(\.text), + ["返回 200", "-scheme ScreenTranslate -destination 'platform=macOS'"] + ) + } + + private func assertRect(_ actual: CGRect, equals expected: CGRect, accuracy: CGFloat = 0.0001) { + XCTAssertEqual(actual.origin.x, expected.origin.x, accuracy: accuracy) + XCTAssertEqual(actual.origin.y, expected.origin.y, accuracy: accuracy) + XCTAssertEqual(actual.size.width, expected.size.width, accuracy: accuracy) + XCTAssertEqual(actual.size.height, expected.size.height, accuracy: accuracy) + } +} diff --git a/ScreenTranslateTests/KeyboardShortcutTests.swift b/ScreenTranslateTests/KeyboardShortcutTests.swift new file mode 100644 index 0000000..c444b5d --- /dev/null +++ b/ScreenTranslateTests/KeyboardShortcutTests.swift @@ -0,0 +1,196 @@ +import XCTest +import AppKit +import Carbon.HIToolbox +@testable import ScreenTranslate + +// MARK: - KeyboardShortcut Tests + +/// Tests for KeyboardShortcut model +final class KeyboardShortcutTests: XCTestCase { + + // MARK: - Default Shortcuts + + func testFullScreenDefaultShortcut() { + let shortcut = KeyboardShortcut.fullScreenDefault + + XCTAssertEqual(shortcut.keyCode, 0x14) // kVK_ANSI_3 + XCTAssertEqual(shortcut.modifiers, UInt32(cmdKey | shiftKey)) + XCTAssertTrue(shortcut.isValid) + } + + func testSelectionDefaultShortcut() { + let shortcut = KeyboardShortcut.selectionDefault + + XCTAssertEqual(shortcut.keyCode, 0x15) // kVK_ANSI_4 + XCTAssertEqual(shortcut.modifiers, UInt32(cmdKey | shiftKey)) + XCTAssertTrue(shortcut.isValid) + } + + func testTranslationModeDefaultShortcut() { + let shortcut = KeyboardShortcut.translationModeDefault + + XCTAssertEqual(shortcut.keyCode, 0x11) // kVK_ANSI_T + XCTAssertEqual(shortcut.modifiers, UInt32(cmdKey | shiftKey)) + XCTAssertTrue(shortcut.isValid) + } + + // MARK: - Validation + + func testShortcutWithCommandModifierIsValid() { + let shortcut = KeyboardShortcut( + keyCode: 0x00, // A + modifiers: UInt32(cmdKey) + ) + + XCTAssertTrue(shortcut.isValid) + XCTAssertTrue(shortcut.hasRequiredModifiers) + } + + func testShortcutWithControlModifierIsValid() { + let shortcut = KeyboardShortcut( + keyCode: 0x00, // A + modifiers: UInt32(controlKey) + ) + + XCTAssertTrue(shortcut.isValid) + XCTAssertTrue(shortcut.hasRequiredModifiers) + } + + func testShortcutWithOptionModifierIsValid() { + let shortcut = KeyboardShortcut( + keyCode: 0x00, // A + modifiers: UInt32(optionKey) + ) + + XCTAssertTrue(shortcut.isValid) + XCTAssertTrue(shortcut.hasRequiredModifiers) + } + + func testShortcutWithNoModifierIsInvalid() { + let shortcut = KeyboardShortcut( + keyCode: 0x00, // A + modifiers: 0 + ) + + XCTAssertFalse(shortcut.isValid) + XCTAssertFalse(shortcut.hasRequiredModifiers) + } + + func testShortcutWithOnlyShiftModifierIsInvalid() { + let shortcut = KeyboardShortcut( + keyCode: 0x00, // A + modifiers: UInt32(shiftKey) + ) + + XCTAssertFalse(shortcut.isValid) + XCTAssertFalse(shortcut.hasRequiredModifiers) + } + + // MARK: - Display String + + func testDisplayStringWithCommandShift() { + let shortcut = KeyboardShortcut( + keyCode: 0x14, // 3 + modifiers: UInt32(cmdKey | shiftKey) + ) + + // Display string should contain modifiers and key + let displayString = shortcut.displayString + XCTAssertTrue(displayString.contains("Cmd")) + XCTAssertTrue(displayString.contains("Shift")) + XCTAssertTrue(displayString.contains("3")) + } + + func testDisplayStringWithAllModifiers() { + let shortcut = KeyboardShortcut( + keyCode: 0x00, // A + modifiers: UInt32(cmdKey | shiftKey | optionKey | controlKey) + ) + + let displayString = shortcut.displayString + XCTAssertTrue(displayString.contains("Ctrl")) + XCTAssertTrue(displayString.contains("Opt")) + XCTAssertTrue(displayString.contains("Shift")) + XCTAssertTrue(displayString.contains("Cmd")) + XCTAssertTrue(displayString.contains("A")) + } + + // MARK: - Symbol String + + func testSymbolStringFormat() { + let shortcut = KeyboardShortcut( + keyCode: 0x14, // 3 + modifiers: UInt32(cmdKey | shiftKey) + ) + + let symbolString = shortcut.symbolString + XCTAssertTrue(symbolString.contains("$")) // shift + XCTAssertTrue(symbolString.contains("@")) // command + } + + // MARK: - Modifier Conversion + + func testCarbonToNSEventModifierConversion() { + let carbonModifiers = UInt32(cmdKey | shiftKey | optionKey | controlKey) + let shortcut = KeyboardShortcut(keyCode: 0x00, modifiers: carbonModifiers) + + let nsFlags = shortcut.nsModifierFlags + + XCTAssertTrue(nsFlags.contains(.command)) + XCTAssertTrue(nsFlags.contains(.shift)) + XCTAssertTrue(nsFlags.contains(.option)) + XCTAssertTrue(nsFlags.contains(.control)) + } + + func testNSEventToCarbonModifierConversion() { + let nsFlags: NSEvent.ModifierFlags = [.command, .shift, .option, .control] + let shortcut = KeyboardShortcut(keyCode: 0x00, modifierFlags: nsFlags) + + XCTAssertTrue(shortcut.modifiers & UInt32(cmdKey) != 0) + XCTAssertTrue(shortcut.modifiers & UInt32(shiftKey) != 0) + XCTAssertTrue(shortcut.modifiers & UInt32(optionKey) != 0) + XCTAssertTrue(shortcut.modifiers & UInt32(controlKey) != 0) + } + + // MARK: - Equatable + + func testShortcutEquality() { + let shortcut1 = KeyboardShortcut(keyCode: 0x14, modifiers: UInt32(cmdKey | shiftKey)) + let shortcut2 = KeyboardShortcut(keyCode: 0x14, modifiers: UInt32(cmdKey | shiftKey)) + let shortcut3 = KeyboardShortcut(keyCode: 0x15, modifiers: UInt32(cmdKey | shiftKey)) + + XCTAssertEqual(shortcut1, shortcut2) + XCTAssertNotEqual(shortcut1, shortcut3) + } + + // MARK: - Codable + + func testShortcutEncodingDecoding() throws { + let original = KeyboardShortcut(keyCode: 0x14, modifiers: UInt32(cmdKey | shiftKey)) + + let encoder = JSONEncoder() + let data = try encoder.encode(original) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(KeyboardShortcut.self, from: data) + + XCTAssertEqual(original, decoded) + } + + // MARK: - Main Key + + func testMainKeyForLetter() { + let shortcut = KeyboardShortcut(keyCode: 0x00, modifiers: UInt32(cmdKey)) // A + XCTAssertEqual(shortcut.mainKey, "A") + } + + func testMainKeyForNumber() { + let shortcut = KeyboardShortcut(keyCode: 0x14, modifiers: UInt32(cmdKey)) // 3 + XCTAssertEqual(shortcut.mainKey, "3") + } + + func testMainKeyForFunctionKey() { + let shortcut = KeyboardShortcut(keyCode: 0x7A, modifiers: UInt32(cmdKey)) // F1 + XCTAssertEqual(shortcut.mainKey, "F1") + } +} diff --git a/ScreenTranslateTests/README.md b/ScreenTranslateTests/README.md new file mode 100644 index 0000000..97d7942 --- /dev/null +++ b/ScreenTranslateTests/README.md @@ -0,0 +1,93 @@ +# ScreenTranslate Unit Tests + +This directory contains unit tests for the ScreenTranslate application. + +## Test Files + +| File | Description | +|------|-------------| +| `KeyboardShortcutTests.swift` | Tests for keyboard shortcut model | +| `ScreenTranslateErrorTests.swift` | Tests for error types | +| `TextTranslationErrorTests.swift` | Tests for translation errors and phases | +| `ShortcutRecordingTypeTests.swift` | Tests for shortcut recording enum | + +## Adding Tests to Xcode Project + +The project does not currently have a test target. To add one: + +1. Open `ScreenTranslate.xcodeproj` in Xcode +2. File → New → Target +3. Select **macOS** → **Unit Testing Bundle** +4. Name it `ScreenTranslateTests` +5. Add the test files from this directory to the new target + +## Running Tests + +### Via Xcode +- Press `Cmd+U` to run all tests +- Or use Product → Test menu + +### Via Command Line +```bash +xcodebuild test \ + -project ScreenTranslate.xcodeproj \ + -scheme ScreenTranslate \ + -destination 'platform=macOS' +``` + +## Test Coverage Goals + +- [x] KeyboardShortcut model +- [x] Error types (ScreenTranslateError, TextTranslationError) +- [x] TranslationFlowPhase +- [x] ShortcutRecordingType enum +- [ ] SettingsViewModel (requires @MainActor setup) +- [ ] Coordinator classes (requires dependency injection) +- [ ] TranslationService (requires mocking) + +## Adding New Tests + +When adding new tests: + +1. Follow the `XCTestCase` pattern +2. Use `MARK:` comments to organize test sections +3. Name test methods descriptively: `test__` +4. For async tests, use `async` test methods + +Example: +```swift +func testTranslate_WhenTextIsEmpty_ReturnsEmptyResult() async throws { + // Arrange + let service = TranslationService.shared + + // Act + let result = try await service.translate( + segments: [], + to: "zh-Hans", + preferredEngine: .apple, + from: nil + ) + + // Assert + XCTAssertTrue(result.isEmpty) +} +``` + +## Mocking Strategy + +For services that require external dependencies (API calls, accessibility), use protocol-based mocking: + +```swift +// Define a mock service +final class MockTranslationService: TranslationServicing { + var mockResult: [BilingualSegment] = [] + var mockError: Error? + + func translate(...) async throws -> [BilingualSegment] { + if let error = mockError { + throw error + } + return mockResult + } +} +``` diff --git a/ScreenTranslateTests/ScreenTranslateErrorTests.swift b/ScreenTranslateTests/ScreenTranslateErrorTests.swift new file mode 100644 index 0000000..0059bc9 --- /dev/null +++ b/ScreenTranslateTests/ScreenTranslateErrorTests.swift @@ -0,0 +1,175 @@ +import XCTest +@testable import ScreenTranslate + +// MARK: - ScreenTranslateError Tests + +/// Tests for ScreenTranslateError enum +final class ScreenTranslateErrorTests: XCTestCase { + + // MARK: - Capture Errors + + func testPermissionDeniedErrorDescription() { + let error = ScreenTranslateError.permissionDenied + + XCTAssertNotNil(error.errorDescription) + XCTAssertTrue(error.errorDescription!.count > 0) + XCTAssertNotNil(error.recoverySuggestion) + } + + func testDisplayNotFoundErrorDescription() { + let displayID: CGDirectDisplayID = 12345 + let error = ScreenTranslateError.displayNotFound(displayID) + + XCTAssertNotNil(error.errorDescription) + XCTAssertNotNil(error.recoverySuggestion) + } + + func testDisplayDisconnectedErrorDescription() { + let error = ScreenTranslateError.displayDisconnected(displayName: "External Monitor") + + XCTAssertNotNil(error.errorDescription) + XCTAssertTrue(error.errorDescription!.contains("External Monitor")) + XCTAssertNotNil(error.recoverySuggestion) + } + + func testCaptureFailureErrorDescription() { + let underlyingError = NSError(domain: "TestDomain", code: 1, userInfo: nil) + let error = ScreenTranslateError.captureFailure(underlying: underlyingError) + + XCTAssertNotNil(error.errorDescription) + XCTAssertNotNil(error.recoverySuggestion) + } + + // MARK: - Export Errors + + func testInvalidSaveLocationErrorDescription() { + let url = URL(fileURLWithPath: "/nonexistent/path") + let error = ScreenTranslateError.invalidSaveLocation(url) + + XCTAssertNotNil(error.errorDescription) + XCTAssertNotNil(error.recoverySuggestion) + } + + func testDiskFullErrorDescription() { + let error = ScreenTranslateError.diskFull + + XCTAssertNotNil(error.errorDescription) + XCTAssertNotNil(error.recoverySuggestion) + } + + func testExportEncodingFailedErrorDescription() { + let error = ScreenTranslateError.exportEncodingFailed(format: .png) + + XCTAssertNotNil(error.errorDescription) + XCTAssertNotNil(error.recoverySuggestion) + } + + // MARK: - Clipboard Errors + + func testClipboardWriteFailedErrorDescription() { + let error = ScreenTranslateError.clipboardWriteFailed + + XCTAssertNotNil(error.errorDescription) + XCTAssertNotNil(error.recoverySuggestion) + } + + // MARK: - Hotkey Errors + + func testHotkeyRegistrationFailedErrorDescription() { + let error = ScreenTranslateError.hotkeyRegistrationFailed(keyCode: 0x14) + + XCTAssertNotNil(error.errorDescription) + XCTAssertNotNil(error.recoverySuggestion) + } + + func testHotkeyConflictErrorDescription() { + let error = ScreenTranslateError.hotkeyConflict(existingApp: "OtherApp") + + XCTAssertNotNil(error.errorDescription) + XCTAssertNotNil(error.recoverySuggestion) + } + + // MARK: - OCR Errors + + func testOCROperationInProgressErrorDescription() { + let error = ScreenTranslateError.ocrOperationInProgress + + XCTAssertNotNil(error.errorDescription) + XCTAssertNotNil(error.recoverySuggestion) + } + + func testOCRInvalidImageErrorDescription() { + let error = ScreenTranslateError.ocrInvalidImage + + XCTAssertNotNil(error.errorDescription) + XCTAssertNotNil(error.recoverySuggestion) + } + + func testOCRRecognitionFailedErrorDescription() { + let error = ScreenTranslateError.ocrRecognitionFailed + + XCTAssertNotNil(error.errorDescription) + XCTAssertNotNil(error.recoverySuggestion) + } + + func testOCRNoTextFoundErrorDescription() { + let error = ScreenTranslateError.ocrNoTextFound + + XCTAssertNotNil(error.errorDescription) + XCTAssertNotNil(error.recoverySuggestion) + } + + // MARK: - Sendable Conformance + + func testErrorIsSendable() { + // Verify Sendable conformance by using in a Sendable closure + let error = ScreenTranslateError.permissionDenied + + let sendableClosure: @Sendable () -> ScreenTranslateError = { + return error + } + + // If this compiles and runs, the error is Sendable + if case .permissionDenied = sendableClosure() { + XCTAssertTrue(true) + } else { + XCTFail("Expected permissionDenied case") + } + } + + // MARK: - CaptureFailureError Helper + + func testCaptureErrorHelper() { + let error = ScreenTranslateError.captureError(message: "Test error message") + + if case .captureFailure(let underlying) = error { + XCTAssertTrue(underlying is CaptureFailureError) + XCTAssertEqual((underlying as? CaptureFailureError)?.message, "Test error message") + } else { + XCTFail("Expected captureFailure case") + } + } +} + +// MARK: - CaptureFailureError Tests + +/// Tests for CaptureFailureError struct +final class CaptureFailureErrorTests: XCTestCase { + + func testLocalizedDescription() { + let error = CaptureFailureError(message: "Capture failed") + + XCTAssertEqual(error.localizedDescription, "Capture failed") + } + + func testSendableConformance() { + let error = CaptureFailureError(message: "Test") + + // If this compiles, CaptureFailureError conforms to Sendable + let sendableClosure: @Sendable () -> CaptureFailureError = { + return error + } + + XCTAssertEqual(sendableClosure().message, "Test") + } +} diff --git a/ScreenTranslateTests/ShortcutRecordingTypeTests.swift b/ScreenTranslateTests/ShortcutRecordingTypeTests.swift new file mode 100644 index 0000000..63e044e --- /dev/null +++ b/ScreenTranslateTests/ShortcutRecordingTypeTests.swift @@ -0,0 +1,108 @@ +import XCTest +import Carbon.HIToolbox +@testable import ScreenTranslate + +// MARK: - ShortcutRecordingType Tests + +/// Tests for ShortcutRecordingType enum +final class ShortcutRecordingTypeTests: XCTestCase { + + // MARK: - All Cases + + func testAllCasesExist() { + let allCases: [ShortcutRecordingType] = [ + .fullScreen, + .selection, + .translationMode, + .textSelectionTranslation, + .translateAndInsert + ] + + XCTAssertEqual(allCases.count, 5) + } + + // MARK: - Equatable + + func testTypeEquality() { + XCTAssertEqual(ShortcutRecordingType.fullScreen, ShortcutRecordingType.fullScreen) + XCTAssertEqual(ShortcutRecordingType.selection, ShortcutRecordingType.selection) + XCTAssertNotEqual(ShortcutRecordingType.fullScreen, ShortcutRecordingType.selection) + } + + // MARK: - Switch Coverage + + func testSwitchExhaustiveness() { + // This test verifies switch exhaustiveness + let types: [ShortcutRecordingType] = [ + .fullScreen, + .selection, + .translationMode, + .textSelectionTranslation, + .translateAndInsert + ] + + for type in types { + // If switch is not exhaustive, compiler will error + switch type { + case .fullScreen: + XCTAssertTrue(true) + case .selection: + XCTAssertTrue(true) + case .translationMode: + XCTAssertTrue(true) + case .textSelectionTranslation: + XCTAssertTrue(true) + case .translateAndInsert: + XCTAssertTrue(true) + } + } + } +} + +// MARK: - SettingsViewModel Shortcut Tests (Partial) + +/// Tests for SettingsViewModel shortcut-related functionality +/// Note: Full tests require @MainActor isolation +final class SettingsViewModelShortcutTests: XCTestCase { + + // MARK: - Shortcut Conflict Detection + + func testShortcutConflictDetection() { + // Test the conflict detection logic indirectly + // by verifying that equal shortcuts would conflict + + let shortcut1 = KeyboardShortcut(keyCode: 0x14, modifiers: UInt32(cmdKey | shiftKey)) + let shortcut2 = KeyboardShortcut(keyCode: 0x14, modifiers: UInt32(cmdKey | shiftKey)) + let shortcut3 = KeyboardShortcut(keyCode: 0x15, modifiers: UInt32(cmdKey | shiftKey)) + + // Equal shortcuts should be equal + XCTAssertEqual(shortcut1, shortcut2) + + // Different shortcuts should not be equal + XCTAssertNotEqual(shortcut1, shortcut3) + } + + // MARK: - Shortcut Validation + + func testValidShortcuts() { + // Cmd modifier + let cmdShortcut = KeyboardShortcut(keyCode: 0x00, modifiers: UInt32(cmdKey)) + XCTAssertTrue(cmdShortcut.isValid) + + // Control modifier + let ctrlShortcut = KeyboardShortcut(keyCode: 0x00, modifiers: UInt32(controlKey)) + XCTAssertTrue(ctrlShortcut.isValid) + + // Option modifier + let optShortcut = KeyboardShortcut(keyCode: 0x00, modifiers: UInt32(optionKey)) + XCTAssertTrue(optShortcut.isValid) + + // Shift only (invalid) + let shiftOnlyShortcut = KeyboardShortcut(keyCode: 0x00, modifiers: UInt32(shiftKey)) + XCTAssertFalse(shiftOnlyShortcut.isValid) + + // No modifier (invalid) + let noModShortcut = KeyboardShortcut(keyCode: 0x00, modifiers: 0) + XCTAssertFalse(noModShortcut.isValid) + } +} diff --git a/ScreenTranslateTests/TextTranslationErrorTests.swift b/ScreenTranslateTests/TextTranslationErrorTests.swift new file mode 100644 index 0000000..a1448df --- /dev/null +++ b/ScreenTranslateTests/TextTranslationErrorTests.swift @@ -0,0 +1,239 @@ +import XCTest +@testable import ScreenTranslate + +// MARK: - TextTranslationError Tests + +/// Tests for TextTranslationError enum +final class TextTranslationErrorTests: XCTestCase { + + // MARK: - Error Descriptions + + func testEmptyInputErrorDescription() { + let error = TextTranslationError.emptyInput + + XCTAssertNotNil(error.errorDescription) + XCTAssertTrue(error.errorDescription!.count > 0) + } + + func testTranslationFailedErrorDescription() { + let errorMessage = "API connection timeout" + let error = TextTranslationError.translationFailed(errorMessage) + + XCTAssertNotNil(error.errorDescription) + XCTAssertTrue(error.errorDescription!.contains(errorMessage)) + } + + func testCancelledErrorDescription() { + let error = TextTranslationError.cancelled + + XCTAssertNotNil(error.errorDescription) + } + + func testServiceUnavailableErrorDescription() { + let error = TextTranslationError.serviceUnavailable + + XCTAssertNotNil(error.errorDescription) + } + + // MARK: - Equatable + + func testErrorEquality() { + let error1 = TextTranslationError.emptyInput + let error2 = TextTranslationError.emptyInput + let error3 = TextTranslationError.cancelled + + XCTAssertEqual(error1, error2) + XCTAssertNotEqual(error1, error3) + } + + func testTranslationFailedEquality() { + let error1 = TextTranslationError.translationFailed("same message") + let error2 = TextTranslationError.translationFailed("same message") + let error3 = TextTranslationError.translationFailed("different message") + + XCTAssertEqual(error1, error2) + XCTAssertNotEqual(error1, error3) + } + + // MARK: - Sendable Conformance + + func testErrorIsSendable() { + // This test verifies that TextTranslationError conforms to Sendable + // by using it in a way that requires Sendable conformance + let error = TextTranslationError.translationFailed("test") + + // If this compiles, TextTranslationError conforms to Sendable + let sendableClosure: @Sendable () -> TextTranslationError = { + return error + } + + XCTAssertEqual(sendableClosure(), error) + } +} + +// MARK: - TranslationFlowError Tests + +/// Tests for TranslationFlowError enum +final class TranslationFlowErrorTests: XCTestCase { + + // MARK: - Error Descriptions + + func testAnalysisFailureDescription() { + let error = TranslationFlowError.analysisFailure("OCR failed") + + XCTAssertNotNil(error.errorDescription) + XCTAssertTrue(error.errorDescription!.contains("OCR failed")) + } + + func testTranslationFailureDescription() { + let error = TranslationFlowError.translationFailure("Network timeout") + + XCTAssertNotNil(error.errorDescription) + XCTAssertTrue(error.errorDescription!.contains("Network timeout")) + } + + func testRenderingFailureDescription() { + let error = TranslationFlowError.renderingFailure("Image processing error") + + XCTAssertNotNil(error.errorDescription) + XCTAssertTrue(error.errorDescription!.contains("Image processing error")) + } + + func testCancelledDescription() { + let error = TranslationFlowError.cancelled + + XCTAssertNotNil(error.errorDescription) + } + + func testNoTextFoundDescription() { + let error = TranslationFlowError.noTextFound + + XCTAssertNotNil(error.errorDescription) + } + + // MARK: - Recovery Suggestions + + func testAnalysisFailureRecoverySuggestion() { + let error = TranslationFlowError.analysisFailure("test") + + XCTAssertNotNil(error.recoverySuggestion) + } + + func testTranslationFailureRecoverySuggestion() { + let error = TranslationFlowError.translationFailure("test") + + XCTAssertNotNil(error.recoverySuggestion) + } + + func testCancelledRecoverySuggestion() { + let error = TranslationFlowError.cancelled + + // Cancelled errors typically don't have recovery suggestions + XCTAssertNil(error.recoverySuggestion) + } + + // MARK: - Equatable + + func testFlowErrorEquality() { + let error1 = TranslationFlowError.cancelled + let error2 = TranslationFlowError.cancelled + let error3 = TranslationFlowError.noTextFound + + XCTAssertEqual(error1, error2) + XCTAssertNotEqual(error1, error3) + } +} + +// MARK: - TranslationFlowPhase Tests + +/// Tests for TranslationFlowPhase enum +final class TranslationFlowPhaseTests: XCTestCase { + + // MARK: - Processing State + + func testIdleIsNotProcessing() { + let phase = TranslationFlowPhase.idle + XCTAssertFalse(phase.isProcessing) + } + + func testAnalyzingIsProcessing() { + let phase = TranslationFlowPhase.analyzing + XCTAssertTrue(phase.isProcessing) + } + + func testTranslatingIsProcessing() { + let phase = TranslationFlowPhase.translating + XCTAssertTrue(phase.isProcessing) + } + + func testRenderingIsProcessing() { + let phase = TranslationFlowPhase.rendering + XCTAssertTrue(phase.isProcessing) + } + + func testCompletedIsNotProcessing() { + let phase = TranslationFlowPhase.completed + XCTAssertFalse(phase.isProcessing) + } + + func testFailedIsNotProcessing() { + let phase = TranslationFlowPhase.failed(.cancelled) + XCTAssertFalse(phase.isProcessing) + } + + // MARK: - Progress + + func testIdleProgress() { + XCTAssertEqual(TranslationFlowPhase.idle.progress, 0.0) + } + + func testAnalyzingProgress() { + XCTAssertEqual(TranslationFlowPhase.analyzing.progress, 0.25) + } + + func testTranslatingProgress() { + XCTAssertEqual(TranslationFlowPhase.translating.progress, 0.50) + } + + func testRenderingProgress() { + XCTAssertEqual(TranslationFlowPhase.rendering.progress, 0.75) + } + + func testCompletedProgress() { + XCTAssertEqual(TranslationFlowPhase.completed.progress, 1.0) + } + + func testFailedProgress() { + XCTAssertEqual(TranslationFlowPhase.failed(.cancelled).progress, 0.0) + } + + // MARK: - Localized Description + + func testAllPhasesHaveLocalizedDescriptions() { + let phases: [TranslationFlowPhase] = [ + .idle, .analyzing, .translating, .rendering, .completed, + .failed(.cancelled) + ] + + for phase in phases { + XCTAssertNotNil(phase.localizedDescription) + XCTAssertTrue(phase.localizedDescription.count > 0) + } + } + + // MARK: - Equatable + + func testPhaseEquality() { + XCTAssertEqual(TranslationFlowPhase.idle, TranslationFlowPhase.idle) + XCTAssertEqual(TranslationFlowPhase.analyzing, TranslationFlowPhase.analyzing) + XCTAssertNotEqual(TranslationFlowPhase.idle, TranslationFlowPhase.analyzing) + } + + func testFailedPhaseEquality() { + let error1 = TranslationFlowError.cancelled + let error2 = TranslationFlowError.noTextFound + + XCTAssertEqual(TranslationFlowPhase.failed(error1), TranslationFlowPhase.failed(error1)) + XCTAssertNotEqual(TranslationFlowPhase.failed(error1), TranslationFlowPhase.failed(error2)) + } +} diff --git a/ScreenTranslateTests/TranslationConfigurationTests.swift b/ScreenTranslateTests/TranslationConfigurationTests.swift new file mode 100644 index 0000000..78441a3 --- /dev/null +++ b/ScreenTranslateTests/TranslationConfigurationTests.swift @@ -0,0 +1,185 @@ +import XCTest +@testable import ScreenTranslate + +final class TranslationConfigurationTests: XCTestCase { + func testScenePromptTakesPriorityOverEngineAndCompatiblePrompts() { + let config = TranslationPromptConfig( + enginePrompts: [.openai: "Engine prompt {text}"], + compatibleEnginePrompts: ["compatible-0": "Compatible prompt {text}"], + scenePrompts: [.screenshot: "Scene prompt {source_language} -> {target_language}: {text}"] + ) + + let resolved = config.resolvedPrompt( + for: .openai, + scene: .screenshot, + sourceLanguage: "English", + targetLanguage: "Chinese", + text: "Hello", + compatiblePromptID: "compatible-0" + ) + + XCTAssertEqual(resolved, "Scene prompt English -> Chinese: Hello") + } + + func testCompatiblePromptIsUsedForCustomEngineInstances() { + let config = TranslationPromptConfig( + compatibleEnginePrompts: ["compatible-2": "Compatible prompt {text}"] + ) + + let resolved = config.resolvedPrompt( + for: .custom, + scene: .textSelection, + sourceLanguage: "English", + targetLanguage: "Japanese", + text: "Hello", + compatiblePromptID: "compatible-2" + ) + + XCTAssertEqual(resolved, "Compatible prompt Hello") + } + + func testTranslateAndInsertUsesDedicatedDefaultPromptWhenNoCustomPromptExists() { + let config = TranslationPromptConfig() + + XCTAssertEqual( + config.promptPreview(for: .apple, scene: .translateAndInsert), + TranslationPromptConfig.defaultInsertPrompt + ) + } + + func testResetRemovesAllCustomPrompts() { + var config = TranslationPromptConfig( + enginePrompts: [.openai: "Engine"], + compatibleEnginePrompts: ["compatible-1": "Compatible"], + scenePrompts: [.textSelection: "Scene"] + ) + + XCTAssertTrue(config.hasCustomPrompts) + config.reset() + + XCTAssertFalse(config.hasCustomPrompts) + XCTAssertTrue(config.enginePrompts.isEmpty) + XCTAssertTrue(config.compatibleEnginePrompts.isEmpty) + XCTAssertTrue(config.scenePrompts.isEmpty) + } + + func testEngineConfigDefaultsCoverBuiltInAndCloudEngines() { + let apple = TranslationEngineConfig.default(for: .apple) + let openai = TranslationEngineConfig.default(for: .openai) + + XCTAssertTrue(apple.isEnabled) + XCTAssertNil(apple.options) + + XCTAssertFalse(openai.isEnabled) + XCTAssertEqual(openai.options?.baseURL, TranslationEngineType.openai.defaultBaseURL) + XCTAssertEqual(openai.options?.modelName, TranslationEngineType.openai.defaultModelName) + XCTAssertEqual(openai.options?.timeout, 30) + } + + func testSceneBindingsDefaultToAppleWithMTranFallback() { + let binding = SceneEngineBinding.default(for: .textSelection) + + XCTAssertEqual(binding.primaryEngine, .apple) + XCTAssertEqual(binding.fallbackEngine, .mtranServer) + XCTAssertTrue(binding.fallbackEnabled) + + let allDefaults = SceneEngineBinding.allDefaults + XCTAssertEqual(allDefaults.count, TranslationScene.allCases.count) + } + + func testTranslationResultHelpersPreserveAndCombineContent() { + let first = TranslationResult( + sourceText: "Hello", + translatedText: "你好", + sourceLanguage: "English", + targetLanguage: "Chinese", + timestamp: Date(timeIntervalSince1970: 1) + ) + let second = TranslationResult( + sourceText: "World", + translatedText: "世界", + sourceLanguage: "English", + targetLanguage: "Chinese", + timestamp: Date(timeIntervalSince1970: 2) + ) + + let empty = TranslationResult.empty(for: "Same") + XCTAssertFalse(empty.hasChanges) + XCTAssertEqual(empty.sourceText, "Same") + XCTAssertEqual(empty.translatedText, "Same") + + let combined = TranslationResult.combine([first, second]) + XCTAssertEqual(combined?.sourceText, "Hello\nWorld") + XCTAssertEqual(combined?.translatedText, "你好\n世界") + XCTAssertEqual(combined?.sourceLanguage, "English") + XCTAssertEqual(combined?.targetLanguage, "Chinese") + XCTAssertEqual(combined?.timestamp, first.timestamp) + } + + func testTranslationResultBundleDerivesStatusCorrectly() { + let primary = TranslationResult( + sourceText: "Hello", + translatedText: "你好", + sourceLanguage: "English", + targetLanguage: "Chinese" + ) + let secondaryError = TranslationProviderError.timeout + + let bundle = TranslationResultBundle( + results: [ + EngineResult(engine: .apple, segments: [BilingualSegment(from: primary)], latency: 0.5), + EngineResult.failed(engine: .mtranServer, error: secondaryError) + ], + primaryEngine: .apple, + selectionMode: .parallel, + scene: .screenshot, + timestamp: Date(timeIntervalSince1970: 100) + ) + + XCTAssertEqual(bundle.primaryResult.count, 1) + XCTAssertEqual(bundle.successfulEngines, [.apple]) + XCTAssertEqual(bundle.failedEngines, [.mtranServer]) + XCTAssertTrue(bundle.hasErrors) + XCTAssertFalse(bundle.allFailed) + XCTAssertEqual(bundle.averageLatency, 0.5, accuracy: 0.0001) + XCTAssertEqual(bundle.scene, .screenshot) + XCTAssertEqual(bundle.timestamp, Date(timeIntervalSince1970: 100)) + } + + func testFailedBundleMarksAllEnginesFailed() { + let bundle = TranslationResultBundle.failed( + engine: .deepl, + error: TranslationProviderError.connectionFailed("offline"), + selectionMode: .primaryWithFallback, + scene: .translateAndInsert + ) + + XCTAssertTrue(bundle.allFailed) + XCTAssertEqual(bundle.primaryEngine, .deepl) + XCTAssertEqual(bundle.selectionMode, .primaryWithFallback) + XCTAssertEqual(bundle.scene, .translateAndInsert) + } + + func testTranslationProviderErrorExposesRecoveryGuidance() { + let errors: [TranslationProviderError] = [ + .notAvailable, + .connectionFailed("offline"), + .invalidConfiguration("missing key"), + .translationFailed("bad gateway"), + .emptyInput, + .unsupportedLanguage("tlh"), + .timeout, + .rateLimited(retryAfter: 10) + ] + + for error in errors { + guard let description = error.errorDescription, + let suggestion = error.recoverySuggestion else { + return XCTFail("Expected localized description and recovery suggestion") + } + + XCTAssertFalse(description.isEmpty) + XCTAssertFalse(suggestion.isEmpty) + } + } +} diff --git a/ScreenTranslateTests/TranslationPipelineRegressionTests.swift b/ScreenTranslateTests/TranslationPipelineRegressionTests.swift new file mode 100644 index 0000000..23f3353 --- /dev/null +++ b/ScreenTranslateTests/TranslationPipelineRegressionTests.swift @@ -0,0 +1,124 @@ +import CoreGraphics +import XCTest +@testable import ScreenTranslate + +final class TranslationPipelineRegressionTests: XCTestCase { + func testPromptLeakageHeuristicIdentifiesSchemaInstructions() { + let leakage = TextSegment( + text: "- x, y: 左上角 (0.0-1.0) - 宽度, 高度: 箱形尺寸 (0.0-1.0)", + boundingBox: .zero, + confidence: 0.99 + ) + let realUI = TextSegment( + text: "ScreenTranslate", + boundingBox: .zero, + confidence: 0.99 + ) + + XCTAssertTrue(leakage.isLikelyOCRPromptLeakage) + XCTAssertFalse(realUI.isLikelyOCRPromptLeakage) + } + + func testRecoverAnalysisResultFallsBackToOCRWhenVLMOutputIsOnlyPromptLeakage() async throws { + let leakedAnalysis = ScreenAnalysisResult( + segments: [ + TextSegment( + text: "置信度: 0.0-1.0", + boundingBox: .zero, + confidence: 0.99 + ), + TextSegment( + text: "- x, y: 左上角 (0.0-1.0)", + boundingBox: .zero, + confidence: 0.99 + ) + ], + imageSize: CGSize(width: 1200, height: 800) + ) + let ocrFallback = OCRResult( + observations: [ + OCRText( + text: "ScreenTranslate", + boundingBox: CGRect(x: 0.1, y: 0.1, width: 0.3, height: 0.05), + confidence: 0.95 + ), + OCRText( + text: "Superset", + boundingBox: CGRect(x: 0.1, y: 0.2, width: 0.2, height: 0.05), + confidence: 0.94 + ) + ], + imageSize: CGSize(width: 1200, height: 800) + ) + + let recovered = try await TranslationFlowController.recoverAnalysisResultIfNeeded(leakedAnalysis) { + ocrFallback + } + + XCTAssertEqual(recovered.segments.map(\.text), ["ScreenTranslate", "Superset"]) + } + @available(macOS 13.0, *) + func testTranslationEngineSourceLocaleLanguageUsesNilForAutoDetect() { + XCTAssertNil(TranslationEngine.sourceLocaleLanguage(for: nil)) + XCTAssertNil(TranslationEngine.sourceLocaleLanguage(for: .auto)) + XCTAssertEqual( + TranslationEngine.sourceLocaleLanguage(for: .japanese)?.minimalIdentifier, + Locale.Language(identifier: "ja").minimalIdentifier + ) + } + + func testPromptDisplayNameUsesHumanReadableLanguageNames() { + XCTAssertEqual(TranslationLanguage.promptDisplayName(for: nil), "Auto Detect") + XCTAssertEqual(TranslationLanguage.promptDisplayName(for: "auto"), "Auto Detect") + XCTAssertEqual(TranslationLanguage.promptDisplayName(for: "zh-Hans"), "Chinese (Simplified)") + XCTAssertEqual(TranslationLanguage.promptDisplayName(for: "ja"), "Japanese") + } + + func testNoiseHeuristicFiltersCoordinateLikeText() { + let tick = TextSegment(text: "12.5%", boundingBox: .zero, confidence: 0.95) + let sentence = TextSegment(text: "Revenue growth", boundingBox: .zero, confidence: 0.95) + let monthTick = TextSegment( + text: "Jan", + boundingBox: CGRect(x: 0.01, y: 0.94, width: 0.05, height: 0.02), + confidence: 0.99 + ) + + XCTAssertTrue(tick.isLikelyTranslationNoise) + XCTAssertTrue(monthTick.isLikelyTranslationNoise) + XCTAssertFalse(sentence.isLikelyTranslationNoise) + } + + func testFilteredForTranslationRemovesNoiseButKeepsContent() { + let segments = [ + TextSegment(text: "100", boundingBox: .zero, confidence: 0.99), + TextSegment(text: "Q4 Revenue increased significantly", boundingBox: .zero, confidence: 0.99), + TextSegment(text: "25%", boundingBox: .zero, confidence: 0.99) + ] + + let result = ScreenAnalysisResult(segments: segments, imageSize: CGSize(width: 1000, height: 800)) + let filtered = result.filteredForTranslation() + + XCTAssertEqual(filtered.segments.count, 1) + XCTAssertEqual(filtered.segments.first?.text, "Q4 Revenue increased significantly") + } + + func testFilteredForTranslationReturnsEmptyWhenEverySegmentLooksLikeNoise() { + let segments = [ + TextSegment( + text: "Jan", + boundingBox: CGRect(x: 0.01, y: 0.94, width: 0.05, height: 0.02), + confidence: 0.99 + ), + TextSegment( + text: "2024", + boundingBox: CGRect(x: 0.95, y: 0.40, width: 0.03, height: 0.02), + confidence: 0.99 + ) + ] + + let result = ScreenAnalysisResult(segments: segments, imageSize: CGSize(width: 1200, height: 800)) + let filtered = result.filteredForTranslation() + + XCTAssertTrue(filtered.segments.isEmpty) + } +} diff --git a/ScreenTranslateTests/TranslationServicePipelineTests.swift b/ScreenTranslateTests/TranslationServicePipelineTests.swift new file mode 100644 index 0000000..a8fe1ae --- /dev/null +++ b/ScreenTranslateTests/TranslationServicePipelineTests.swift @@ -0,0 +1,716 @@ +import XCTest +@testable import ScreenTranslate + +@available(macOS 13.0, *) +actor MockTranslationProvider: TranslationProvider, TranslationPromptConfigurable, TranslationPromptContextProviding { + struct Request: Sendable, Equatable { + let texts: [String] + let sourceLanguage: String? + let targetLanguage: String + } + + nonisolated let id: String + nonisolated let name: String + + private var available: Bool + private var translateError: Error? + private var batchResults: [TranslationResult] + private var checkConnectionResult: Bool + private var promptContextID: String? + private(set) var requests: [Request] = [] + private(set) var promptTemplates: [String?] = [] + + init( + id: String, + name: String, + available: Bool = true, + batchResults: [TranslationResult] = [], + translateError: Error? = nil, + checkConnectionResult: Bool = true, + promptContextID: String? = nil + ) { + self.id = id + self.name = name + self.available = available + self.batchResults = batchResults + self.translateError = translateError + self.checkConnectionResult = checkConnectionResult + self.promptContextID = promptContextID + } + + var isAvailable: Bool { + get async { available } + } + + func translate( + text: String, + from sourceLanguage: String?, + to targetLanguage: String + ) async throws -> TranslationResult { + let results = try await translate( + texts: [text], + from: sourceLanguage, + to: targetLanguage + ) + guard let result = results.first else { + XCTFail("MockTranslationProvider returned no results for a single-text request") + throw TranslationProviderError.translationFailed("MockTranslationProvider returned no results") + } + return result + } + + func translate( + texts: [String], + from sourceLanguage: String?, + to targetLanguage: String + ) async throws -> [TranslationResult] { + try await translate( + texts: texts, + from: sourceLanguage, + to: targetLanguage, + promptTemplate: nil + ) + } + + func translate( + texts: [String], + from sourceLanguage: String?, + to targetLanguage: String, + promptTemplate: String? + ) async throws -> [TranslationResult] { + requests.append( + Request(texts: texts, sourceLanguage: sourceLanguage, targetLanguage: targetLanguage) + ) + promptTemplates.append(promptTemplate) + + if let translateError { + throw translateError + } + + if batchResults.count == texts.count { + return batchResults + } + + if batchResults.count == 1, let first = batchResults.first { + return texts.map { text in + TranslationResult( + sourceText: text, + translatedText: first.translatedText, + sourceLanguage: first.sourceLanguage, + targetLanguage: first.targetLanguage + ) + } + } + + return texts.map { text in + TranslationResult( + sourceText: text, + translatedText: "\(text) -> \(targetLanguage)", + sourceLanguage: sourceLanguage ?? "Auto", + targetLanguage: targetLanguage + ) + } + } + + func checkConnection() async -> Bool { + checkConnectionResult + } + + func requestCount() async -> Int { + requests.count + } + + func lastPromptTemplate() async -> String? { + promptTemplates.last.flatMap { $0 } + } + + func compatiblePromptIdentifier() async -> String? { + promptContextID + } +} + +@available(macOS 13.0, *) +actor MockTranslationServicing: TranslationServicing { + struct Request: Sendable, Equatable { + let segments: [String] + let targetLanguage: String + let preferredEngine: TranslationEngineType + let sourceLanguage: String? + let scene: TranslationScene? + let mode: EngineSelectionMode + let fallbackEnabled: Bool + let parallelEngines: [TranslationEngineType] + let sceneBindings: [TranslationScene: SceneEngineBinding] + } + + private var nextResult: [BilingualSegment] + private var nextError: Error? + private(set) var requests: [Request] = [] + + init(nextResult: [BilingualSegment] = [], nextError: Error? = nil) { + self.nextResult = nextResult + self.nextError = nextError + } + + func translate( + segments: [String], + to targetLanguage: String, + preferredEngine: TranslationEngineType, + from sourceLanguage: String?, + scene: TranslationScene?, + mode: EngineSelectionMode, + fallbackEnabled: Bool, + parallelEngines: [TranslationEngineType], + sceneBindings: [TranslationScene: SceneEngineBinding] + ) async throws -> [BilingualSegment] { + requests.append( + Request( + segments: segments, + targetLanguage: targetLanguage, + preferredEngine: preferredEngine, + sourceLanguage: sourceLanguage, + scene: scene, + mode: mode, + fallbackEnabled: fallbackEnabled, + parallelEngines: parallelEngines, + sceneBindings: sceneBindings + ) + ) + + if let nextError { + throw nextError + } + + return nextResult + } + + func requestCount() async -> Int { + requests.count + } +} + +@available(macOS 13.0, *) +final class TranslationServicePipelineTests: XCTestCase { + private func makeResult( + source: String, + translated: String, + sourceLanguage: String = "English", + targetLanguage: String = "Chinese" + ) -> TranslationResult { + TranslationResult( + sourceText: source, + translatedText: translated, + sourceLanguage: sourceLanguage, + targetLanguage: targetLanguage + ) + } + + func testPrimaryEngineAppliesCustomPromptAndReturnsBundle() async throws { + let registry = TranslationEngineRegistry(registerBuiltInProviders: false) + let apple = MockTranslationProvider( + id: "apple", + name: "Apple", + batchResults: [ + makeResult(source: "Hello", translated: "你好"), + makeResult(source: "World", translated: "世界") + ] + ) + await registry.register(apple, for: .apple) + + let service = TranslationService(registry: registry) + await service.updatePromptConfig( + TranslationPromptConfig( + enginePrompts: [.apple: "Custom prompt {text}"] + ) + ) + + let bundle = try await service.translate( + segments: ["Hello", "World"], + to: "zh-Hans", + from: "en", + scene: .translateAndInsert, + mode: .primaryWithFallback, + preferredEngine: .apple, + fallbackEnabled: false + ) + + XCTAssertEqual(bundle.primaryEngine, .apple) + XCTAssertEqual(bundle.selectionMode, .primaryWithFallback) + XCTAssertEqual(bundle.primaryResult.map(\.translated), ["你好", "世界"]) + let appleRequests = await apple.requests + let applePromptTemplate = await apple.lastPromptTemplate() + XCTAssertEqual(appleRequests, [ + MockTranslationProvider.Request( + texts: ["Hello", "World"], + sourceLanguage: "en", + targetLanguage: "zh-Hans" + ) + ]) + XCTAssertEqual(applePromptTemplate, "Custom prompt {text}") + } + + func testTranslateAndInsertUsesDefaultInsertPromptWhenNoCustomPromptExists() async throws { + let registry = TranslationEngineRegistry(registerBuiltInProviders: false) + let apple = MockTranslationProvider( + id: "apple", + name: "Apple", + batchResults: [ + makeResult(source: "Translate me", translated: "请翻译我") + ] + ) + await registry.register(apple, for: .apple) + + let service = TranslationService(registry: registry) + + _ = try await service.translate( + segments: ["Translate me"], + to: "zh-Hans", + from: "en", + scene: .translateAndInsert, + mode: .primaryWithFallback, + preferredEngine: .apple, + fallbackEnabled: false + ) + + let promptTemplate = await apple.lastPromptTemplate() + XCTAssertEqual(promptTemplate, TranslationPromptConfig.defaultInsertPrompt) + } + + func testCustomEngineUsesCompatiblePromptForSelectedProviderInstance() async throws { + let registry = TranslationEngineRegistry(registerBuiltInProviders: false) + let custom = MockTranslationProvider( + id: "custom:1", + name: "Custom", + batchResults: [ + makeResult(source: "Translate me", translated: "翻译我") + ], + promptContextID: "compatible-provider-1" + ) + await registry.register(custom, for: .custom) + + let service = TranslationService(registry: registry) + await service.updatePromptConfig( + TranslationPromptConfig( + compatibleEnginePrompts: ["compatible-provider-1": "Compatible prompt {text}"] + ) + ) + + _ = try await service.translate( + segments: ["Translate me"], + to: "zh-Hans", + from: "en", + scene: .translateAndInsert, + mode: .primaryWithFallback, + preferredEngine: .custom, + fallbackEnabled: false + ) + + let promptTemplate = await custom.lastPromptTemplate() + XCTAssertEqual(promptTemplate, "Compatible prompt {text}") + } + + func testPrimaryWithFallbackUsesFallbackWhenPrimaryFails() async throws { + let registry = TranslationEngineRegistry(registerBuiltInProviders: false) + let primary = MockTranslationProvider( + id: "apple", + name: "Apple", + translateError: TranslationProviderError.connectionFailed("primary offline") + ) + let fallback = MockTranslationProvider( + id: "mtran", + name: "MTran", + batchResults: [ + makeResult(source: "Hello", translated: "你好") + ] + ) + + await registry.register(primary, for: .apple) + await registry.register(fallback, for: .mtranServer) + + let service = TranslationService(registry: registry) + + let bundle = try await service.translate( + segments: ["Hello"], + to: "zh-Hans", + from: "en", + mode: .primaryWithFallback, + preferredEngine: .apple, + fallbackEnabled: true + ) + + XCTAssertEqual(bundle.primaryEngine, .mtranServer) + XCTAssertEqual(bundle.successfulEngines, [.mtranServer]) + XCTAssertEqual(bundle.failedEngines, [.apple]) + let primaryRequestCount = await primary.requestCount() + let fallbackRequestCount = await fallback.requestCount() + XCTAssertEqual(primaryRequestCount, 1) + XCTAssertEqual(fallbackRequestCount, 1) + } + + func testUnavailablePrimaryEngineFallsBackWithoutExecutingPrimaryTranslation() async throws { + let registry = TranslationEngineRegistry(registerBuiltInProviders: false) + let primary = MockTranslationProvider( + id: "apple", + name: "Apple", + available: false, + batchResults: [ + makeResult(source: "Hello", translated: "你好") + ] + ) + let fallback = MockTranslationProvider( + id: "mtran", + name: "MTran", + batchResults: [ + makeResult(source: "Hello", translated: "您好") + ] + ) + + await registry.register(primary, for: .apple) + await registry.register(fallback, for: .mtranServer) + + let service = TranslationService(registry: registry) + + let bundle = try await service.translate( + segments: ["Hello"], + to: "zh-Hans", + from: "en", + mode: .primaryWithFallback, + preferredEngine: .apple, + fallbackEnabled: true + ) + + XCTAssertEqual(bundle.primaryEngine, .mtranServer) + XCTAssertEqual(bundle.primaryResult.map(\.translated), ["您好"]) + let primaryRequestCount = await primary.requestCount() + let fallbackRequestCount = await fallback.requestCount() + XCTAssertEqual(primaryRequestCount, 0) + XCTAssertEqual(fallbackRequestCount, 1) + } + + func testRegistryCreatesBuiltInProviderWhenRegistrationWasSkipped() async throws { + let registry = TranslationEngineRegistry(registerBuiltInProviders: false) + + let provider = try await registry.createProvider( + for: .apple, + config: .default(for: .apple) + ) + + XCTAssertTrue(provider is AppleTranslationProvider) + let registeredProvider = await registry.provider(for: .apple) + XCTAssertNotNil(registeredProvider) + } + + func testRegistryCreatesLLMProvidersThatArePromptConfigurable() async throws { + let registry = TranslationEngineRegistry(registerBuiltInProviders: false) + let provider = try await registry.createProvider( + for: .ollama, + config: TranslationEngineConfig( + id: .ollama, + isEnabled: true, + options: EngineOptions( + baseURL: "http://127.0.0.1:11434", + modelName: "llama3", + timeout: 30 + ) + ) + ) + + XCTAssertNotNil(provider as? any TranslationPromptConfigurable) + } + + func testParallelModeCapturesSuccessAndFailurePerEngine() async throws { + let registry = TranslationEngineRegistry(registerBuiltInProviders: false) + let apple = MockTranslationProvider( + id: "apple", + name: "Apple", + batchResults: [ + makeResult(source: "Hello", translated: "你好") + ] + ) + let mtran = MockTranslationProvider( + id: "mtran", + name: "MTran", + translateError: TranslationProviderError.timeout + ) + + await registry.register(apple, for: .apple) + await registry.register(mtran, for: .mtranServer) + + let service = TranslationService(registry: registry) + + let bundle = try await service.translate( + segments: ["Hello"], + to: "zh-Hans", + from: "en", + mode: .parallel, + preferredEngine: .apple, + parallelEngines: [.apple, .mtranServer] + ) + + XCTAssertEqual(bundle.selectionMode, .parallel) + XCTAssertEqual(bundle.result(for: .apple)?.segments.map(\.translated), ["你好"]) + XCTAssertNil(bundle.result(for: .mtranServer)?.segments.first) + XCTAssertNotNil(bundle.result(for: .mtranServer)?.error) + XCTAssertTrue(bundle.hasErrors) + } + + func testQuickSwitchUsesPrimaryEngineWithoutFallback() async throws { + let registry = TranslationEngineRegistry(registerBuiltInProviders: false) + let apple = MockTranslationProvider( + id: "apple", + name: "Apple", + batchResults: [ + makeResult(source: "Hello", translated: "你好") + ] + ) + let mtran = MockTranslationProvider( + id: "mtran", + name: "MTran", + batchResults: [ + makeResult(source: "Hello", translated: "您好") + ] + ) + + await registry.register(apple, for: .apple) + await registry.register(mtran, for: .mtranServer) + + let service = TranslationService(registry: registry) + + let bundle = try await service.translate( + segments: ["Hello"], + to: "zh-Hans", + from: "en", + mode: .quickSwitch, + preferredEngine: .apple + ) + + XCTAssertEqual(bundle.selectionMode, .quickSwitch) + XCTAssertEqual(bundle.primaryEngine, .apple) + XCTAssertEqual(bundle.primaryResult.map(\.translated), ["你好"]) + let appleRequestCount = await apple.requestCount() + let mtranRequestCount = await mtran.requestCount() + XCTAssertEqual(appleRequestCount, 1) + XCTAssertEqual(mtranRequestCount, 0) + } + + func testSceneBindingHonorsSceneSpecificPrimaryEngine() async throws { + let registry = TranslationEngineRegistry(registerBuiltInProviders: false) + let apple = MockTranslationProvider( + id: "apple", + name: "Apple", + batchResults: [ + makeResult(source: "Hello", translated: "你好") + ] + ) + let mtran = MockTranslationProvider( + id: "mtran", + name: "MTran", + batchResults: [ + makeResult(source: "Hello", translated: "您好") + ] + ) + + await registry.register(apple, for: .apple) + await registry.register(mtran, for: .mtranServer) + + let service = TranslationService(registry: registry) + + let bundle = try await service.translate( + segments: ["Hello"], + to: "zh-Hans", + from: "en", + scene: .screenshot, + mode: .sceneBinding, + preferredEngine: .apple, + sceneBindings: [ + .screenshot: SceneEngineBinding( + scene: .screenshot, + primaryEngine: .mtranServer, + fallbackEngine: nil, + fallbackEnabled: false + ) + ] + ) + + XCTAssertEqual(bundle.primaryEngine, .mtranServer) + XCTAssertEqual(bundle.primaryResult.map(\.translated), ["您好"]) + let appleRequestCount = await apple.requestCount() + let mtranRequestCount = await mtran.requestCount() + XCTAssertEqual(appleRequestCount, 0) + XCTAssertEqual(mtranRequestCount, 1) + } + + func testAllEnginesFailThrowsMultiEngineError() async { + let registry = TranslationEngineRegistry(registerBuiltInProviders: false) + let apple = MockTranslationProvider( + id: "apple", + name: "Apple", + translateError: TranslationProviderError.connectionFailed("primary offline") + ) + let mtran = MockTranslationProvider( + id: "mtran", + name: "MTran", + translateError: TranslationProviderError.timeout + ) + + await registry.register(apple, for: .apple) + await registry.register(mtran, for: .mtranServer) + + let service = TranslationService(registry: registry) + + do { + _ = try await service.translate( + segments: ["Hello"], + to: "zh-Hans", + from: "en", + mode: .primaryWithFallback, + preferredEngine: .apple, + fallbackEnabled: true + ) + XCTFail("Expected translation failure") + } catch let error as MultiEngineError { + switch error { + case .allEnginesFailed(let errors): + XCTAssertEqual(errors.count, 2) + default: + XCTFail("Unexpected multi-engine error: \(error)") + } + } catch { + XCTFail("Unexpected error type: \(error)") + } + } + + func testEmptyInputReturnsEmptyBundleWithoutQueryingProviders() async throws { + let registry = TranslationEngineRegistry(registerBuiltInProviders: false) + let apple = MockTranslationProvider(id: "apple", name: "Apple") + await registry.register(apple, for: .apple) + + let service = TranslationService(registry: registry) + + let bundle = try await service.translate( + segments: [], + to: "zh-Hans", + from: "en", + mode: .primaryWithFallback, + preferredEngine: .apple + ) + + XCTAssertTrue(bundle.results.isEmpty) + let appleRequestCount = await apple.requestCount() + XCTAssertEqual(appleRequestCount, 0) + } + + func testTextTranslationFlowUpdatesStateAndResultOnSuccess() async throws { + let service = MockTranslationServicing( + nextResult: [ + BilingualSegment( + original: TextSegment(text: "Hello", boundingBox: .zero, confidence: 1.0), + translated: "你好", + sourceLanguage: "English", + targetLanguage: "Chinese" + ) + ] + ) + let flow = TextTranslationFlow(service: service) + + let result = try await flow.translate( + "Hello", + config: TextTranslationConfig( + targetLanguage: "zh-Hans", + sourceLanguage: "en", + preferredEngine: .apple, + scene: .translateAndInsert, + mode: .parallel, + fallbackEnabled: false, + parallelEngines: [.apple, .mtranServer], + sceneBindings: [.translateAndInsert: SceneEngineBinding( + scene: .translateAndInsert, + primaryEngine: .custom, + fallbackEngine: .ollama, + fallbackEnabled: true + )] + ) + ) + + XCTAssertEqual(result.translatedText, "你好") + XCTAssertEqual(result.targetLanguage, "Chinese") + let currentPhase = await flow.currentPhase + let lastError = await flow.lastError + let lastResult = await flow.lastResult + let serviceRequests = await service.requests + let serviceRequestCount = await service.requestCount() + XCTAssertEqual(currentPhase, .completed) + XCTAssertNil(lastError) + XCTAssertEqual(lastResult?.translatedText, "你好") + XCTAssertEqual(serviceRequests, [ + MockTranslationServicing.Request( + segments: ["Hello"], + targetLanguage: "zh-Hans", + preferredEngine: .apple, + sourceLanguage: "en", + scene: .translateAndInsert, + mode: .parallel, + fallbackEnabled: false, + parallelEngines: [.apple, .mtranServer], + sceneBindings: [.translateAndInsert: SceneEngineBinding( + scene: .translateAndInsert, + primaryEngine: .custom, + fallbackEngine: .ollama, + fallbackEnabled: true + )] + ) + ]) + XCTAssertEqual(serviceRequestCount, 1) + } + + func testTextTranslationFlowMapsServiceFailureToUserFacingErrorState() async { + let service = MockTranslationServicing( + nextError: TranslationProviderError.connectionFailed("offline") + ) + let flow = TextTranslationFlow(service: service) + + do { + _ = try await flow.translate( + "Hello", + config: TextTranslationConfig( + targetLanguage: "zh-Hans", + sourceLanguage: "en", + preferredEngine: .apple, + scene: .textSelection, + mode: .primaryWithFallback, + fallbackEnabled: true, + parallelEngines: [], + sceneBindings: [:] + ) + ) + XCTFail("Expected flow to fail") + } catch let error as TextTranslationError { + switch error { + case .translationFailed(let message): + XCTAssertTrue(message.contains("offline")) + default: + XCTFail("Unexpected text translation error: \(error)") + } + } catch { + XCTFail("Unexpected error type: \(error)") + } + + let currentPhase = await flow.currentPhase + let lastError = await flow.lastError + let serviceRequestCount = await service.requestCount() + + if case .failed(let failedError) = currentPhase, + case .translationFailed(let message) = failedError { + XCTAssertTrue(message.contains("offline")) + } else { + XCTFail("Expected failed phase with translationFailed error") + } + + if case .translationFailed(let message)? = lastError { + XCTAssertTrue(message.contains("offline")) + } else { + XCTFail("Expected translationFailed error") + } + XCTAssertEqual(serviceRequestCount, 1) + } +} diff --git a/docs/README.md b/docs/README.md index 6ada9ae..806445b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,15 +1,18 @@ -# ScreenCapture Documentation +# ScreenTranslate Documentation -Welcome to the ScreenCapture documentation. ScreenCapture is a macOS menu bar application for capturing and annotating screenshots. +Welcome to the ScreenTranslate documentation. ScreenTranslate is a macOS menu bar application for capturing, annotating, and translating screenshots. ## Overview -ScreenCapture provides: +ScreenTranslate provides: - **Full-screen capture** - Capture entire displays with a single hotkey - **Region selection** - Draw a selection rectangle to capture specific areas +- **Translation mode** - OCR and translate captured text instantly +- **Text selection translation** - Select text anywhere and translate - **Annotation tools** - Add rectangles, arrows, freehand drawings, and text - **Quick export** - Save to disk or copy to clipboard with keyboard shortcuts - **Multi-monitor support** - Works seamlessly with multiple connected displays +- **Multi-engine translation** - Support for Apple Translation, LLM APIs, and self-hosted engines ## Documentation Index @@ -27,6 +30,11 @@ ScreenCapture provides: - macOS 13.0 (Ventura) or later - Screen Recording permission (prompted on first launch) +- Accessibility permission (for text translation features) + +### Installation + +Download the latest DMG from the [Releases](https://github.com/hubo1989/ScreenTranslate/releases) page. ### Default Keyboard Shortcuts @@ -34,6 +42,9 @@ ScreenCapture provides: |----------|--------| | `Cmd+Shift+3` | Capture full screen | | `Cmd+Shift+4` | Capture selection | +| `Cmd+Shift+T` | Translation mode | +| `Cmd+Shift+Y` | Text selection translation | +| `Cmd+Shift+I` | Translate and insert | | `Escape` | Cancel/dismiss | | `Cmd+S` / `Enter` | Save screenshot | | `Cmd+C` | Copy to clipboard | @@ -50,38 +61,34 @@ ScreenCapture provides: ## Technology Stack -- **Swift 6.2.3** with strict concurrency checking +- **Swift 6.0** with strict concurrency checking - **SwiftUI** for modern UI components - **AppKit** for menu bar integration and native windows - **ScreenCaptureKit** for system-level screenshot capture +- **Vision** for Apple native OCR +- **Translation** for Apple system translation - **CoreGraphics** for image manipulation ## Project Structure ``` -ScreenCapture/ +ScreenTranslate/ ├── App/ # Application entry point ├── Features/ # Feature modules │ ├── Capture/ # Screenshot capture logic │ ├── Preview/ # Post-capture editing │ ├── Annotations/ # Drawing tools -│ ├── MenuBar/ # Status bar integration -│ └── Settings/ # Preferences UI +│ ├── TextTranslation/ # Text selection translation +│ ├── Settings/ # Preferences UI +│ └── MenuBar/ # Status bar integration ├── Services/ # Reusable services +│ ├── OCREngine/ # OCR providers +│ └── Translation/ # Translation providers ├── Models/ # Data types ├── Extensions/ # Swift extensions -├── Errors/ # Error types └── Resources/ # Assets ``` ## License This project is licensed under the **MIT License** - see the [LICENSE](../LICENSE) file for details. - -This means you are free to: -- Use the software for any purpose -- Modify the source code -- Distribute copies -- Include in commercial products - -The only requirement is to include the original copyright notice and license text. diff --git a/docs/plans/2026-02-25-architecture-refactoring-design.md b/docs/plans/2026-02-25-architecture-refactoring-design.md new file mode 100644 index 0000000..289028a --- /dev/null +++ b/docs/plans/2026-02-25-architecture-refactoring-design.md @@ -0,0 +1,358 @@ +# ScreenTranslate 架构重构设计 + +## 概述 + +本文档记录 ScreenTranslate 项目的架构重构计划,基于 2026-02-25 的架构审查结果。 + +## 设计决策 + +| 决策点 | 选择 | 理由 | +|--------|------|------| +| AppDelegate 拆分策略 | 按功能拆分多个 Manager | 边界清晰,适合 Agent 编码,增量重构 | +| 依赖注入策略 | 保持单例,测试时用协议 Mock | 改动最小,风险可控 | +| 拆分粒度 | 粗粒度(3 个 Coordinator) | 边界最清晰,改动范围可控 | + +## 实施阶段 + +### 第一阶段:快速修复(任务 1-3) + +#### 1. 修复 `showLoadingIndicator` 硬编码 scaleFactor + +**问题**:`AppDelegate.swift:559` 硬编码 `scaleFactor: 2.0`,在非 Retina 或混合显示器上可能显示异常。 + +**方案**:从当前主屏幕动态获取 `backingScaleFactor`: +```swift +private func showLoadingIndicator() async { + let scaleFactor = NSScreen.main?.backingScaleFactor ?? 2.0 + // ... +} +``` + +#### 2. 清理未使用代码 + +| 文件 | 内容 | 原因 | +|------|------|------| +| `AppDelegate.swift:679-688` | `showEmptyClipboardNotification()` | 未被调用 | +| `AppDelegate.swift:691-703` | `showSuccessNotification()` | 未被调用(translate-and-insert 改为静默操作) | + +#### 3. 提取重复代码为公共方法 + +**3.1 AppDelegate 权限检查逻辑** +```swift +// 提取为私有方法 +private func ensureAccessibilityPermission() async -> Bool { + let permissionManager = PermissionManager.shared + permissionManager.refreshPermissionStatus() + + if !permissionManager.hasAccessibilityPermission { + let granted = await withCheckedContinuation { continuation in + Task { @MainActor in + let result = permissionManager.requestAccessibilityPermission() + continuation.resume(returning: result) + } + } + if !granted { + await MainActor.run { + permissionManager.showPermissionDeniedError(for: .accessibility) + } + return false + } + } + return true +} +``` + +**3.2 SettingsViewModel 快捷键冲突检查** +```swift +// 提取为通用方法 +private func checkShortcutConflict(_ shortcut: KeyboardShortcut, excluding: KeyboardShortcut?) -> Bool { + let allShortcuts = [ + fullScreenShortcut, selectionShortcut, translationModeShortcut, + textSelectionTranslationShortcut, translateAndInsertShortcut + ].filter { $0 != excluding } + return allShortcuts.contains(shortcut) +} +``` + +### 第二阶段:重构改进(任务 4-6) + +#### 4. 重构 SettingsViewModel 快捷键录制逻辑 + +**当前问题**:5 个独立布尔变量 + 5 个几乎相同的录制方法 + +**方案**:使用枚举 + 单一状态变量 +```swift +enum ShortcutRecordingType { + case fullScreen, selection, translationMode + case textSelectionTranslation, translateAndInsert + + var shortcut: KeyboardShortcut { + switch self { + case .fullScreen: return fullScreenShortcut + case .selection: return selectionShortcut + case .translationMode: return translationModeShortcut + case .textSelectionTranslation: return textSelectionTranslationShortcut + case .translateAndInsert: return translateAndInsertShortcut + } + } +} + +var recordingType: ShortcutRecordingType? + +func startRecording(_ type: ShortcutRecordingType) { + recordingType = type + recordedShortcut = nil +} +``` + +#### 5. 优化 PermissionManager 轮询机制 + +**当前问题**:每 2 秒轮询权限状态,浪费资源 + +**方案**:改用按需检查 + 应用激活时检查 +```swift +// 移除定时器,改用通知监听 +private func setupNotificationObservers() { + NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshIfNeeded() + } +} + +private var lastCheckTime: Date = .distantPast + +func refreshIfNeeded() { + // 限流:最多每 5 秒检查一次 + if Date().timeIntervalSince(lastCheckTime) < 5 { return } + refreshPermissionStatus() + lastCheckTime = Date() +} +``` + +#### 6. 增强 TextInsertService 国际化支持 + +**当前问题**:`keyCodeForCharacter` 只支持美式键盘布局 + +**方案**:使用 `TISInputSource` API 获取当前键盘布局映射 +```swift +import Carbon + +private func getCurrentKeyboardLayoutMapping() -> [Character: CGKeyCode]? { + guard let inputSource = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(), + let layoutData = TISGetInputSourceProperty(inputSource, kTISPropertyUnicodeKeyLayoutData) else { + return nil + } + + let layoutPtr = UnsafePointer(OpaquePointer(layoutData)) + // 构建字符到键码的映射表 + // ... +} +``` + +### 第三阶段:架构重构(任务 7-8) + +#### 7. 拆分 AppDelegate 为 3 个 Coordinator + +**新增文件:** + +##### CaptureCoordinator.swift +```swift +/// 协调截图相关功能:全屏截图、区域截图、翻译模式截图 +@MainActor +final class CaptureCoordinator { + weak var appDelegate: AppDelegate? + + private var isCaptureInProgress = false + private let displaySelector = DisplaySelector() + + init(appDelegate: AppDelegate) { + self.appDelegate = appDelegate + } + + // 从 AppDelegate 迁移的方法 + func captureFullScreen() { ... } + func captureSelection() { ... } + func startTranslationMode() { ... } + + private func handleSelectionComplete(rect: CGRect, display: DisplayInfo) async { ... } + private func handleTranslationSelection(rect: CGRect, display: DisplayInfo) async { ... } +} +``` + +##### TextTranslationCoordinator.swift +```swift +/// 协调文本翻译功能:文本选择翻译、翻译并插入 +@MainActor +final class TextTranslationCoordinator { + weak var appDelegate: AppDelegate? + + private var isTranslating = false + + init(appDelegate: AppDelegate) { + self.appDelegate = appDelegate + } + + // 从 AppDelegate 迁移的方法 + func translateSelectedText() { ... } + func translateClipboardAndInsert() { ... } + + private func handleTextSelectionTranslation() async { ... } + private func handleTranslateClipboardAndInsert() async { ... } + private func ensureAccessibilityPermission() async -> Bool { ... } +} +``` + +##### HotkeyCoordinator.swift +```swift +/// 协调热键管理:注册、注销、更新 +@MainActor +final class HotkeyCoordinator { + weak var appDelegate: AppDelegate? + + private var registrations: [HotkeyType: HotkeyManager.Registration] = [:] + + enum HotkeyType { + case fullScreen, selection, translationMode + case textSelectionTranslation, translateAndInsert + } + + init(appDelegate: AppDelegate) { + self.appDelegate = appDelegate + } + + func registerAllHotkeys() async { ... } + func unregisterAllHotkeys() async { ... } + func updateHotkeys() { ... } +} +``` + +**AppDelegate 精简后保留:** +- 应用生命周期管理(`applicationDidFinishLaunching`, `applicationWillTerminate`) +- MenuBarController 初始化 +- Onboarding 流程 +- Coordinator 实例持有和协调 + +#### 8. 引入协议抽象(为测试准备) + +```swift +// TranslationServicing.swift +protocol TranslationServicing { + func translate( + segments: [String], + to targetLanguage: String, + preferredEngine: TranslationEngineType, + from sourceLanguage: String? + ) async throws -> [BilingualSegment] +} + +// TextSelectionServicing.swift +protocol TextSelectionServicing { + func captureSelectedText() async throws -> TextSelectionResult + var canCapture: Bool { get } +} + +// TextInsertServicing.swift +protocol TextInsertServicing { + func insertText(_ text: String) async throws + func deleteSelectionAndInsert(_ text: String) async throws + var canInsert: Bool { get } +} + +// 现有实现扩展 +extension TranslationService: TranslationServicing {} +extension TextSelectionService: TextSelectionServicing {} +extension TextInsertService: TextInsertServicing {} +``` + +### 第四阶段:收尾和测试(任务 9-10) + +#### 9. 改进代码文档 + +- 为新增的 Coordinator 添加文档注释 +- 为公共 API 添加 `///` 文档 +- 更新 README 中的架构说明(如有) + +#### 10. 添加单元测试 + +**测试覆盖优先级:** + +1. **TextTranslationFlow** - 核心翻译流程 + - 测试空输入处理 + - 测试翻译成功流程 + - 测试取消操作 + - 测试错误处理 + +2. **TextInsertService** - 文本插入逻辑 + - 测试权限检查 + - 测试 Unicode 文本插入 + - 测试 Delete + Insert 组合 + +3. **TranslationService** - 翻译服务编排 + - 测试 Provider 选择逻辑 + - 测试 Fallback 机制 + +4. **KeyboardShortcut** - 快捷键解析和验证 + - 测试键码转换 + - 测试修饰符转换 + - 测试验证逻辑 + +5. **SettingsViewModel** - 快捷键录制逻辑 + - 测试冲突检测 + - 测试录制状态管理 + +## 预估改动范围 + +| 阶段 | 新增文件 | 修改文件 | 代码行变化 | +|------|----------|----------|------------| +| 第一阶段 | 0 | 2 | +50 -80 | +| 第二阶段 | 0 | 2 | +100 -150 | +| 第三阶段 | 3 | 2 | +400 -300 | +| 第四阶段 | 5+ | 3 | +600 -50 | + +## 文件结构变化 + +``` +ScreenTranslate/ +├── App/ +│ ├── AppDelegate.swift # 精简后 +│ ├── ScreenTranslateApp.swift +│ └── Coordinators/ # 新增 +│ ├── CaptureCoordinator.swift +│ ├── TextTranslationCoordinator.swift +│ └── HotkeyCoordinator.swift +├── Services/ +│ ├── Protocols/ # 新增 +│ │ ├── TranslationServicing.swift +│ │ ├── TextSelectionServicing.swift +│ │ └── TextInsertServicing.swift +│ └── ... +└── Tests/ # 新增 + ├── TextTranslationFlowTests.swift + ├── TextInsertServiceTests.swift + ├── TranslationServiceTests.swift + ├── KeyboardShortcutTests.swift + └── SettingsViewModelTests.swift +``` + +## 风险评估 + +| 风险 | 可能性 | 影响 | 缓解措施 | +|------|--------|------|----------| +| 重构引入回归 bug | 中 | 高 | 每阶段完成后手动测试核心功能 | +| TextInsertService 国际化改动影响现有功能 | 中 | 中 | 保留原美式布局作为 fallback | +| 测试覆盖不足 | 低 | 中 | 优先覆盖核心业务逻辑 | + +## 验收标准 + +- [ ] 所有阶段完成,无编译错误 +- [ ] 核心功能手动测试通过: + - [ ] 全屏截图 + - [ ] 区域截图 + - [ ] 翻译模式 + - [ ] 文本选择翻译 + - [ ] 翻译并插入 +- [ ] 单元测试覆盖率 >= 60%(核心模块) +- [ ] 代码通过 SwiftLint 检查 diff --git a/docs/plans/2026-02-26-multi-translation-engine-design.md b/docs/plans/2026-02-26-multi-translation-engine-design.md new file mode 100644 index 0000000..450aa65 --- /dev/null +++ b/docs/plans/2026-02-26-multi-translation-engine-design.md @@ -0,0 +1,492 @@ +# 多翻译引擎支持功能设计文档 + +**日期**: 2026-02-26 +**版本**: 1.0 +**状态**: 已批准 + +## 概述 + +为 ScreenTranslate 增加多翻译引擎支持,允许用户: +- 选择多种翻译模式(主备、并行、即时切换、场景绑定) +- 使用多个翻译引擎(云服务商、LLM、自定义接口) +- 为 AI 翻译引擎自定义提示词(分引擎、分场景) + +## 需求总结 + +### 引擎选择模式 +1. **主备模式** - 选择主引擎,失败自动切换备用引擎 +2. **并行模式** - 同时调用多个引擎,展示所有结果 +3. **即时切换** - 在结果窗口快速切换引擎对比 +4. **场景绑定** - 不同场景使用不同默认引擎 + +### 支持的翻译引擎 +- Apple Translation(现有) +- MTranServer(现有) +- LLM 翻译:OpenAI / Claude / Ollama +- 云服务商:Google Translate / DeepL / 百度翻译 +- 自定义 OpenAI 兼容接口 + +### 提示词自定义 +- 分引擎提示词 +- 分场景提示词 +- 支持模板变量:`{source_language}`, `{target_language}`, `{text}` + +### 密钥管理 +- 使用 macOS Keychain 安全存储 API 密钥 + +--- + +## 架构设计 + +### 1. 引擎类型层次 + +```swift +enum TranslationEngineType: String, CaseIterable, Sendable, Codable { + // 现有 + case apple = "apple" + case mtranServer = "mtran" + + // LLM 翻译 + case openai = "openai" + case claude = "claude" + case ollama = "ollama" + + // 云服务商 + case google = "google" + case deepl = "deepl" + case baidu = "baidu" + + // 自定义 + case custom = "custom" +} +``` + +### 2. 核心协议(保持现有) + +```swift +protocol TranslationProvider: Sendable { + var id: String { get } + var name: String { get } + var isAvailable: Bool { get async } + + func translate(text: String, from: String?, to: String) async throws -> TranslationResult + func translate(texts: [String], from: String?, to: String) async throws -> [TranslationResult] + func checkConnection() async -> Bool +} +``` + +### 3. 引擎注册表 + +```swift +actor TranslationEngineRegistry { + static let shared = TranslationEngineRegistry() + + private var providers: [TranslationEngineType: any TranslationProvider] = [:] + + func register(_ provider: any TranslationProvider, for type: TranslationEngineType) + func provider(for type: TranslationEngineType) -> (any TranslationProvider)? + func availableEngines() -> [TranslationEngineType] +} +``` + +### 4. 选择模式枚举 + +```swift +enum EngineSelectionMode: String, Codable, CaseIterable { + case primaryWithFallback // 主备模式 + case parallel // 并行模式 + case quickSwitch // 即时切换 + case sceneBinding // 场景绑定 +} +``` + +--- + +## 配置模型 + +### 引擎配置 + +```swift +struct TranslationEngineConfig: Codable, Identifiable { + let id: TranslationEngineType + var isEnabled: Bool + var credentials: EngineCredentials? + var options: EngineOptions? +} + +struct EngineCredentials: Codable { + var keychainRef: String // Keychain 条目的 service 标识 +} + +struct EngineOptions: Codable { + var baseURL: String? + var modelName: String? + var timeout: TimeInterval? +} +``` + +### 提示词配置 + +```swift +struct TranslationPromptConfig: Codable { + var enginePrompts: [TranslationEngineType: String] // 分引擎 + var scenePrompts: [TranslationScene: String] // 分场景 + + static let defaultPrompt = """ + Translate the following text from {source_language} to {target_language}. \ + Only output the translation, no explanations. + + Text: {text} + """ +} + +enum TranslationScene: String, Codable, CaseIterable { + case screenshot // 截图翻译 + case textSelection // 文本选择翻译 + case translateAndInsert // 翻译并插入 +} +``` + +### 场景绑定配置 + +```swift +struct SceneEngineBinding: Codable { + var scene: TranslationScene + var primaryEngine: TranslationEngineType + var fallbackEngine: TranslationEngineType? + var fallbackEnabled: Bool +} +``` + +### AppSettings 扩展 + +```swift +extension AppSettings { + var engineSelectionMode: EngineSelectionMode + var engineConfigs: [TranslationEngineType: TranslationEngineConfig] + var promptConfig: TranslationPromptConfig + var sceneBindings: [TranslationScene: SceneEngineBinding] + var parallelEngines: [TranslationEngineType] +} +``` + +--- + +## Keychain 密钥管理 + +### Keychain 服务 + +```swift +actor KeychainService { + static let shared = KeychainService() + + private let service = "com.screentranslate.credentials" + + func saveCredentials( + apiKey: String, + for engine: TranslationEngineType, + additionalData: [String: String]? = nil + ) throws + + func getCredentials(for engine: TranslationEngineType) throws -> StoredCredentials? + func deleteCredentials(for engine: TranslationEngineType) throws + func hasCredentials(for engine: TranslationEngineType) -> Bool +} + +struct StoredCredentials: Codable { + let apiKey: String + let appID: String? // 百度翻译需要 + let additional: [String: String]? +} +``` + +### 引擎凭据需求 + +| 引擎 | 必需凭据 | 可选配置 | +|------|---------|---------| +| Apple | 无 | - | +| MTranServer | 无 | host, port | +| OpenAI | API Key | baseURL, model | +| Claude | API Key | baseURL, model | +| Ollama | 无 | baseURL, model | +| Compatible | API Key | baseURL, model | +| Google | API Key | - | +| DeepL | API Key | - | +| 百度 | API Key + App ID | - | + +--- + +## 引擎实现 + +### LLM 翻译引擎 + +复用现有 `VLMProvider` 架构,仅替换提示词: + +```swift +actor LLMTranslationProvider: TranslationProvider { + let id: TranslationEngineType + let vlmProvider: any VLMProvider + let promptConfig: TranslationPromptConfig + + func translate(text: String, from: String?, to: String) async throws -> TranslationResult { + let prompt = promptConfig.resolvedPrompt( + for: id, + scene: currentScene, + sourceLanguage: from ?? "auto", + targetLanguage: to, + text: text + ) + return try await vlmProvider.complete(prompt: prompt) + } +} +``` + +### 云服务商引擎 + +```swift +// Google Translate +actor GoogleTranslationProvider: TranslationProvider { + // POST https://translation.googleapis.com/language/translate/v2 +} + +// DeepL +actor DeepLTranslationProvider: TranslationProvider { + // POST https://api-free.deepl.com/v2/translate (免费) + // POST https://api.deepl.com/v2/translate (专业) +} + +// 百度翻译(需要签名) +actor BaiduTranslationProvider: TranslationProvider { + // 签名: md5(appid+q+salt+密钥) + // GET https://fanyi-api.baidu.com/api/trans/vip/translate +} + +// OpenAI 兼容接口 +actor CompatibleTranslationProvider: TranslationProvider { + // 复用 OpenAI Chat Completions API 格式 +} +``` + +--- + +## TranslationService 重构 + +### 统一结果类型 + +```swift +struct TranslationResultBundle { + let results: [EngineResult] + let primaryEngine: TranslationEngineType + + var primaryResult: [BilingualSegment] { + results.first { $0.engine == primaryEngine }?.segments ?? [] + } +} + +struct EngineResult { + let engine: TranslationEngineType + let segments: [BilingualSegment] + let latency: TimeInterval + let error: Error? +} +``` + +### 服务重构 + +```swift +actor TranslationService { + static let shared = TranslationService() + + private let registry: TranslationEngineRegistry + private let settings: AppSettings + + func translate( + segments: [String], + to targetLanguage: String, + from sourceLanguage: String?, + scene: TranslationScene + ) async throws -> TranslationResultBundle { + let mode = settings.engineSelectionMode + + switch mode { + case .primaryWithFallback: + return try await translateWithFallback(...) + case .parallel: + return try await translateParallel(...) + case .quickSwitch: + return try await translateForQuickSwitch(...) + case .sceneBinding: + return try await translateByScene(...) + } + } +} +``` + +### 四种模式实现 + +```swift +extension TranslationService { + // 1. 主备模式 + private func translateWithFallback(...) async throws -> TranslationResultBundle { + let binding = getSceneBinding(for: scene) + // 先尝试主引擎,失败则切换备用 + } + + // 2. 并行模式 + private func translateParallel(...) async throws -> TranslationResultBundle { + let engines = settings.parallelEngines + // 并发调用所有引擎,收集所有结果 + } + + // 3. 即时切换模式 + private func translateForQuickSwitch(...) async throws -> TranslationResultBundle { + // 先返回主引擎结果,UI 可触发其他引擎的懒加载 + } + + // 4. 场景绑定模式 + private func translateByScene(...) async throws -> TranslationResultBundle { + let binding = settings.sceneBindings[scene] ?? defaultBinding + return try await translateWithFallback(..., binding: binding) + } +} +``` + +--- + +## 设置界面 + +### 引擎设置 Tab + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 翻译引擎设置 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─ 引擎选择模式 ─────────────────────────────────────────────┐ │ +│ │ ○ 主备模式(失败自动切换) │ │ +│ │ ○ 并行模式(同时调用多引擎) │ │ +│ │ ○ 即时切换(快速对比结果) │ │ +│ │ ○ 场景绑定(不同场景用不同引擎) │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 可用引擎 ───────────────────────────────────────────────────┐ │ +│ │ ☑ Apple Translation [已启用 ✓] │ │ +│ │ ☑ OpenAI GPT [配置 API Key...] │ │ +│ │ ☑ Claude [配置 API Key...] │ │ +│ │ ☐ Google Translate [配置...] │ │ +│ │ ☐ DeepL [配置...] │ │ +│ │ ☐ 百度翻译 [配置...] │ │ +│ │ ☐ Ollama (本地) [配置端点...] │ │ +│ │ ☐ 自定义接口 [配置...] │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ [根据模式显示的动态配置区] ─────────────────────────────────┐ │ +│ │ 并行模式: 勾选要并行调用的引擎 │ │ +│ │ 场景绑定: 为每个场景选择主/备引擎 │ │ +│ │ 主备模式: 选择主引擎和备引擎 │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 提示词设置界面 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 翻译提示词设置 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─ 分引擎提示词 ───────────────────────────────────────────────┐ │ +│ │ OpenAI: [使用默认 ▼] [编辑] │ │ +│ │ Claude: [使用默认 ▼] [编辑] │ │ +│ │ Ollama: [使用默认 ▼] [编辑] │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 分场景提示词 ───────────────────────────────────────────────┐ │ +│ │ 截图翻译: [使用默认 ▼] [编辑] │ │ +│ │ 文本选择翻译: [使用默认 ▼] [编辑] │ │ +│ │ 翻译并插入: [使用默认 ▼] [编辑] │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 提示词编辑器 ───────────────────────────────────────────────┐ │ +│ │ ┌──────────────────────────────────────────────────────────┐ │ │ +│ │ │ Translate from {source_language} to {target_language}. │ │ │ +│ │ │ Only output the translation. │ │ │ +│ │ │ │ │ │ +│ │ │ Text: {text} │ │ │ +│ │ └──────────────────────────────────────────────────────────┘ │ │ +│ │ 可用变量: {source_language} {target_language} {text} │ │ +│ │ [恢复默认] [测试] │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 文件结构 + +### 新增文件 + +``` +ScreenTranslate/ +├── Models/ +│ ├── TranslationEngineType.swift # 扩展引擎枚举 +│ ├── TranslationEngineConfig.swift # 新增:引擎配置模型 +│ ├── TranslationPromptConfig.swift # 新增:提示词配置 +│ ├── TranslationScene.swift # 新增:场景枚举 +│ └── EngineSelectionMode.swift # 新增:选择模式枚举 +│ +├── Services/ +│ ├── Translation/ +│ │ ├── TranslationService.swift # 重构:支持多模式 +│ │ ├── TranslationEngineRegistry.swift # 新增:引擎注册表 +│ │ └── Providers/ +│ │ ├── GoogleTranslationProvider.swift +│ │ ├── DeepLTranslationProvider.swift +│ │ ├── BaiduTranslationProvider.swift +│ │ ├── LLMTranslationProvider.swift +│ │ └── CompatibleTranslationProvider.swift +│ │ +│ └── Security/ +│ └── KeychainService.swift # 新增:密钥管理 +│ +├── Features/Settings/ +│ ├── EngineSettingsTab.swift # 重构:引擎选择 +│ ├── PromptSettingsView.swift # 新增:提示词设置 +│ └── EngineConfigSheet.swift # 新增:引擎配置弹窗 +``` + +--- + +## 实现阶段 + +| 阶段 | 内容 | 预估工作量 | +|------|------|-----------| +| Phase 1 | 基础架构 - 枚举扩展、配置模型、Keychain 服务 | 小 | +| Phase 2 | 引擎注册表 + TranslationService 重构 | 中 | +| Phase 3 | LLM 翻译 Provider | 小 | +| Phase 4 | 云服务商 Provider(Google/DeepL/百度) | 中 | +| Phase 5 | OpenAI 兼容接口 Provider | 小 | +| Phase 6 | 设置界面重构 | 中 | +| Phase 7 | 四种选择模式 UI + 逻辑 | 中 | +| Phase 8 | 提示词编辑器 + 测试功能 | 中 | + +--- + +## 风险与考量 + +1. **API 限流** - 云服务商 API 可能有调用频率限制,需要在 UI 中提供错误提示 +2. **网络超时** - 并行模式下单个引擎超时不应影响其他引擎结果 +3. **密钥安全** - Keychain 访问需要处理权限和错误情况 +4. **向后兼容** - 现有用户的设置需要平滑迁移到新配置模型 +5. **LLM 翻译成本** - 需要提示用户 LLM 翻译可能产生 API 费用 + +--- + +## 后续扩展 + +- 支持更多云服务商(有道翻译、腾讯翻译君等) +- 翻译结果质量评分 +- 用户自定义翻译后处理规则 +- 离线翻译模型支持 diff --git a/docs/plans/2026-02-26-multi-translation-engine-implementation-plan.md b/docs/plans/2026-02-26-multi-translation-engine-implementation-plan.md new file mode 100644 index 0000000..2c82ea5 --- /dev/null +++ b/docs/plans/2026-02-26-multi-translation-engine-implementation-plan.md @@ -0,0 +1,996 @@ +# 多翻译引擎支持功能 - 实现计划 + +**基于设计文档**: `docs/plans/2026-02-26-multi-translation-engine-design.md` +**创建日期**: 2026-02-26 + +--- + +## Phase 0: 文档发现与 API 确认(已完成) + +### 已确认的现有代码 + +| 组件 | 文件路径 | 可复用程度 | +|------|---------|-----------| +| TranslationProvider 协议 | `Services/TranslationProvider.swift:14-51` | ✅ 直接使用 | +| AppleTranslationProvider | `Services/AppleTranslationProvider.swift:12-62` | ✅ 参考模式 | +| MTranServerEngine | `Services/MTranServerEngine.swift:4-303` | ✅ 参考模式 | +| TranslationService | `Services/TranslationService.swift:13-102` | ⚠️ 需重构 | +| VLMProvider 协议 | `Services/VLMProvider.swift:39-57` | ✅ LLM翻译参考 | +| OpenAIVLMProvider | `Services/OpenAIVLMProvider.swift` | ✅ HTTP请求模式参考 | +| SettingsViewModel | `Features/Settings/SettingsViewModel.swift:701-755` | ✅ 测试连接模式 | +| EngineSettingsTab | `Features/Settings/EngineSettingsTab.swift:15-129` | ✅ UI模式参考 | +| AppSettings | `Models/AppSettings.swift` | ⚠️ 需扩展 | + +### 已确认不存在的组件(需新建) + +- Keychain 服务 +- TranslationScene 枚举 +- EngineSelectionMode 枚举 +- TranslationEngineRegistry +- 云服务商 Provider(Google/DeepL/百度) +- LLMTranslationProvider +- CompatibleTranslationProvider + +### Keychain API 参考 + +```swift +import Security + +// 保存密钥 +func SecItemAdd(_ query: CFDictionary, _ result: UnsafeMutablePointer?) -> OSStatus + +// 获取密钥 +func SecItemCopyMatching(_ query: CFDictionary, _ result: UnsafeMutablePointer?) -> OSStatus + +// 删除密钥 +func SecItemDelete(_ query: CFDictionary) -> OSStatus + +// 常用常量 +kSecClass, kSecClassGenericPassword, kSecAttrService, kSecAttrAccount, kSecValueData, kSecReturnData, kSecMatchLimit +``` + +--- + +## Phase 1: 基础架构 - 枚举与配置模型 + +### 目标 +创建新引擎类型、选择模式和场景枚举,以及配置模型。 + +### 任务清单 + +#### 1.1 扩展 TranslationEngineType 枚举 +**文件**: `Models/TranslationEngineType.swift` + +**操作**: 在现有枚举中添加新 case + +```swift +enum TranslationEngineType: String, CaseIterable, Sendable, Codable { + // 现有 + case apple = "apple" + case mtranServer = "mtran" + + // 新增 - LLM 翻译 + case openai = "openai" + case claude = "claude" + case ollama = "ollama" + + // 新增 - 云服务商 + case google = "google" + case deepl = "deepl" + case baidu = "baidu" + + // 新增 - 自定义 + case custom = "custom" +} +``` + +**为每个新引擎添加**: +- `localizedName` 属性(参考现有实现) +- `description` 属性 +- `requiresAPIKey: Bool` 属性 +- `requiresAppID: Bool` 属性(仅百度需要) + +#### 1.2 创建 EngineSelectionMode 枚举 +**文件**: `Models/EngineSelectionMode.swift`(新建) + +```swift +enum EngineSelectionMode: String, Codable, CaseIterable, Identifiable { + case primaryWithFallback = "primary_fallback" + case parallel = "parallel" + case quickSwitch = "quick_switch" + case sceneBinding = "scene_binding" + + var id: String { rawValue } + + var localizedName: String { ... } + var description: String { ... } +} +``` + +#### 1.3 创建 TranslationScene 枚举 +**文件**: `Models/TranslationScene.swift`(新建) + +```swift +enum TranslationScene: String, Codable, CaseIterable, Identifiable { + case screenshot = "screenshot" + case textSelection = "text_selection" + case translateAndInsert = "translate_and_insert" + + var id: String { rawValue } + var localizedName: String { ... } +} +``` + +#### 1.4 创建引擎配置模型 +**文件**: `Models/TranslationEngineConfig.swift`(新建) + +```swift +struct TranslationEngineConfig: Codable, Identifiable, Equatable { + let id: TranslationEngineType + var isEnabled: Bool + var options: EngineOptions? + + init(id: TranslationEngineType, isEnabled: Bool = false, options: EngineOptions? = nil) +} + +struct EngineOptions: Codable, Equatable { + var baseURL: String? + var modelName: String? + var timeout: TimeInterval? +} +``` + +#### 1.5 创建场景绑定配置 +**文件**: `Models/SceneEngineBinding.swift`(新建) + +```swift +struct SceneEngineBinding: Codable, Identifiable, Equatable { + let scene: TranslationScene + var primaryEngine: TranslationEngineType + var fallbackEngine: TranslationEngineType? + var fallbackEnabled: Bool + + var id: TranslationScene { scene } +} +``` + +#### 1.6 创建提示词配置模型 +**文件**: `Models/TranslationPromptConfig.swift`(新建) + +```swift +struct TranslationPromptConfig: Codable, Equatable { + var enginePrompts: [TranslationEngineType: String] + var scenePrompts: [TranslationScene: String] + + static let defaultPrompt: String + static let defaultInsertPrompt: String + + func resolvedPrompt( + for engine: TranslationEngineType, + scene: TranslationScene, + sourceLanguage: String, + targetLanguage: String, + text: String + ) -> String +} +``` + +### 验证清单 +- [ ] `TranslationEngineType.allCases.count == 9` +- [ ] `EngineSelectionMode.allCases.count == 4` +- [ ] `TranslationScene.allCases.count == 3` +- [ ] 所有枚举都实现 `Codable` 和 `Sendable` +- [ ] 编译通过,无警告 + +### 参考文件 +- 现有枚举模式: `Models/TranslationEngineType.swift:5-48` +- 现有枚举模式: `Models/VLMProviderType.swift:11-83` + +--- + +## Phase 2: Keychain 服务 + +### 目标 +实现安全的 API 密钥存储服务。 + +### 任务清单 + +#### 2.1 创建 KeychainService +**文件**: `Services/Security/KeychainService.swift`(新建目录和文件) + +```swift +import Security +import Foundation + +actor KeychainService { + static let shared = KeychainService() + + private let service = "com.screentranslate.credentials" + + // 保存凭据 + func saveCredentials( + apiKey: String, + for engine: TranslationEngineType, + additionalData: [String: String]? = nil + ) throws + + // 获取凭据 + func getCredentials(for engine: TranslationEngineType) throws -> StoredCredentials? + + // 删除凭据 + func deleteCredentials(for engine: TranslationEngineType) throws + + // 检查凭据是否存在 + func hasCredentials(for engine: TranslationEngineType) -> Bool +} + +struct StoredCredentials: Codable, Sendable { + let apiKey: String + let appID: String? + let additional: [String: String]? +} +``` + +#### 2.2 实现 Keychain 错误类型 +**文件**: `Services/Security/KeychainService.swift`(同一文件) + +```swift +enum KeychainError: LocalizedError, Sendable { + case itemNotFound + case duplicateItem + case invalidData + case unexpectedStatus(OSStatus) + + var errorDescription: String? { ... } +} +``` + +#### 2.3 实现 OSStatus 扩展 +```swift +extension OSStatus { + var asNSError: NSError { + let domain = String(kSecErrorDomain) + let code = Int(self) + let description = SecCopyErrorMessageString(self, nil) + return NSError(domain: domain, code: code, userInfo: [ + NSLocalizedDescriptionKey: description ?? "Unknown keychain error" + ]) + } +} +``` + +### Keychain 查询字典模板 + +```swift +// 保存/更新 +let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: engine.rawValue, + kSecValueData as String: encodedData, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked +] + +// 查询 +let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: engine.rawValue, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne +] + +// 删除 +let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: engine.rawValue +] +``` + +### 验证清单 +- [ ] 可以保存 API Key +- [ ] 可以读取已保存的 API Key +- [ ] 可以删除 API Key +- [ ] 未找到时返回 `nil` 而非抛出错误 +- [ ] 编译通过 + +### 参考文件 +- Keychain API: Apple Security Framework 文档 +- 错误处理模式: `Services/TranslationProvider.swift:56-89` + +--- + +## Phase 3: 引擎注册表与 TranslationService 重构 + +### 目标 +创建引擎注册表,重构 TranslationService 支持多种选择模式。 + +### 任务清单 + +#### 3.1 创建 TranslationEngineRegistry +**文件**: `Services/Translation/TranslationEngineRegistry.swift`(新建) + +```swift +actor TranslationEngineRegistry { + static let shared = TranslationEngineRegistry() + + private var providers: [TranslationEngineType: any TranslationProvider] = [:] + private let keychain = KeychainService.shared + + init() { + // 注册内置引擎 + registerBuiltinProviders() + } + + func register(_ provider: any TranslationProvider, for type: TranslationEngineType) + func provider(for type: TranslationEngineType) -> (any TranslationProvider)? + func availableEngines() async -> [TranslationEngineType] + func isEngineConfigured(_ type: TranslationEngineType) async -> Bool +} +``` + +#### 3.2 创建翻译结果包模型 +**文件**: `Models/TranslationResultBundle.swift`(新建) + +```swift +struct TranslationResultBundle: Sendable { + let results: [EngineResult] + let primaryEngine: TranslationEngineType + + var primaryResult: [BilingualSegment] { ... } + var hasErrors: Bool { ... } + var successfulEngines: [TranslationEngineType] { ... } +} + +struct EngineResult: Sendable { + let engine: TranslationEngineType + let segments: [BilingualSegment] + let latency: TimeInterval + let error: Error? +} +``` + +#### 3.3 重构 TranslationService +**文件**: `Services/TranslationService.swift`(修改现有文件) + +**新增方法**: + +```swift +actor TranslationService { + // 现有属性保持不变 + private let registry: TranslationEngineRegistry + + // 新增: 统一入口 + func translate( + segments: [String], + to targetLanguage: String, + from sourceLanguage: String?, + scene: TranslationScene, + mode: EngineSelectionMode + ) async throws -> TranslationResultBundle + + // 新增: 四种模式实现 + private func translateWithFallback(...) async throws -> TranslationResultBundle + private func translateParallel(...) async throws -> TranslationResultBundle + private func translateForQuickSwitch(...) async throws -> TranslationResultBundle + private func translateByScene(...) async throws -> TranslationResultBundle +} +``` + +**保持向后兼容**: +- 保留现有 `translate(segments:to:preferredEngine:from:)` 方法签名 +- 内部调用新的统一入口 + +### 验证清单 +- [ ] 可以注册和获取 Provider +- [ ] 可以列出可用引擎 +- [ ] 主备模式正常工作 +- [ ] 现有翻译功能不受影响 + +### 参考文件 +- 现有实现: `Services/TranslationService.swift:35-76` +- Provider 模式: `Services/TranslationProvider.swift` + +--- + +## Phase 4: LLM 翻译 Provider + +### 目标 +实现基于 LLM 的翻译引擎(OpenAI/Claude/Ollama)。 + +### 任务清单 + +#### 4.1 创建 LLMTranslationProvider +**文件**: `Services/Translation/Providers/LLMTranslationProvider.swift`(新建目录和文件) + +```swift +actor LLMTranslationProvider: TranslationProvider { + let id: TranslationEngineType + let name: String + + private let baseURL: URL + private let modelName: String + private let apiKey: String? + private let keychain: KeychainService.shared + + init(type: TranslationEngineType) async throws + + var isAvailable: Bool { get async } + + func translate(text: String, from: String?, to: String) async throws -> TranslationResult + func translate(texts: [String], from: String?, to: String) async throws -> [TranslationResult] + func checkConnection() async -> Bool +} +``` + +#### 4.2 实现提示词构建 +```swift +private func buildTranslationPrompt( + text: String, + sourceLanguage: String?, + targetLanguage: String +) -> String { + // 从 TranslationPromptConfig 获取提示词 + // 替换模板变量: {source_language}, {target_language}, {text} +} +``` + +#### 4.3 实现 API 调用(复用 OpenAI 兼容格式) +```swift +private func callChatAPI(prompt: String) async throws -> String { + var request = URLRequest(url: baseURL.appendingPathComponent("chat/completions")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + // OpenAI 格式 + let body: [String: Any] = [ + "model": modelName, + "messages": [ + ["role": "user", "content": prompt] + ], + "temperature": 0.3 + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + // Claude 需要 x-api-key header + if id == .claude { + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + } else if let apiKey = apiKey { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + + let (data, response) = try await URLSession.shared.data(for: request) + // 解析响应... +} +``` + +### 验证清单 +- [ ] OpenAI 翻译正常 +- [ ] Claude 翻译正常 +- [ ] Ollama 本地翻译正常 +- [ ] API Key 从 Keychain 读取 +- [ ] 错误处理完整 + +### 参考文件 +- HTTP 请求模式: `Services/OpenAIVLMProvider.swift:290-356` +- 错误处理: `Services/OpenAIVLMProvider.swift:359-390` +- Claude 特定: `Services/ClaudeVLMProvider.swift` + +--- + +## Phase 5: 云服务商 Provider + +### 目标 +实现 Google Translate、DeepL、百度翻译 Provider。 + +### 任务清单 + +#### 5.1 Google Translate Provider +**文件**: `Services/Translation/Providers/GoogleTranslationProvider.swift` + +```swift +actor GoogleTranslationProvider: TranslationProvider { + let id = TranslationEngineType.google + let name = "Google Translate" + + // API 端点: https://translation.googleapis.com/language/translate/v2 + // 请求格式: + // { + // "q": "text to translate", + // "source": "en", + // "target": "zh", + // "format": "text" + // } + // Header: Authorization: Bearer {API_KEY} +} +``` + +#### 5.2 DeepL Provider +**文件**: `Services/Translation/Providers/DeepLTranslationProvider.swift` + +```swift +actor DeepLTranslationProvider: TranslationProvider { + let id = TranslationEngineType.deepl + let name = "DeepL" + + // API 端点: + // 免费: https://api-free.deepl.com/v2/translate + // 专业: https://api.deepl.com/v2/translate + // 请求格式: + // { + // "text": ["text to translate"], + // "source_lang": "EN", + // "target_lang": "ZH" + // } + // Header: Authorization: DeepL-Auth-Key {API_KEY} +} +``` + +#### 5.3 百度翻译 Provider +**文件**: `Services/Translation/Providers/BaiduTranslationProvider.swift` + +```swift +actor BaiduTranslationProvider: TranslationProvider { + let id = TranslationEngineType.baidu + let name = "百度翻译" + + // API 端点: https://fanyi-api.baidu.com/api/trans/vip/translate + // 请求方式: GET + // 参数: q, from, to, appid, salt, sign + // 签名: MD5(appid + q + salt + 密钥) + + private func generateSign(query: String, appID: String, salt: String, secretKey: String) -> String { + let input = appID + query + salt + secretKey + return input.md5 + } +} +``` + +### 验证清单 +- [ ] Google Translate API 调用成功 +- [ ] DeepL API 调用成功 +- [ ] 百度翻译 API 调用成功(含签名验证) +- [ ] API Key 存储在 Keychain +- [ ] 错误处理完整 + +### 参考文件 +- HTTP 请求模式: `Services/MTranServerEngine.swift:67-117` +- JSON 解析: `Services/MTranServerEngine.swift:294-336` + +--- + +## Phase 6: OpenAI 兼容接口 Provider + +### 目标 +实现通用的 OpenAI 兼容接口 Provider。 + +### 任务清单 + +#### 6.1 创建 CompatibleTranslationProvider +**文件**: `Services/Translation/Providers/CompatibleTranslationProvider.swift` + +```swift +actor CompatibleTranslationProvider: TranslationProvider { + let id = TranslationEngineType.custom + let name: String // 用户自定义名称 + + private let config: CompatibleConfig + + struct CompatibleConfig: Codable, Equatable { + var displayName: String + var baseURL: String + var modelName: String + var hasAPIKey: Bool + } +} +``` + +#### 6.2 实现 API 调用 +- 使用标准 OpenAI Chat Completions API 格式 +- 支持自定义 baseURL +- 支持可选的 API Key + +### 验证清单 +- [ ] 可以配置自定义端点 +- [ ] API 调用成功 +- [ ] 无 API Key 模式正常工作 + +### 参考文件 +- OpenAI 请求格式: `Services/OpenAIVLMProvider.swift:541-552` + +--- + +## Phase 7: AppSettings 扩展 + +### 目标 +扩展 AppSettings 以支持新的配置模型。 + +### 任务清单 + +#### 7.1 添加新配置键 +**文件**: `Models/AppSettings.swift` + +在 `Keys` 枚举中添加: +```swift +// 引擎选择模式 +static let engineSelectionMode = prefix + "engineSelectionMode" + +// 引擎配置(JSON 编码存储) +static let engineConfigs = prefix + "engineConfigs" + +// 提示词配置 +static let promptConfig = prefix + "promptConfig" + +// 场景绑定 +static let sceneBindings = prefix + "sceneBindings" + +// 并行引擎列表 +static let parallelEngines = prefix + "parallelEngines" + +// 兼容接口配置 +static let compatibleProviderConfigs = prefix + "compatibleProviderConfigs" +``` + +#### 7.2 添加新属性 +```swift +// 新增属性 +var engineSelectionMode: EngineSelectionMode +var engineConfigs: [TranslationEngineType: TranslationEngineConfig] +var promptConfig: TranslationPromptConfig +var sceneBindings: [TranslationScene: SceneEngineBinding] +var parallelEngines: [TranslationEngineType] +var compatibleProviderConfigs: [CompatibleTranslationProvider.CompatibleConfig] +``` + +#### 7.3 实现默认值 +```swift +// init() 中添加 +engineSelectionMode = .primaryWithFallback + +engineConfigs = [ + .apple: TranslationEngineConfig(id: .apple, isEnabled: true), + .mtranServer: TranslationEngineConfig(id: .mtranServer, isEnabled: false), + // 其他引擎默认禁用 +] + +sceneBindings = [ + .screenshot: SceneEngineBinding(scene: .screenshot, primaryEngine: .apple, fallbackEngine: .mtranServer, fallbackEnabled: true), + .textSelection: SceneEngineBinding(scene: .textSelection, primaryEngine: .apple, fallbackEngine: .mtranServer, fallbackEnabled: true), + .translateAndInsert: SceneEngineBinding(scene: .translateAndInsert, primaryEngine: .apple, fallbackEngine: .mtranServer, fallbackEnabled: true) +] + +promptConfig = TranslationPromptConfig( + enginePrompts: [:], + scenePrompts: [:] +) +``` + +### 验证清单 +- [ ] 配置可以保存到 UserDefaults +- [ ] 配置可以从 UserDefaults 正确加载 +- [ ] 默认值正确 + +### 参考文件 +- 现有模式: `Models/AppSettings.swift:176-206` +- 加载逻辑: `Models/AppSettings.swift:295-310` + +--- + +## Phase 8: 设置界面 - 引擎配置 + +### 目标 +重构引擎设置界面,支持多引擎配置和选择模式。 + +### 任务清单 + +#### 8.1 创建引擎配置弹窗组件 +**文件**: `Features/Settings/EngineConfigSheet.swift`(新建) + +```swift +struct EngineConfigSheet: View { + let engine: TranslationEngineType + @Binding var config: TranslationEngineConfig + + var body: some View { + VStack(spacing: 16) { + // API Key 输入(使用 SecureField) + // Base URL 输入(如适用) + // Model Name 输入(如适用) + // 测试连接按钮 + } + } +} +``` + +#### 8.2 重构 EngineSettingsTab +**文件**: `Features/Settings/EngineSettingsTab.swift` + +新增组件: +- `EngineSelectionModeSection` - 选择模式 +- `AvailableEnginesSection` - 可用引擎列表 +- `DynamicConfigSection` - 根据模式显示的动态配置 + +```swift +struct EngineSettingsContent: View { + @Bindable var viewModel: SettingsViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 24) { + EngineSelectionModeSection(viewModel: viewModel) + AvailableEnginesSection(viewModel: viewModel) + DynamicConfigSection(viewModel: viewModel) + } + } +} +``` + +#### 8.3 扩展 SettingsViewModel +**文件**: `Features/Settings/SettingsViewModel.swift` + +添加: +```swift +// 新增属性 +var engineSelectionMode: EngineSelectionMode +var engineConfigs: [TranslationEngineType: TranslationEngineConfig] +var parallelEngines: [TranslationEngineType] +var sceneBindings: [TranslationScene: SceneEngineBinding] + +// 新增方法 +func testEngineConnection(_ engine: TranslationEngineType) async +func toggleEngine(_ engine: TranslationEngineType) +func setPrimaryEngine(_ engine: TranslationEngineType, for scene: TranslationScene) +``` + +### 验证清单 +- [ ] 可以切换选择模式 +- [ ] 可以启用/禁用引擎 +- [ ] 可以配置引擎参数 +- [ ] 测试连接功能正常 + +### 参考文件 +- UI 模式: `Features/Settings/EngineSettingsTab.swift:15-129` +- 测试方法: `Features/Settings/SettingsViewModel.swift:701-755` + +--- + +## Phase 9: 设置界面 - 提示词配置 + +### 目标 +创建提示词编辑界面。 + +### 任务清单 + +#### 9.1 创建提示词设置视图 +**文件**: `Features/Settings/PromptSettingsView.swift`(新建) + +```swift +struct PromptSettingsView: View { + @Bindable var viewModel: SettingsViewModel + @State private var editingPrompt: PromptEditTarget? + @State private var promptText: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 24) { + EnginePromptsSection(viewModel: viewModel) + ScenePromptsSection(viewModel: viewModel) + } + .sheet(item: $editingPrompt) { target in + PromptEditorSheet( + target: target, + prompt: $promptText, + onSave: { ... } + ) + } + } +} +``` + +#### 9.2 创建提示词编辑器 +```swift +struct PromptEditorSheet: View { + let target: PromptEditTarget + @Binding var prompt: String + let onSave: () -> Void + + var body: some View { + VStack(spacing: 16) { + Text("编辑提示词") + + TextEditor(text: $prompt) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 200) + + // 可用变量提示 + HStack { + Text("可用变量:") + ForEach(["{source_language}", "{target_language}", "{text}"], id: \.self) { variable in + Button(variable) { insertVariable(variable) } + .buttonStyle(.borderless) + } + } + + HStack { + Button("恢复默认") { ... } + Spacer() + Button("取消") { ... } + Button("保存") { onSave() } + .buttonStyle(.borderedProminent) + } + } + .padding() + } +} +``` + +#### 9.3 创建测试功能 +```swift +struct PromptTestView: View { + let engine: TranslationEngineType + let prompt: String + + @State private var testText: String = "" + @State private var result: String? + @State private var isTesting: Bool = false + + var body: some View { + // 输入测试文本 + // 显示翻译结果 + } +} +``` + +### 验证清单 +- [ ] 可以编辑分引擎提示词 +- [ ] 可以编辑分场景提示词 +- [ ] 变量插入功能正常 +- [ ] 恢复默认功能正常 + +### 参考文件 +- Sheet 模式: 现有设置界面中的弹窗组件 + +--- + +## Phase 10: 集成与验证 + +### 目标 +集成所有组件,进行端到端测试。 + +### 任务清单 + +#### 10.1 更新 TranslationFlowController +**文件**: `Features/TranslationFlow/TranslationFlowController.swift` + +确保使用新的 TranslationService API: +```swift +func performTranslation( + segments: [String], + scene: TranslationScene +) async throws -> TranslationResultBundle { + try await translationService.translate( + segments: segments, + to: settings.translationTargetLanguage?.rawValue ?? "zh-Hans", + from: settings.translationSourceLanguage.rawValue, + scene: scene, + mode: settings.engineSelectionMode + ) +} +``` + +#### 10.2 更新结果展示组件 +确保 `BilingualResultView` 可以处理多引擎结果。 + +#### 10.3 添加本地化字符串 +**文件**: `Resources/en.lproj/Localizable.strings` 和 `Resources/zh-Hans.lproj/Localizable.strings` + +添加所有新引擎和模式的本地化字符串。 + +### 验证清单 + +#### 功能验证 +- [ ] Apple Translation 正常工作 +- [ ] MTranServer 正常工作 +- [ ] 新增 LLM 翻译引擎正常工作 +- [ ] 新增云服务商引擎正常工作 +- [ ] 自定义接口正常工作 + +#### 模式验证 +- [ ] 主备模式正常切换 +- [ ] 并行模式返回所有结果 +- [ ] 即时切换可以懒加载其他引擎 +- [ ] 场景绑定按场景使用正确引擎 + +#### 配置验证 +- [ ] API Key 存储在 Keychain +- [ ] 配置持久化正常 +- [ ] 设置界面显示正确 + +#### 错误处理 +- [ ] 网络错误正确显示 +- [ ] API Key 错误正确提示 +- [ ] 超时处理正确 + +### Anti-Pattern 检查 +```bash +# 检查是否使用了不存在的 API +grep -r "SecItemUpdate" ScreenTranslate/ # 应该不存在(使用 SecItemAdd + delete 策略) + +# 检查是否硬编码 API Key +grep -r "sk-" ScreenTranslate/ # 应该只在测试文件中 + +# 检查是否在 UserDefaults 存储 API Key(旧代码迁移) +grep -r "apiKey.*UserDefaults" ScreenTranslate/ # 应该不存在 +``` + +--- + +## 文件变更摘要 + +### 新建文件 +``` +ScreenTranslate/ +├── Models/ +│ ├── EngineSelectionMode.swift +│ ├── TranslationScene.swift +│ ├── TranslationEngineConfig.swift +│ ├── SceneEngineBinding.swift +│ ├── TranslationPromptConfig.swift +│ └── TranslationResultBundle.swift +│ +├── Services/ +│ ├── Security/ +│ │ └── KeychainService.swift +│ │ +│ └── Translation/ +│ ├── TranslationEngineRegistry.swift +│ └── Providers/ +│ ├── LLMTranslationProvider.swift +│ ├── GoogleTranslationProvider.swift +│ ├── DeepLTranslationProvider.swift +│ ├── BaiduTranslationProvider.swift +│ └── CompatibleTranslationProvider.swift +│ +└── Features/Settings/ + ├── EngineConfigSheet.swift + └── PromptSettingsView.swift +``` + +### 修改文件 +``` +ScreenTranslate/ +├── Models/ +│ ├── TranslationEngineType.swift # 扩展枚举 +│ └── AppSettings.swift # 添加新配置 +│ +├── Services/ +│ └── TranslationService.swift # 重构 +│ +└── Features/Settings/ + ├── EngineSettingsTab.swift # 重构 + └── SettingsViewModel.swift # 扩展 +``` + +--- + +## 风险与缓解 + +| 风险 | 影响 | 缓解措施 | +|------|------|---------| +| Keychain 权限问题 | 用户无法保存 API Key | 提供清晰的错误提示和恢复指南 | +| API 限流 | 翻译失败 | 实现重试机制和限流提示 | +| 并行模式性能 | UI 卡顿 | 使用 TaskGroup 并发,限制最大并发数 | +| 配置迁移 | 现有用户配置丢失 | 提供迁移逻辑,保留旧配置格式兼容 | +| LLM 翻译成本 | 用户产生意外费用 | UI 明确提示,提供测试功能预览 | + +--- + +## 里程碑 + +| 里程碑 | 包含 Phase | 预期成果 | +|--------|-----------|---------| +| M1: 基础架构 | Phase 1-2 | 新枚举、Keychain 服务可用 | +| M2: 引擎实现 | Phase 3-6 | 所有引擎可独立工作 | +| M3: 配置系统 | Phase 7 | AppSettings 支持新配置 | +| M4: UI 完成 | Phase 8-9 | 设置界面完整可用 | +| M5: 集成测试 | Phase 10 | 端到端功能验证 | diff --git a/docs/plans/2026-03-02-annotation-enhancement-design.md b/docs/plans/2026-03-02-annotation-enhancement-design.md new file mode 100644 index 0000000..cb44cd4 --- /dev/null +++ b/docs/plans/2026-03-02-annotation-enhancement-design.md @@ -0,0 +1,352 @@ +# 标注与钉图功能增强设计 + +## 概述 + +为 ScreenTranslate 添加**钉图功能**和**预制形状**,提升截图标注的实用性。 + +## 功能需求 + +### 1. 钉图功能 + +- **单图固定**: 将截图窗口固定在屏幕最上层 (always on top) +- **多图钉住**: 支持同时钉住多张截图,作为快速参考/对比 + +### 2. 预制形状 + +#### 基础形状 +- **圆形/椭圆** (Ellipse): 可调整填充/描边 +- **直线** (Line): 可调整线宽和颜色 + +#### 强调形状 +- **马赛克** (Mosaic): 像素化区域,隐藏敏感信息 +- **高亮荧光笔** (Highlight): 半透明背景,强调文字区域 + +#### 标注形状 +- **编号标签** (Number Label): 自动递增编号 (①②③...) +- **对话框/气泡** (Callout): 带箭头的气泡框 + +## 架构设计 + +### 目录结构 + +```text +ScreenTranslate/ +├── Features/ +│ ├── Preview/ +│ │ ├── PreviewWindow.swift # 添加钉图状态管理 +│ │ ├── PreviewToolBar.swift # 添加新工具按钮 +│ │ ├── AnnotationCanvas.swift # 添加新形状渲染 +│ │ └── PreviewViewModel.swift # 扩展 AnnotationToolType +│ ├── Annotations/ +│ │ ├── EllipseTool.swift # 新增 +│ │ ├── LineTool.swift # 新增 +│ │ ├── MosaicTool.swift # 新增 +│ │ ├── HighlightTool.swift # 新增 +│ │ ├── NumberLabelTool.swift # 新增 +│ │ └── CalloutTool.swift # 新增 +│ └── Pinned/ # 新增目录 +│ ├── PinnedWindow.swift +│ └── PinnedWindowsManager.swift +├── Models/ +│ └── Annotation.swift # 扩展 enum + 新增 6 个 struct +``` + +### 数据模型 + +#### Annotation 枚举扩展 + +```swift +enum Annotation: Identifiable, Equatable, Sendable { + // 现有 + case rectangle(RectangleAnnotation) + case freehand(FreehandAnnotation) + case arrow(ArrowAnnotation) + case text(TextAnnotation) + // 新增 + case ellipse(EllipseAnnotation) + case line(LineAnnotation) + case mosaic(MosaicAnnotation) + case highlight(HighlightAnnotation) + case numberLabel(NumberLabelAnnotation) + case callout(CalloutAnnotation) +} +``` + +#### 新增形状结构体 + +```swift +// 圆形/椭圆 +struct EllipseAnnotation: Identifiable, Equatable, Sendable { + let id: UUID + var rect: CGRect + var style: StrokeStyle + var isFilled: Bool +} + +// 直线 +struct LineAnnotation: Identifiable, Equatable, Sendable { + let id: UUID + var startPoint: CGPoint + var endPoint: CGPoint + var style: StrokeStyle +} + +// 马赛克 +struct MosaicAnnotation: Identifiable, Equatable, Sendable { + let id: UUID + var rect: CGRect + var blockSize: Int // 8-32 +} + +// 高亮 +struct HighlightAnnotation: Identifiable, Equatable, Sendable { + let id: UUID + var rect: CGRect + var color: CodableColor // 默认黄色 + var opacity: Double // 默认 0.3 +} + +// 编号标签 +struct NumberLabelAnnotation: Identifiable, Equatable, Sendable { + let id: UUID + var position: CGPoint + var number: Int + var style: TextStyle +} + +// 对话框/气泡 +struct CalloutAnnotation: Identifiable, Equatable, Sendable { + let id: UUID + var rect: CGRect // 气泡框位置 + var tailPoint: CGPoint // 箭头指向的点 + var content: String + var style: TextStyle +} +``` + +#### 穷举 Switch 同步检查清单 + +添加新 Annotation case 时,必须更新以下位置: + +| 文件 | 符号/位置 | 更新内容 | +|------|----------|----------| +| `Models/Annotation.swift` | `enum Annotation` | 添加新 case | +| `Models/Annotation.swift` | `var id: UUID` | 添加 switch 分支返回对应 ID | +| `Models/Annotation.swift` | `var bounds: CGRect` | 添加 switch 分支返回对应 bounds | +| `Features/Preview/PreviewViewModel.swift` | `enum AnnotationToolType` | 添加新工具类型 | +| `Features/Preview/PreviewViewModel.swift` | `currentTool` switch | 添加工具创建逻辑 | +| `Features/Preview/AnnotationCanvas.swift` | `renderAnnotation()` | 添加渲染逻辑 | +| `Services/ImageExporter+AnnotationRendering.swift` | `renderAnnotation()` | 添加导出渲染逻辑 | +| `Features/Preview/PreviewToolBar.swift` | 工具按钮列表 | 添加工具按钮 | + +新增 case 分支行为模板: +- `ellipse`: 椭圆/圆形,支持填充 +- `line`: 直线,两点连接 +- `mosaic`: 马赛克块,基于 rect +- `highlight`: 半透明高亮矩形 +- `numberLabel`: 编号标签,基于 position +- `callout`: 气泡框,带箭头和文本 + +### 工具类型扩展 + +```swift +enum AnnotationToolType: String, CaseIterable, Identifiable, Sendable { + // 现有 + case rectangle + case freehand + case arrow + case text + // 新增 + case ellipse + case line + case mosaic + case highlight + case numberLabel + case callout + + var systemImage: String { + switch self { + case .rectangle: return "rectangle" + case .freehand: return "pencil.tip" + case .arrow: return "arrow.up.right" + case .text: return "textformat" + case .ellipse: return "circle" + case .line: return "line.diagonal" + case .mosaic: return "checkerboard.rectangle" + case .highlight: return "highlighter" + case .numberLabel: return "number.circle" + case .callout: return "bubble.right" + } + } + + var keyboardShortcut: Character { + switch self { + case .rectangle: return "r" + case .freehand: return "d" + case .arrow: return "a" + case .text: return "t" + case .ellipse: return "o" + case .line: return "l" + case .mosaic: return "m" + case .highlight: return "h" + case .numberLabel: return "n" + case .callout: return "b" + } + } +} +``` + +#### 快捷键别名映射 + +`keyboardShortcut` 保持单一 `Character`,通过 `hotkeyAliases` 字典支持多键绑定: + +```swift +extension AnnotationToolType { + /// 主快捷键别名映射(数字键作为字母键的备选) + static let hotkeyAliases: [Character: AnnotationToolType] = [ + // 主键 + "r": .rectangle, "d": .freehand, "a": .arrow, "t": .text, + "o": .ellipse, "l": .line, "m": .mosaic, "h": .highlight, + "n": .numberLabel, "b": .callout, + // 数字别名 + "5": .ellipse, "6": .line, "7": .highlight, "8": .mosaic, + "9": .numberLabel, "0": .callout + ] +} +``` + +### 钉图管理器 + +```swift +@MainActor +final class PinnedWindowsManager { + static let shared = PinnedWindowsManager() + private(set) var pinnedWindows: [UUID: PinnedWindow] = [:] + + /// 钉住截图 + func pinScreenshot(_ screenshot: Screenshot, annotations: [Annotation]) -> PinnedWindow + + /// 取消钉住 + func unpinWindow(_ id: UUID) + + /// 取消所有钉住 + func unpinAll() + + /// 获取钉住数量 + var pinnedCount: Int { pinnedWindows.count } +} +``` + +### PinnedWindow + +```swift +final class PinnedWindow: NSPanel { + private let screenshot: Screenshot + private let annotations: [Annotation] + + init(screenshot: Screenshot, annotations: [Annotation]) + + /// 配置为始终置顶(不覆盖系统对话框/通知) + func configureAsPinned() { + level = .floating // 保持置顶但不干扰系统级窗口 + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + styleMask = [.borderless, .nonactivatingPanel] + // 简化 UI,只保留关闭按钮 + } +} +``` + +## UI 设计 + +### 工具栏布局 + +```text +[矩形 R] [圆形 O] [直线 L] [箭头 A] [画笔 D] | [高亮 H] [马赛克 M] | [文字 T] [编号 N] [气泡 B] | [裁剪 C] [钉图 P] +``` + +工具分组: +- 基础形状: 矩形、圆形、直线、箭头、画笔 +- 强调工具: 高亮、马赛克 +- 文字标注: 文字、编号、气泡 +- 操作: 裁剪、钉图 + +### 钉图按钮状态 + +- 未钉住: `pin` 图标,点击后钉住 +- 已钉住: `pin.fill` 图标,点击后取消钉住 + +## 快捷键 + +| 功能 | 快捷键 | +|------|--------| +| 圆形 | `O` 或 `5` | +| 直线 | `L` 或 `6` | +| 高亮 | `H` 或 `7` | +| 马赛克 | `M` 或 `8` | +| 编号 | `N` 或 `9` | +| 气泡 | `B` 或 `0` | +| 钉图 | `P` | + +## 实现计划 + +### Phase 1: 钉图功能 +1. 创建 `PinnedWindow` 和 `PinnedWindowsManager` +2. 在 `PreviewToolBar` 添加钉图按钮 +3. 实现钉图状态切换逻辑 + +### Phase 2: 基础形状 +1. 扩展 `Annotation` enum +2. 实现 `EllipseAnnotation` 和 `EllipseTool` +3. 实现 `LineAnnotation` 和 `LineTool` +4. 在 `AnnotationCanvas` 添加渲染逻辑 + +### Phase 3: 强调形状 +1. 实现 `MosaicAnnotation` 和 `MosaicTool` +2. 实现 `HighlightAnnotation` 和 `HighlightTool` +3. 在 `AnnotationCanvas` 添加渲染逻辑 + +### Phase 4: 标注形状 +1. 实现 `NumberLabelAnnotation` 和 `NumberLabelTool` +2. 实现 `CalloutAnnotation` 和 `CalloutTool` +3. 在 `AnnotationCanvas` 添加渲染逻辑 + +### Phase 5: 测试与优化 +1. 单元测试覆盖 +2. 性能优化 +3. 边界情况处理 + +## 技术考虑 + +### 马赛克渲染 +使用 Core Image 的 `CIPixellate` 滤镜或手动像素化算法: +```swift +func applyMosaic(to image: NSImage, in rect: CGRect, blockSize: Int) -> NSImage +``` + +### 高亮渲染 +使用半透明矩形覆盖: +```swift +context.fill(path, with: .color(color.color.withAlphaComponent(opacity))) +``` + +### 编号标签 +使用 `NumberFormatter` 或 Unicode 圈数字字符 (①②③...): +- U+2460 - U+2473 (①-⑳): 基础范围 1-20 +- U+3251 - U+325F (㉑-㉟): 扩展范围 21-35 +- U+32B1 - U+32BF (㊱-㊿): 扩展范围 36-50 +- 超过 50 时使用普通数字 "51" 等作为回退 + +### 气泡框 +使用贝塞尔曲线绘制圆角矩形 + 三角形箭头: +```swift +func drawCalloutBubble(in context: CGContext, rect: CGRect, tailPoint: CGPoint) +``` + +## 风险与缓解 + +| 风险 | 缓解措施 | +|------|---------| +| 工具栏过于拥挤 | 分组设计,考虑二级菜单 | +| 马赛克性能 | 缓存像素化结果,仅在导出时计算 | +| 多钉图内存占用 | 限制最大钉图数量 (建议 5 个) | +| 快捷键冲突 | 避免与系统快捷键冲突,提供自定义选项 | diff --git a/docs/specs/window-detection-auto-select.md b/docs/specs/window-detection-auto-select.md new file mode 100644 index 0000000..bd63022 --- /dev/null +++ b/docs/specs/window-detection-auto-select.md @@ -0,0 +1,316 @@ +# Spec: 区域选择模式 - 窗口自动识别与高亮 + +## 概述 + +在现有的区域截图/翻译模式中增加**窗口自动识别**能力。当用户进入区域选择模式后,系统实时检测鼠标光标下方的 UI 窗口,以轻微强调色边框高亮该窗口区域。用户可以直接点击来选取整个窗口范围,也可以忽略高亮框、按住拖动来手动圈选自定义区域。 + +## 动机 + +当前的区域选择模式只支持拖拽圈选,用户若想截取一个完整窗口,需要精确地拖拽出窗口边界,操作繁琐且难以精准对齐。macOS 原生截图工具(Cmd+Shift+4 后按空格)支持窗口选取模式,但它是一个独立的切换操作,不如"悬停检测 + 单击确认"来得流畅。本特性将两种选择方式无缝融合在同一个交互流程中。 + +## 用户故事 + +**US-1**: 作为用户,我进入区域选择模式后,光标悬停在某个窗口上时,该窗口会自动以高亮框标识出来,让我知道系统识别了哪个窗口。 + +**US-2**: 作为用户,我直接单击(不拖动),系统将截取/翻译当前高亮的窗口区域。 + +**US-3**: 作为用户,我按下鼠标并拖动,系统忽略窗口高亮,进入手动圈选模式,行为与现有逻辑完全一致。 + +**US-4**: 作为用户,我移动鼠标到不同窗口时,高亮框平滑地跟随切换到新窗口。 + +**US-5**: 作为用户,我光标移到桌面空白区域(无窗口)时,高亮框消失,只显示十字准线。 + +## 技术设计 + +### 1. 窗口信息获取 + +使用 `CGWindowListCopyWindowInfo` API 获取当前所有可见窗口的位置和尺寸信息。选择该 API 而非 ScreenCaptureKit 的 `SCShareableContent.windows`,原因: + +- `CGWindowListCopyWindowInfo` 是同步调用,适合在 `mouseMoved` 高频事件中实时查询 +- 返回结果包含 `kCGWindowBounds`(窗口 frame)、`kCGWindowLayer`(窗口层级)、`kCGWindowOwnerName`(应用名)等完整信息 +- 无需额外权限(Screen Recording 权限已涵盖) + +**新建文件**: `ScreenTranslate/Services/WindowDetector.swift` + +```swift +/// 检测鼠标光标下方的窗口,提供窗口 frame 信息。 +/// 该服务用于区域选择模式中的窗口自动识别。 +@MainActor +final class WindowDetector { + + struct WindowInfo { + let windowID: CGWindowID + let frame: CGRect // Quartz 坐标系 (Y=0 at top) + let ownerName: String + let windowName: String? + let windowLayer: Int + } + + /// 获取指定屏幕坐标点下方最顶层的可见窗口。 + /// - Parameter point: Quartz 坐标系中的屏幕坐标 + /// - Returns: 该点下方最顶层的可见窗口信息,无窗口则返回 nil + func windowUnderPoint(_ point: CGPoint) -> WindowInfo? { ... } + + /// 获取所有可见窗口列表(排除自身覆盖层和系统级窗口)。 + /// - Returns: 按 Z-order 从前到后排序的窗口列表 + func visibleWindows() -> [WindowInfo] { ... } +} +``` + +**关键实现细节**: + +- 使用 `CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID)` 获取当前屏幕上所有可见窗口 +- 过滤条件:排除 `kCGWindowLayer` != 0 的系统级窗口(Dock、Menu Bar 等),排除自身应用的覆盖层窗口 +- 窗口列表已按 Z-order 排序,遍历找第一个包含目标点的窗口即可 +- 缓存策略:窗口列表在 `mouseDown` 时刷新一次,`mouseMoved` 时可使用最近一次的缓存结果(窗口在用户操作覆盖层期间不会移动) + +### 2. SelectionOverlayView 改造 + +**修改文件**: `ScreenTranslate/Features/Capture/SelectionOverlayWindow.swift` + +在 `SelectionOverlayView` 中增加窗口高亮绘制逻辑: + +#### 2.1 新增属性 + +```swift +/// 当前高亮的窗口 frame(view 坐标系) +var highlightedWindowRect: CGRect? + +/// 窗口检测器 +private let windowDetector = WindowDetector() + +/// 高亮框颜色(轻微强调色) +private let windowHighlightColor = NSColor.systemBlue.withAlphaComponent(0.3) +private let windowHighlightStrokeColor = NSColor.systemBlue.withAlphaComponent(0.6) + +/// 拖拽阈值(像素),超过此距离判定为拖拽而非点击 +private let dragThreshold: CGFloat = 4.0 + +/// mouseDown 时记录的初始位置(用于区分点击与拖拽) +private var mouseDownPoint: NSPoint? +``` + +#### 2.2 鼠标事件改造 + +**`mouseMoved`** — 增加窗口检测: + +``` +1. 获取鼠标屏幕坐标(Quartz 坐标系) +2. 调用 windowDetector.windowUnderPoint(screenPoint) +3. 若找到窗口,将窗口 frame 从 Quartz 屏幕坐标转换为 view 坐标 +4. 更新 highlightedWindowRect 并触发重绘 +5. 若未找到窗口,清除 highlightedWindowRect +``` + +**`mouseDown`** — 记录起始点,但不立即开始选区: + +``` +1. 记录 mouseDownPoint(用于后续判断是点击还是拖拽) +2. 刷新 windowDetector 缓存 +3. 不设置 isDragging = true(延迟到确认拖拽后) +``` + +**`mouseDragged`** — 判断是否进入拖拽模式: + +``` +1. 计算当前点与 mouseDownPoint 的距离 +2. 若距离 < dragThreshold 且未进入拖拽模式 → 忽略(视为手抖) +3. 若距离 >= dragThreshold → + a. 设置 isDragging = true + b. 清除 highlightedWindowRect(退出窗口高亮模式) + c. 设置 selectionStart = mouseDownPoint + d. 设置 selectionCurrent = 当前点 + e. 后续行为与现有拖拽逻辑一致 +``` + +**`mouseUp`** — 判断是点击还是拖拽完成: + +``` +1. 若 isDragging == true → 执行现有的选区完成逻辑(不变) +2. 若 isDragging == false → 这是一次单击 + a. 若 highlightedWindowRect 不为 nil → + 将高亮窗口 frame 转换为 display-relative Quartz 坐标 + 调用 delegate?.selectionOverlay(didSelectRect:on:) + b. 若 highlightedWindowRect == nil → + 调用 delegate?.selectionOverlayDidCancel()(无窗口可选) +3. 重置所有状态 +``` + +#### 2.3 绘制逻辑改造 + +在 `draw(_:)` 方法中增加窗口高亮绘制: + +``` +原有流程: + 1. 绘制暗色覆盖 + 2. 有选区 → 绘制选区矩形 + 尺寸标签 + 3. 无选区 → 绘制十字准线 + +新流程: + 1. 绘制暗色覆盖(如果有 highlightedWindowRect,在覆盖层上挖出窗口区域) + 2. 有选区(isDragging)→ 绘制选区矩形 + 尺寸标签 + 3. 无选区 → + a. 绘制十字准线 + b. 如果有 highlightedWindowRect → 绘制窗口高亮框 +``` + +**窗口高亮框样式**: + +- 填充:`systemBlue.withAlphaComponent(0.08)` — 极轻的蓝色着色,让窗口区域与暗色覆盖区分开 +- 边框:`systemBlue.withAlphaComponent(0.5)`,线宽 2pt,圆角 0(精确贴合窗口边界) +- 暗色覆盖层在窗口区域挖洞(与现有选区挖洞逻辑类似,使用 even-odd fill rule) +- 窗口高亮区域的亮度应比暗色覆盖区域明显更亮,但不刺眼 + +**尺寸标签**:窗口高亮模式下同样显示窗口的像素尺寸标签(复用现有的 `drawDimensionsLabel` 方法)。 + +### 3. 坐标转换 + +窗口检测涉及多个坐标系之间的转换,这是实现中最关键的部分。 + +**坐标系说明**: + +| 坐标系 | Y 轴方向 | 使用场景 | +|--------|---------|---------| +| Quartz (CGWindow) | Y=0 在屏幕顶部 | `CGWindowListCopyWindowInfo` 返回的窗口 frame | +| Cocoa (NSWindow/NSView) | Y=0 在主屏幕底部 | `NSView` 坐标、`NSWindow.convertToScreen` | +| Display-relative | Y=0 在显示器顶部 | `CaptureManager.captureRegion` 的输入参数 | + +**转换路径**: + +``` +CGWindow frame (Quartz) + → Cocoa 屏幕坐标 (翻转 Y 轴: cocoaY = primaryScreenHeight - quartzY - height) + → NSWindow view 坐标 (通过 window.convertFromScreen) + → 绘制高亮框 + +单击确认时的反向转换: +view 坐标中的 highlightedWindowRect + → Cocoa 屏幕坐标 (window.convertToScreen) + → Quartz 屏幕坐标 (翻转 Y 轴) + → Display-relative 坐标 (减去 displayInfo.frame.origin) + → 传给 delegate +``` + +该反向转换逻辑与现有 `mouseUp` 中 selectionRect 的转换完全一致(`SelectionOverlayView` 第 411-451 行),可直接复用。 + +### 4. 性能考量 + +**`mouseMoved` 调用频率**:macOS 默认 ~60Hz,每次调用 `CGWindowListCopyWindowInfo` 约 1-3ms。 + +**优化策略**: + +1. **节流(Throttle)**:对窗口检测做 16ms(~60fps)节流,避免过于频繁的系统调用 +2. **缓存窗口列表**:进入覆盖层后获取一次完整窗口列表并缓存,`mouseMoved` 只做 point-in-rect 检测(O(n),n 通常 < 30) +3. **增量更新**:只在 `highlightedWindowRect` 发生变化时触发 `needsDisplay = true` +4. **脏区域重绘**:使用 `setNeedsDisplay(_:)` 指定只重绘高亮框变化的区域,而非整个 view + +### 5. 边界情况处理 + +| 场景 | 处理方式 | +|------|---------| +| 光标在自身覆盖层窗口上 | `CGWindowListCopyWindowInfo` 结果中过滤掉自身 bundle ID 的窗口 | +| 光标在菜单栏/Dock 上 | 过滤 `kCGWindowLayer != 0` 的窗口,不高亮系统级 UI | +| 全屏应用窗口 | 正常检测和高亮,全屏窗口的 frame 等于整个屏幕 | +| 多显示器 | 每个显示器有独立的 SelectionOverlayView,窗口检测使用全局屏幕坐标,不受影响 | +| 窗口部分在屏幕外 | 高亮框裁剪到屏幕可见范围内(使用 `NSRect.intersection`) | +| 极小窗口(< 10x10) | 跳过不高亮,避免误操作 | +| 快速移动鼠标穿过多个窗口 | 节流机制 + 增量更新保证流畅 | + +### 6. 视觉设计 + +``` +┌─────────────────────────────────────────────────┐ +│ ░░░░░░░░░░░░░ 暗色覆盖 (30% 黑) ░░░░░░░░░░░░░ │ +│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ +│ ░░░░ ┌──────────────────────────┐ ░░░░░░░░░░░░ │ +│ ░░░░ │ │ ░░░░░░░░░░░░ │ +│ ░░░░ │ 窗口内容(明亮可见) │ ░░░░░░░░░░░░ │ +│ ░░░░ │ 轻微蓝色着色 (8%) │ ░░░░░░░░░░░░ │ +│ ░░░░ │ │ ░░░░░░░░░░░░ │ +│ ░░░░ └──────────────────────────┘ ░░░░░░░░░░░░ │ +│ ░░░░░░ 蓝色边框 (50%, 2pt) ░░░░░░░░░░░░░░░░░░ │ +│ ░░░░░░░░░░░░░░░░░░░░░░░ ┌──────────┐ ░░░░░░░░ │ +│ ░░░░░░░░░░░░░░░░░░░░░░░ │1440 × 900│ ░░░░░░░░ │ +│ ░░░░░░░░░░░░░░░░░░░░░░░ └──────────┘ ░░░░░░░░ │ +│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ +│ ╋ (十字准线) │ +└─────────────────────────────────────────────────┘ +``` + +高亮框区域与暗色覆盖的对比效果类似于现有选区的"挖洞"效果——窗口内容清晰可见,周围被暗色覆盖压暗,视觉上突出目标窗口。 + +### 7. 交互流程状态机 + +``` + ┌─────────────┐ + ESC │ Idle │ + ┌─────────────│ (十字准线) │ + │ └──────┬──────┘ + │ │ mouseMoved + │ ▼ + │ ┌─────────────────┐ + │ ESC │ Window Hovering │ ◀─── mouseMoved (切换窗口) + ├───────────│ (窗口高亮+准线) │ + │ └───┬─────────┬───┘ + │ mouseDown │ │ mouseDown + │ + drag │ │ + click (no drag) + │ ▼ ▼ + │ ┌──────────────┐ ┌──────────────────┐ + │ │ Dragging │ │ Window Selected │ + │ │ (手动圈选) │ │ (窗口区域确认) │ + │ └──────┬───────┘ └────────┬─────────┘ + │ │ mouseUp │ + │ ▼ ▼ + │ ┌──────────────────────────────┐ + └───▶│ Complete / Cancel │ + │ (通知 delegate,关闭覆盖层) │ + └──────────────────────────────┘ +``` + +## 文件变更清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `ScreenTranslate/Services/WindowDetector.swift` | **新建** | 窗口检测服务,封装 `CGWindowListCopyWindowInfo` 调用和结果解析 | +| `ScreenTranslate/Features/Capture/SelectionOverlayWindow.swift` | **修改** | SelectionOverlayView 增加窗口高亮绘制、点击/拖拽区分逻辑 | + +## 不需要改动的文件 + +- `AppDelegate.swift` — 调用入口不变,`captureSelection()` 和 `startTranslationMode()` 逻辑无需修改 +- `SelectionOverlayController` — overlay 管理逻辑不变 +- `SelectionOverlayDelegate` 协议 — 接口不变,`selectionOverlay(didSelectRect:on:)` 既可接收拖拽选区也可接收窗口选区 +- `CaptureManager.swift` — 截图逻辑不变,只是输入的 rect 来源多了一种 +- `TranslationFlowController.swift` — 翻译流程不变 + +## 测试计划 + +### 功能测试 + +1. **窗口高亮显示**:进入区域选择模式,移动鼠标到不同窗口,验证高亮框正确包围目标窗口 +2. **单击选取窗口**:高亮某窗口后单击,验证截取的图像范围精确匹配窗口 frame +3. **拖拽自定义选区**:按住并拖动,验证高亮框消失,进入传统圈选模式,行为与改动前一致 +4. **桌面空白区域**:鼠标移到无窗口区域,验证高亮框消失 +5. **窗口切换**:快速在不同窗口间移动鼠标,验证高亮框平滑切换 +6. **翻译模式验证**:翻译模式下同样支持窗口点击选取,翻译结果正确 + +### 边界测试 + +7. **多显示器**:跨显示器移动鼠标,验证窗口检测在所有显示器上正常工作 +8. **全屏应用**:对全屏应用窗口进行高亮和点击截取 +9. **极小窗口**:验证极小窗口不会被高亮 +10. **ESC 取消**:在窗口高亮状态下按 ESC,验证正确取消并清除覆盖层 + +### 性能测试 + +11. **帧率验证**:在覆盖层显示期间快速移动鼠标,验证无明显卡顿(目标 ≥ 30fps 视觉更新) +12. **内存**:验证覆盖层关闭后 WindowDetector 缓存正确清理 + +## 里程碑 + +| 阶段 | 内容 | 预估工作量 | +|------|------|-----------| +| M1 | WindowDetector 服务实现 + 单元测试 | 小 | +| M2 | SelectionOverlayView 窗口高亮绘制 | 中 | +| M3 | 点击/拖拽区分逻辑 + 坐标转换 | 中 | +| M4 | 边界情况处理 + 性能优化 | 小 | +| M5 | 集成测试 + 多显示器验证 | 小 | diff --git a/package.json b/package.json new file mode 100644 index 0000000..93db0bb --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "screentranslate", + "version": "1.0.2", + "description": "macOS menu bar translation tool", + "repository": { + "type": "git", + "url": "git+https://github.com/hubo1989/ScreenTranslate.git" + }, + "author": "hubo", + "license": "MIT" +} diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..8a546ae --- /dev/null +++ b/release.sh @@ -0,0 +1,35 @@ +#!/bin/bash +VERSION="1.0.2" +PROJECT_PATH="/Users/hubo/Projects/screentranslate" +XCODE_PROJ="$PROJECT_PATH/ScreenTranslate.xcodeproj" + +echo "🚀 开始发布版本 v$VERSION..." +rm -rf "$PROJECT_PATH/build_artifacts" +rm -f "$PROJECT_PATH/ScreenTranslate-v$VERSION-arm64.zip" +rm -f "$PROJECT_PATH/ScreenTranslate-v$VERSION-x86_64.zip" + +echo "🏗️ 正在编译 arm64 版本..." +xcodebuild -project "$XCODE_PROJ" -scheme ScreenTranslate -configuration Release -arch arm64 -derivedDataPath "$PROJECT_PATH/build_artifacts/arm64" -quiet +mkdir -p "$PROJECT_PATH/build_artifacts/release_arm64" +cp -R "$PROJECT_PATH/build_artifacts/arm64/Build/Products/Release/ScreenTranslate.app" "$PROJECT_PATH/build_artifacts/release_arm64/" +(cd "$PROJECT_PATH/build_artifacts/release_arm64" && zip -r "../../ScreenTranslate-v$VERSION-arm64.zip" ScreenTranslate.app > /dev/null) + +echo "🏗️ 正在编译 x86_64 版本..." +xcodebuild -project "$XCODE_PROJ" -scheme ScreenTranslate -configuration Release -arch x86_64 -derivedDataPath "$PROJECT_PATH/build_artifacts/x86_64" -quiet +mkdir -p "$PROJECT_PATH/build_artifacts/release_x86_64" +cp -R "$PROJECT_PATH/build_artifacts/x86_64/Build/Products/Release/ScreenTranslate.app" "$PROJECT_PATH/build_artifacts/release_x86_64/" +(cd "$PROJECT_PATH/build_artifacts/release_x86_64" && zip -r "../../ScreenTranslate-v$VERSION-x86_64.zip" ScreenTranslate.app > /dev/null) + +echo "📤 正在提交代码..." +git add . +git commit -m "feat: 发布 v$VERSION 版本 - 彻底修复 Retina 缩放并调大译文字号" +git push origin main + +echo "🏷️ 正在创建 Release..." +gh release create "v$VERSION" \ + "$PROJECT_PATH/ScreenTranslate-v$VERSION-arm64.zip" \ + "$PROJECT_PATH/ScreenTranslate-v$VERSION-x86_64.zip" \ + --title "v$VERSION" \ + --notes "Fixed Retina scaling and increased translation font size." + +echo "✅ 发布完成!" diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..1e26290 --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,109 @@ +#!/bin/bash + +# 测试运行脚本 +# 用于 ScreenCapture 项目的测试验证 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "=========================================" +echo "ScreenCapture OCR 测试脚本" +echo "=========================================" +echo "" + +# 颜色定义 +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 1. 验证编译 +echo -e "${YELLOW}[1/3] 验证编译...${NC}" +if xcodebuild -project ScreenCapture.xcodeproj \ + -scheme ScreenCapture \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGN_IDENTITY="" \ + build > /dev/null 2>&1; then + echo -e "${GREEN}✓ 编译通过${NC}" +else + echo -e "${RED}✗ 编译失败${NC}" + exit 1 +fi + +echo "" + +# 2. 运行 SwiftLint(仅检查新创建的 OCR 文件) +echo -e "${YELLOW}[2/3] 运行 SwiftLint...${NC}" +if command -v swiftlint &> /dev/null; then + # 只检查新创建的 OCR 相关文件 + OCR_FILES=( + "ScreenCapture/Models/OCRResult.swift" + "ScreenCapture/Services/OCREngine.swift" + "ScreenCapture/Errors/ScreenCaptureError.swift" + ) + + HAS_ERROR=false + for file in "${OCR_FILES[@]}"; do + if [ -f "$file" ]; then + FILE_RESULT=$(swiftlint lint --path "$file" 2>&1 || true) + if echo "$FILE_RESULT" | grep -q "error:"; then + echo -e "${RED}✗ $file 有错误${NC}" + echo "$FILE_RESULT" + HAS_ERROR=true + fi + fi + done + + if [ "$HAS_ERROR" = true ]; then + exit 1 + else + echo -e "${GREEN}✓ SwiftLint 通过(OCR 相关文件无错误)${NC}" + fi +else + echo -e "${YELLOW}⚠ SwiftLint 未安装,跳过${NC}" +fi + +echo "" + +# 3. 测试文件验证 +echo -e "${YELLOW}[3/3] 验证测试文件...${NC}" + +TEST_FILES=( + "ScreenCaptureTests/OCRResultTests.swift" + "ScreenCaptureTests/OCREngineTests.swift" + "ScreenCaptureTests/ScreenCaptureTests.swift" +) + +ALL_EXISTS=true +for file in "${TEST_FILES[@]}"; do + if [ -f "$file" ]; then + echo " ✓ $file" + else + echo " ✗ $file (不存在)" + ALL_EXISTS=false + fi +done + +if [ "$ALL_EXISTS" = true ]; then + echo -e "${GREEN}✓ 所有测试文件存在${NC}" +else + echo -e "${RED}✗ 部分测试文件缺失${NC}" + exit 1 +fi + +echo "" +echo "=========================================" +echo -e "${GREEN}所有检查通过!${NC}" +echo "=========================================" +echo "" +echo "注意:由于项目使用 Xcode 项目结构(非 SPM)," +echo "无法直接运行 'swift test'。请使用以下方式运行测试:" +echo "" +echo " 1. 在 Xcode 中按 Cmd+U" +echo " 2. 或使用: xcodebuild test -project ScreenCapture.xcodeproj \\" +echo " -scheme ScreenCapture \\" +echo " -destination 'platform=macOS'" +echo "" diff --git a/skills/swiftui-expert-skill b/skills/swiftui-expert-skill new file mode 120000 index 0000000..b931311 --- /dev/null +++ b/skills/swiftui-expert-skill @@ -0,0 +1 @@ +../.agents/skills/swiftui-expert-skill \ No newline at end of file diff --git a/tasks/prd-.md b/tasks/prd-.md new file mode 100644 index 0000000..e70839e --- /dev/null +++ b/tasks/prd-.md @@ -0,0 +1,157 @@ +# PRD: 截图翻译流程优化与显示重构 + +## Overview + +重构截图翻译功能的用户流程和显示方式。当前流程要求用户先等待 OCR 完成才能点击翻译按钮,改为点击即触发(自动完成 OCR + 翻译)。同时将译文显示从底部面板改为直接渲染在图片上,支持两种模式:覆盖原文(使用内容感知填充)或原文下方显示。此外修复截图编辑框尺寸与原始框选不一致的问题。 + +## Goals + +- 简化用户操作流程:一键完成 OCR + 翻译 +- 译文直接显示在图片上,视觉更直观 +- 支持"覆盖原文"和"原文下方"两种显示模式 +- 可保存/复制带译文的图片 +- 编辑框尺寸与原始框选完全一致 + +## Quality Gates + +These commands must pass for every user story: +- `swift build` - 编译通过 +- Xcode 项目编译无错误 + +## User Stories + +### US-001: 翻译按钮始终可点击 +As a user, I want to click the translate button at any time so that I don't have to wait for OCR to complete first. + +**Acceptance Criteria:** +- [ ] 翻译按钮在截图完成后立即可用(不再等待 OCR) +- [ ] `TranslateButtonView` 移除对 `ocrCompleted` 状态的依赖 +- [ ] 按钮样式始终为可点击状态 + +### US-002: 点击翻译自动触发 OCR +As a user, I want clicking translate to automatically perform OCR first (if not done) so that the workflow is seamless. + +**Acceptance Criteria:** +- [ ] 点击翻译时检查 OCR 是否已完成 +- [ ] 若未完成,先执行 OCR,完成后自动继续翻译 +- [ ] 若已完成,直接进行翻译 +- [ ] 显示适当的加载状态(如"正在识别并翻译...") +- [ ] OCR 或翻译失败时显示错误提示 + +### US-003: 读取翻译显示位置设置 +As a user, I want the app to respect my display preference setting so that translations appear where I configured. + +**Acceptance Criteria:** +- [ ] 找到并读取现有的翻译显示位置设置项 +- [ ] 若设置项不存在,新增设置项(覆盖原文 / 原文下方) +- [ ] 翻译完成后根据设置决定显示方式 +- [ ] 设置变更后立即生效 + +### US-004: 覆盖原文模式 - 内容感知填充 +As a user, I want the original text area to be intelligently filled before showing translation so that the result looks clean. + +**Acceptance Criteria:** +- [ ] 使用 OCR 返回的文字块坐标定位原文区域 +- [ ] 尝试使用 macOS 内容感知填充 API(如 Core Image 的 inpainting) +- [ ] 若无合适 API,fallback 到模糊滤镜或背景色填充 +- [ ] 填充后在该区域渲染译文 + +### US-005: 覆盖原文模式 - 译文渲染 +As a user, I want translations to be rendered in place of original text so that I can see the translated content naturally. + +**Acceptance Criteria:** +- [ ] 在填充后的区域绘制译文 +- [ ] 译文字体大小根据区域高度自动调整 +- [ ] 若译文较长,允许向右/下延伸超出原区域 +- [ ] 译文颜色与背景形成足够对比 + +### US-006: 原文下方模式 - 译文渲染 +As a user, I want translations to appear below each text block so that I can compare original and translated text. + +**Acceptance Criteria:** +- [ ] 在每个 OCR 识别的文字块下方显示对应译文 +- [ ] 译文与原文对齐(左对齐或居中,视原文情况) +- [ ] 若译文较长,允许向右/下延伸 +- [ ] 译文使用区分性样式(如不同颜色或半透明背景) + +### US-007: Preview 窗口图片渲染 +As a user, I want to see the translated result directly on the image in the Preview window so that I get immediate visual feedback. + +**Acceptance Criteria:** +- [ ] 在 `ScreenshotPreviewView` 中的图片上渲染译文 +- [ ] 使用 overlay 或自定义绘制层实现 +- [ ] 渲染层不影响原始截图数据 +- [ ] 支持实时切换显示模式 + +### US-008: 保留底部面板作为备用 +As a user, I want the bottom results panel to remain available so that I can still see plain text results if needed. + +**Acceptance Criteria:** +- [ ] 保留 `resultsPanel` 组件和功能 +- [ ] 面板可折叠或默认收起 +- [ ] 面板仍显示原文和译文的纯文本版本 +- [ ] 面板文本可复制 + +### US-009: 保存带译文的图片 +As a user, I want to save the image with translations so that I can keep a permanent copy. + +**Acceptance Criteria:** +- [ ] 添加"保存图片"按钮 +- [ ] 将渲染后的图片(含译文)保存为 PNG/JPEG +- [ ] 提供文件保存对话框选择位置 +- [ ] 保存成功后显示确认提示 + +### US-010: 复制带译文的图片到剪贴板 +As a user, I want to copy the translated image to clipboard so that I can quickly paste it elsewhere. + +**Acceptance Criteria:** +- [ ] 添加"复制图片"按钮 +- [ ] 将渲染后的图片(含译文)复制到系统剪贴板 +- [ ] 复制成功后显示确认提示(如短暂的 toast) +- [ ] 支持直接粘贴到其他应用 + +### US-011: 修复编辑框尺寸与原框选一致 +As a user, I want the screenshot editor to show the exact size I selected so that what I see matches what I captured. + +**Acceptance Criteria:** +- [ ] `ScreenshotPreviewView` 中图片显示为原始尺寸(1:1) +- [ ] 移除任何缩放逻辑或固定尺寸约束 +- [ ] 若图片超出窗口,使用 ScrollView 允许滚动查看 +- [ ] 窗口大小可调整,图片始终保持原始比例 + +## Functional Requirements + +- FR-1: 翻译按钮在 `ScreenshotPreviewView` 加载后立即可用 +- FR-2: 点击翻译触发 `performOCRIfNeeded() -> performTranslation()` 链式调用 +- FR-3: 系统必须读取用户设置中的 `translationDisplayMode` 配置项 +- FR-4: 覆盖模式必须使用 OCR 返回的 `boundingBox` 坐标定位文字区域 +- FR-5: 图片渲染层必须独立于原始图片数据,支持导出 +- FR-6: 编辑窗口必须使用截图的实际像素尺寸 + +## Non-Goals + +- 不实现多语言选择 UI(使用现有设置) +- 不实现手动编辑 OCR 结果功能 +- 不实现译文字体/颜色自定义 +- 不实现实时翻译(逐字显示) +- 不重构 OCR 引擎本身 + +## Technical Considerations + +- **OCR 坐标系统**:VNRecognizedTextObservation 的 boundingBox 使用归一化坐标 (0-1),需转换为图片像素坐标 +- **内容感知填充**:macOS 可能需要使用 Core ML 模型或 Vision 框架,若无现成 API,fallback 到 CIFilter 模糊 +- **图片渲染**:考虑使用 `NSImage` + `NSGraphicsContext` 或 SwiftUI Canvas 进行绘制 +- **剪贴板**:使用 `NSPasteboard` 写入图片数据 + +## Success Metrics + +- 翻译按钮点击后 3 秒内完成 OCR + 翻译(常规尺寸截图) +- 译文正确显示在图片对应位置 +- 保存/复制的图片包含完整译文渲染 +- 编辑框尺寸与框选尺寸像素级一致 + +## Open Questions + +- macOS 是否有开箱即用的 inpainting API?若无,模糊滤镜是否可接受作为 v1 方案? +- 设置项 `translationDisplayMode` 的确切位置和键名需确认 +- 是否需要支持撤销译文渲染(恢复原图)? \ No newline at end of file diff --git a/tasks/prd-macos-screentranslate.md b/tasks/prd-macos-screentranslate.md new file mode 100644 index 0000000..3d026d9 --- /dev/null +++ b/tasks/prd-macos-screentranslate.md @@ -0,0 +1,182 @@ +# PRD: macOS 屏幕翻译工具 (ScreenTranslate) + +## Overview + +一个 macOS 菜单栏应用,允许用户通过快捷键截取屏幕任意区域,自动识别文字(OCR),调用本地 MTranServer 进行翻译,并以覆盖层形式在原位置展示译文(替换原文或在原文下方显示)。 + +## Goals + +- 提供流畅的屏幕取词翻译体验,无需手动复制粘贴 +- 支持两种译文展示模式:原位替换 和 原文下方显示 +- 使用本地 OCR 和翻译服务,保护用户隐私 +- 智能语言检测,同时允许用户手动覆盖 +- 保存翻译历史,方便回顾 + +## Quality Gates + +These commands must pass for every user story: +- `swift build` - Swift 编译检查 +- `swift test` - 单元测试通过 +- `swiftlint` - 代码风格检查(如项目配置) + +## User Stories + +### US-001: 项目初始化和菜单栏基础架构 +As a developer, I want to set up the macOS menu bar app project structure so that subsequent features can be built on a solid foundation. + +**Acceptance Criteria:** +- [ ] 创建 Swift 项目,使用 SwiftUI + AppKit 混合架构 +- [ ] 配置菜单栏图标(StatusBarItem),点击显示下拉菜单 +- [ ] 菜单包含:开始截图、设置、历史记录、退出 +- [ ] 应用启动时不显示 Dock 图标(LSUIElement) +- [ ] 创建配置文件目录 `~/Library/Application Support/ScreenTranslate/` + +### US-002: 全局快捷键注册与管理 +As a user, I want to use a customizable global hotkey to trigger screenshot capture from anywhere. + +**Acceptance Criteria:** +- [ ] 默认快捷键 Cmd+Shift+T 注册成功 +- [ ] 使用 `MASShortcut` 或 `HotKey` 库实现全局快捷键监听 +- [ ] 快捷键可在设置中修改,修改后立即生效 +- [ ] 快捷键冲突时给出友好提示 + +### US-003: 屏幕截图区域选择 +As a user, I want to select any rectangular region on screen for OCR processing. + +**Acceptance Criteria:** +- [ ] 快捷键触发后进入截图模式,屏幕变暗 +- [ ] 鼠标拖拽绘制选区,实时显示选区边框 +- [ ] 支持按 Esc 取消截图 +- [ ] 支持 Retina 屏幕,截图分辨率正确 +- [ ] 选区确定后(鼠标松开)触发 OCR 流程 + +### US-004: PaddleOCR 本地集成 +As a user, I want text to be recognized locally using PaddleOCR for privacy and speed. + +**Acceptance Criteria:** +- [ ] 集成 PaddleOCR C++ 库或调用 Python 脚本 +- [ ] 支持中英文混合识别 +- [ ] OCR 结果包含:文字内容、置信度、每个文字的边界框坐标 +- [ ] 异步执行 OCR,不阻塞主线程 +- [ ] OCR 失败时显示友好错误提示 + +### US-005: MTranServer 翻译集成 +As a user, I want recognized text to be translated using local MTranServer. + +**Acceptance Criteria:** +- [ ] 实现 MTranServer HTTP API 客户端 +- [ ] 支持自动检测源语言(可选配置) +- [ ] 支持配置目标语言(默认跟随系统,可手动覆盖) +- [ ] 翻译请求异步执行,带超时处理(默认 10 秒) +- [ ] 翻译失败时显示原文 + 错误提示 + +### US-006: 覆盖层渲染引擎 - 原位替换模式 +As a user, I want to see translated text overlaid at the exact position of original text. + +**Acceptance Criteria:** +- [ ] 创建透明覆盖窗口,覆盖整个屏幕或选区 +- [ ] 根据 OCR 返回的边界框坐标定位译文 +- [ ] 译文文字样式匹配原文区域(近似字体大小、颜色) +- [ ] 支持点击覆盖层外部关闭 +- [ ] 支持按 Esc 关闭覆盖层 + +### US-007: 覆盖层渲染引擎 - 原文下方模式 +As a user, I want to see translation displayed below the original text area. + +**Acceptance Criteria:** +- [ ] 在选区下方创建浮窗展示完整译文 +- [ ] 浮窗样式美观,带阴影和圆角 +- [ ] 显示原文和译文对照(原文灰色,译文黑色) +- [ ] 支持复制译文到剪贴板 +- [ ] 支持点击外部或按 Esc 关闭 + +### US-008: 设置面板 - 基础配置 +As a user, I want to configure app settings through a preferences window. + +**Acceptance Criteria:** +- [ ] 创建设置窗口,可从菜单栏打开 +- [ ] 快捷键设置:显示当前快捷键,点击可修改 +- [ ] MTranServer 地址配置(默认 localhost:8989) +- [ ] 翻译模式选择:原位替换 / 原文下方 +- [ ] 设置变更立即保存到配置文件 + +### US-009: 设置面板 - 语言配置 +As a user, I want to configure source and target languages for translation. + +**Acceptance Criteria:** +- [ ] 源语言选项:自动检测、中文、英文、日文等 +- [ ] 目标语言选项:跟随系统、中文、英文等 +- [ ] 语言列表从 MTranServer 动态获取支持的语言对 +- [ ] 语言配置保存并立即生效 + +### US-010: 翻译历史记录 +As a user, I want to view and manage my recent translation history. + +**Acceptance Criteria:** +- [ ] 每次翻译保存记录:时间、原文、译文、截图缩略图 +- [ ] 历史记录窗口可从菜单栏打开 +- [ ] 显示最近 50 条记录,支持滚动加载更多 +- [ ] 支持搜索历史记录(按原文或译文内容) +- [ ] 支持删除单条或清空全部历史 + +### US-011: 首次启动引导 +As a new user, I want to be guided through initial setup on first launch. + +**Acceptance Criteria:** +- [ ] 检测首次启动,显示欢迎窗口 +- [ ] 引导用户配置 MTranServer 地址 +- [ ] 请求屏幕录制权限(macOS 隐私权限) +- [ ] 请求辅助功能权限(用于全局快捷键) +- [ ] 提供测试翻译按钮验证配置 + +## Functional Requirements + +- FR-1: 应用以菜单栏图标形式常驻,不占用 Dock +- FR-2: 全局快捷键触发后,用户可通过拖拽选择屏幕区域 +- FR-3: 选中区域自动进行 OCR 文字识别 +- FR-4: 识别出的文字发送至 MTranServer 进行翻译 +- FR-5: 支持两种译文展示模式: + - FR-5.1: 原位替换 - 译文覆盖在原文位置 + - FR-5.2: 原文下方 - 译文显示在选区下方的浮窗中 +- FR-6: 覆盖层支持点击外部或按 Esc 关闭 +- FR-7: 快捷键可在设置中自定义,默认 Cmd+Shift+T +- FR-8: 源语言支持自动检测或手动指定 +- FR-9: 目标语言默认跟随系统,可手动覆盖 +- FR-10: 翻译历史自动保存,支持查看、搜索、删除 +- FR-11: 应用启动时检查并请求必要的系统权限 +- FR-12: 所有网络请求(MTranServer)使用本地地址,不泄露数据 + +## Non-Goals + +- 不支持 Windows/Linux 平台 +- 不支持云端 OCR 服务(仅本地 PaddleOCR) +- 不支持批量图片翻译 +- 不支持 PDF 文档翻译 +- 不支持翻译结果的持久化同步(如 iCloud) +- 不支持离线翻译(仍需本地运行 MTranServer) +- 不支持手写文字识别 +- 不支持竖排文字的原位替换展示 + +## Technical Considerations + +- **OCR 引擎**: PaddleOCR C++ 库通过 Swift Package Manager 或桥接头集成,或作为外部进程调用 +- **截图实现**: 使用 `CGDisplayStream` 或 `SCScreenshotManager` (macOS 12.3+) 获取屏幕内容 +- **覆盖层窗口**: 使用 `NSPanel` 配合 `NSWindow.Level` 设置为 `.screenSaver` 或更高 +- **权限处理**: 屏幕录制权限(kTCCServiceScreenCapture)和辅助功能权限(Accessibility) +- **性能**: OCR 过程可能耗时,需在后台线程执行,避免 UI 卡顿 +- **内存管理**: 历史记录中的截图缩略图需要压缩存储,避免内存膨胀 + +## Success Metrics + +- 截图到展示译文的端到端延迟 < 3 秒(M1 Mac 标准) +- OCR 识别准确率 > 90%(标准印刷体) +- 应用内存占用 < 200MB +- 快捷键响应延迟 < 100ms +- 崩溃率 < 0.1% + +## Open Questions + +- PaddleOCR 模型文件如何分发?(随应用打包还是首次下载) +- 是否需要支持多显示器环境? +- 原位替换模式下,如何处理文字长度差异过大的情况? +- 是否需要支持翻译结果的语音朗读? \ No newline at end of file diff --git a/tasks/prd-screencoder-kiss-translator.md b/tasks/prd-screencoder-kiss-translator.md new file mode 100644 index 0000000..b728551 --- /dev/null +++ b/tasks/prd-screencoder-kiss-translator.md @@ -0,0 +1,306 @@ +# ScreenTranslate 架构重构需求文档 +## 从 OCR 后翻译迁移至 ScreenCoder + KISS 风格翻译方案 + +--- + +## 1. 项目背景 + +### 1.1 当前架构 + +``` +截图捕获 → Vision OCR → 纯文本 → Apple Translation → 翻译结果 +``` + +**问题:** OCR 丢失布局信息,翻译服务单一,无法实现双语对照显示。 + +### 1.2 目标架构 + +``` +截图捕获 → ScreenCoder (VLM) → 结构化文本+位置 → 多引擎翻译 → 双语对照渲染 +``` + +**核心改进:** +- **ScreenCoder**:用 VLM 替代 OCR,提取文本同时保留精确位置信息 +- **KISS 风格翻译**:借鉴 KISS Translator 的多引擎架构,自行实现 Provider 层(非接入 KISS 客户端) +- **双语对照**:在原始截图上叠加翻译结果,实现视觉对应 + +--- + +## 2. 功能需求 + +### FR-001: ScreenCoder 引擎 - 文本提取与定位 + +**描述:** 使用 VLM 分析截图,提取所有可见文本及其精确边界框。 + +**输入:** 截图图像 (CGImage) + +**输出:** +```swift +struct TextSegment: Identifiable, Sendable { + let id: UUID + let text: String // 原始文本 + let boundingBox: CGRect // 在图像中的位置 (归一化坐标 0-1) + let confidence: Float // 识别置信度 +} + +struct ScreenAnalysisResult: Sendable { + let segments: [TextSegment] + let imageSize: CGSize +} +``` + +**VLM Prompt 示例:** +``` +分析这张截图,提取所有可见文本。 +对每段文本,返回 JSON 格式: +{ + "segments": [ + {"text": "文本内容", "bbox": [x1, y1, x2, y2]} + ] +} +bbox 使用归一化坐标 (0-1)。 +``` + +**验收标准:** +- [ ] 正确提取截图中所有可读文本 +- [ ] 边界框定位精度 ≥ 90% +- [ ] 支持中英日韩混合文本 + +--- + +### FR-002: 双引擎翻译服务 + +**描述:** 支持 macOS 原生翻译 + MTransServer 本地翻译服务器。 + +**支持的引擎:** + +| 引擎 | 描述 | 优先级 | +|------|------|--------| +| **macOS 原生** | Apple Translation 框架,离线可用,系统级集成 | P0 | +| **MTransServer** | 本地部署的翻译服务器,支持多种模型 | P0 | + +**接口设计:** +```swift +protocol TranslationProvider: Sendable { + var id: String { get } + var name: String { get } + var isAvailable: Bool { get async } + + func translate( + texts: [String], + from: LanguageCode?, + to: LanguageCode + ) async throws -> [String] +} + +// 实现 +// 1. AppleTranslationProvider - 使用 Translation 框架 +// 2. MTransServerProvider - 调用本地 HTTP API +``` + +**MTransServer API 格式:** +```json +// 请求 POST /translate +{ + "text": "Hello World", + "source_lang": "en", + "target_lang": "zh" +} + +// 响应 +{ + "translation": "你好世界" +} +``` + +**配置项:** +```swift +struct TranslationConfig { + var preferredProvider: ProviderType = .apple // .apple | .mtransserver + var mtransServerURL: URL = URL(string: "http://localhost:8989")! + var fallbackEnabled: Bool = true // 失败时切换到备选引擎 +} +``` + +**验收标准:** +- [ ] macOS 原生翻译正常工作(复用现有 TranslationEngine) +- [ ] MTransServer 连接与翻译正常 +- [ ] 支持引擎优先级选择 +- [ ] 翻译失败时自动 fallback 到备选引擎 + +--- + +### FR-003: 双语对照渲染 + +**描述:** 将翻译结果以双语对照形式叠加在原始截图上。 + +**显示效果:** +``` +┌─────────────────────────────┐ +│ [原文区域] │ +│ ┌───────────────────────┐ │ +│ │ Hello World │ │ ← 原始文本位置 +│ │ ───────────────────── │ │ +│ │ 你好世界 │ │ ← 译文紧跟其下 +│ └───────────────────────┘ │ +│ │ +│ [另一原文区域] │ +│ ┌───────────────────────┐ │ +│ │ Settings │ │ +│ │ ───────────────────── │ │ +│ │ 设置 │ │ +│ └───────────────────────┘ │ +└─────────────────────────────┘ +``` + +**实现方式:** +```swift +struct BilingualOverlay { + let originalImage: CGImage + let segments: [BilingualSegment] +} + +struct BilingualSegment { + let original: TextSegment // 原文 + 位置 + let translated: String // 译文 +} + +@MainActor +final class OverlayRenderer { + func render(_ overlay: BilingualOverlay) -> NSImage { + // 1. 绘制原始截图 + // 2. 在每个 segment 位置下方绘制译文 + // 3. 可选:半透明背景提高可读性 + } +} +``` + +**样式配置:** +```swift +struct OverlayStyle { + var translationFont: NSFont = .systemFont(ofSize: 12) + var translationColor: NSColor = .systemBlue + var backgroundColor: NSColor = .white.withAlphaComponent(0.8) + var padding: CGFloat = 4 +} +``` + +**验收标准:** +- [ ] 译文位置与原文对应准确 +- [ ] 支持自定义字体、颜色 +- [ ] 长文本自动换行或截断 +- [ ] 渲染结果可导出为图片 + +--- + +## 3. 技术架构 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Feature Layer │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ TranslationFlowController │ │ +│ │ 1. 接收截图 │ │ +│ │ 2. 调用 ScreenCoder 提取文本 │ │ +│ │ 3. 调用 TranslationService 翻译 │ │ +│ │ 4. 调用 OverlayRenderer 渲染双语对照 │ │ +│ └─────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────┐ +│ Service Layer │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ +│ │ScreenCoder │ │Translation │ │ Overlay │ │ +│ │Engine │ │Service │ │ Renderer │ │ +│ │(VLM调用) │ │(多Provider) │ │ (双语渲染) │ │ +│ └─────────────┘ └──────┬──────┘ └─────────────────┘ │ +│ │ │ +│ ┌────────────────┼────────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │GoogleProvider│ │OpenAIProvider│ │OllamaProvider│ │ +│ └────────────┘ └────────────┘ └────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────┐ +│ Model Layer │ +│ TextSegment, BilingualSegment, OverlayStyle, etc. │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. 数据流 + +``` +用户截图 (CGImage) + │ + ▼ +ScreenCoderEngine.analyze(image) + │ + ▼ +ScreenAnalysisResult { segments: [TextSegment] } + │ + ▼ +TranslationService.translate(segments, to: targetLang) + │ ├─ 选择 Provider (Google/OpenAI/Ollama) + │ ├─ 批量请求:["Hello", "Settings", ...] → ["你好", "设置", ...] + │ └─ 组装结果 + ▼ +[BilingualSegment] (原文+译文+位置) + │ + ▼ +OverlayRenderer.render(image, segments) + │ + ▼ +NSImage (双语对照截图) +``` + +--- + +## 5. 文件结构 + +``` +ScreenTranslate/ +├── Services/ +│ ├── ScreenCoderEngine.swift # VLM 文本提取 +│ ├── TranslationService.swift # 翻译编排层 +│ ├── Providers/ +│ │ ├── TranslationProvider.swift # Provider 协议 +│ │ ├── AppleTranslationProvider.swift # macOS 原生翻译 +│ │ └── MTransServerProvider.swift # MTransServer 本地服务 +│ └── OverlayRenderer.swift # 双语渲染 +├── Models/ +│ ├── TextSegment.swift +│ ├── BilingualSegment.swift +│ └── OverlayStyle.swift +└── Features/ + └── Translation/ + └── TranslationFlowController.swift +``` + +--- + +## 6. 验收标准 + +### 功能验收 +- [ ] ScreenCoder 能正确提取截图文本及位置 +- [ ] 至少 2 个翻译引擎可正常工作 +- [ ] 双语对照渲染效果符合设计 +- [ ] 翻译失败时显示友好错误提示 + +### 性能验收 +- [ ] 截图分析 < 3s +- [ ] 翻译延迟 < 2s(网络正常时) +- [ ] 渲染延迟 < 500ms + +--- + +## 7. 参考资料 + +- [ScreenCoder GitHub](https://github.com/leigest519/ScreenCoder) - VLM UI 理解框架 +- [KISS Translator Custom API](https://github.com/fishjar/kiss-translator/blob/main/custom-api_v2.md) - 多引擎翻译 API 设计参考 + +--- + +*v1.1 - 2026-02-06 - 精简版,聚焦双语对照核心功能* diff --git a/tasks/prd-screencoder.md b/tasks/prd-screencoder.md new file mode 100644 index 0000000..8501e57 --- /dev/null +++ b/tasks/prd-screencoder.md @@ -0,0 +1,235 @@ +# PRD: ScreenCoder 双语翻译模式 + +## Overview + +将 ScreenTranslate 的翻译功能从现有截图-标注流程中独立出来,创建全新的「双语翻译模式」。用户通过独立快捷键触发,直接框选屏幕区域,使用 VLM (Vision Language Model) 提取文本及位置信息,调用多引擎翻译服务,最终在专用窗口中呈现双语对照结果。 + +核心改进: +- **ScreenCoder 引擎**:用 VLM 替代 OCR,提取文本同时保留精确位置 +- **KISS 风格翻译**:借鉴 KISS Translator 的多引擎架构,自行实现 Provider 层 +- **双语对照窗口**:独立窗口展示原文+译文的视觉对应 + +## Goals + +- 实现独立于截图-标注的翻译入口(快捷键 + 菜单) +- 使用 VLM 提取屏幕文本及其精确边界框位置 +- 支持多 VLM 提供商:OpenAI GPT-4V、Claude Vision、Ollama 本地模型 +- 支持双翻译引擎:macOS 原生翻译 + MTransServer +- 在专用窗口中渲染双语对照结果 +- 翻译引擎失败时自动 fallback 到备选引擎 + +## Quality Gates + +These commands must pass for every user story: +- `xcodebuild -scheme ScreenTranslate build` - 编译通过 +- SwiftLint 检查通过(如项目已配置) + +UI 功能手动验证即可。 + +## User Stories + +### US-001: 创建独立翻译入口 +As a user, I want a dedicated shortcut and menu entry for translation mode so that I can translate screen content without going through the screenshot annotation flow. + +**Acceptance Criteria:** +- [ ] 新增全局快捷键(如 ⌘⇧T)触发翻译模式 +- [ ] 菜单栏添加「翻译模式」入口 +- [ ] 快捷键可在设置中自定义 +- [ ] 触发后进入区域框选状态(复用现有 `SelectionOverlayView` 或新建) + +### US-002: 实现区域框选捕获 +As a user, I want to select a screen region for translation so that I can choose exactly what content to translate. + +**Acceptance Criteria:** +- [ ] 触发翻译模式后显示全屏半透明遮罩 +- [ ] 用户可拖拽框选任意矩形区域 +- [ ] 框选完成后捕获该区域为 CGImage +- [ ] 支持 ESC 取消框选 +- [ ] 框选区域最小尺寸限制(避免误触) + +### US-003: 定义 TextSegment 和 ScreenAnalysisResult 模型 +As a developer, I want well-defined data models for text extraction results so that VLM output can be structured and passed through the pipeline. + +**Acceptance Criteria:** +- [ ] 创建 `TextSegment` 结构体:id, text, boundingBox (CGRect, 归一化坐标 0-1), confidence +- [ ] 创建 `ScreenAnalysisResult` 结构体:segments, imageSize +- [ ] 所有模型遵循 `Sendable` 协议 +- [ ] 添加必要的 Codable 支持(用于 JSON 解析) + +### US-004: 实现 VLM Provider 协议 +As a developer, I want a unified protocol for VLM providers so that different vision models can be swapped without changing business logic. + +**Acceptance Criteria:** +- [ ] 创建 `VLMProvider` 协议,定义 `analyze(image:) async throws -> ScreenAnalysisResult` +- [ ] 协议包含 `id`, `name`, `isAvailable` 属性 +- [ ] 支持配置项:apiKey, baseURL, modelName +- [ ] 定义标准化的 VLM Prompt 模板(提取文本+bbox 的 JSON 格式) + +### US-005: 实现 OpenAI Vision Provider +As a user, I want to use OpenAI GPT-4V/GPT-4o for text extraction so that I can leverage OpenAI's vision capabilities. + +**Acceptance Criteria:** +- [ ] 实现 `OpenAIVLMProvider` 遵循 `VLMProvider` 协议 +- [ ] 支持配置:API Key, Base URL (可自定义), Model Name +- [ ] 正确处理 base64 图像编码 +- [ ] 解析 JSON 响应为 `ScreenAnalysisResult` +- [ ] 处理 API 错误(rate limit, invalid key, timeout) + +### US-006: 实现 Claude Vision Provider +As a user, I want to use Claude Vision for text extraction so that I have an alternative to OpenAI. + +**Acceptance Criteria:** +- [ ] 实现 `ClaudeVLMProvider` 遵循 `VLMProvider` 协议 +- [ ] 支持配置:API Key, Base URL, Model Name +- [ ] 使用 Anthropic Messages API 格式 +- [ ] 正确处理图像 media type 和 base64 编码 +- [ ] 解析响应为 `ScreenAnalysisResult` + +### US-007: 实现 Ollama Vision Provider +As a user, I want to use local Ollama models for text extraction so that I can work offline without API costs. + +**Acceptance Criteria:** +- [ ] 实现 `OllamaVLMProvider` 遵循 `VLMProvider` 协议 +- [ ] 支持配置:Base URL (默认 localhost:11434), Model Name (如 llava, qwen-vl) +- [ ] 使用 Ollama API 格式发送图像 +- [ ] 实现连接检测(`isAvailable`) +- [ ] 解析响应为 `ScreenAnalysisResult` + +### US-008: 创建 ScreenCoder 引擎 +As a developer, I want a unified ScreenCoder engine that manages VLM providers so that the translation flow has a single entry point for text extraction. + +**Acceptance Criteria:** +- [ ] 创建 `ScreenCoderEngine` actor/class +- [ ] 管理多个 VLM Provider 实例 +- [ ] 根据用户配置选择当前 Provider +- [ ] 提供 `analyze(image:) async throws -> ScreenAnalysisResult` 方法 +- [ ] 封装 Provider 切换逻辑 + +### US-009: 扩展 MTransServerProvider 翻译能力 +As a user, I want MTransServer to work as a translation provider so that I can use my local translation server. + +**Acceptance Criteria:** +- [ ] 确认现有 `MTranServerEngine` 可复用或需要适配 +- [ ] 实现 `TranslationProvider` 协议(如需新建) +- [ ] 支持批量翻译接口 `translate(texts:from:to:)` +- [ ] 正确处理 MTransServer API(POST /translate) +- [ ] 实现连接状态检测 + +### US-010: 创建 TranslationService 编排层 +As a developer, I want a TranslationService that orchestrates multiple translation providers so that fallback logic is centralized. + +**Acceptance Criteria:** +- [ ] 创建 `TranslationService` actor/class +- [ ] 管理 AppleTranslationProvider 和 MTransServerProvider +- [ ] 根据用户配置选择首选 Provider +- [ ] 实现 fallback 逻辑:首选失败时切换备选 +- [ ] 提供 `translate(segments:to:) async throws -> [BilingualSegment]` + +### US-011: 定义 BilingualSegment 和 OverlayStyle 模型 +As a developer, I want models for bilingual content and rendering style so that the overlay renderer has structured input. + +**Acceptance Criteria:** +- [ ] 创建 `BilingualSegment` 结构体:original (TextSegment), translated (String) +- [ ] 创建 `OverlayStyle` 结构体:translationFont, translationColor, backgroundColor, padding +- [ ] 提供合理的默认样式值 +- [ ] 样式支持用户配置 + +### US-012: 实现 OverlayRenderer 双语渲染 +As a developer, I want an OverlayRenderer that draws bilingual content on the original image so that users see translations in context. + +**Acceptance Criteria:** +- [ ] 创建 `OverlayRenderer` 类 +- [ ] 输入:原始 CGImage + [BilingualSegment] + OverlayStyle +- [ ] 输出:NSImage(双语对照图) +- [ ] 在每个原文位置下方绘制译文 +- [ ] 译文带半透明背景提高可读性 +- [ ] 长文本自动换行处理 + +### US-013: 创建双语对照展示窗口 +As a user, I want a dedicated window to display bilingual translation results so that I can review and interact with translations. + +**Acceptance Criteria:** +- [ ] 创建 `BilingualResultWindow` (NSWindow/SwiftUI) +- [ ] 显示渲染后的双语对照图像 +- [ ] 支持图像缩放和滚动 +- [ ] 提供「复制图片」按钮 +- [ ] 提供「保存图片」按钮 +- [ ] 窗口可调整大小 +- [ ] ESC 或关闭按钮关闭窗口 + +### US-014: 实现 TranslationFlowController 主流程 +As a developer, I want a TranslationFlowController that orchestrates the entire translation flow so that all components work together. + +**Acceptance Criteria:** +- [ ] 创建 `TranslationFlowController` +- [ ] 流程:接收 CGImage → ScreenCoder 提取 → TranslationService 翻译 → OverlayRenderer 渲染 → 显示窗口 +- [ ] 处理各阶段错误并显示用户友好提示 +- [ ] 显示处理进度指示器 +- [ ] 支持取消正在进行的翻译 + +### US-015: 添加 VLM 和翻译配置 UI +As a user, I want settings UI to configure VLM providers and translation preferences so that I can customize the translation behavior. + +**Acceptance Criteria:** +- [ ] 在设置中添加「翻译模式」配置区 +- [ ] VLM 配置:选择 Provider (OpenAI/Claude/Ollama) +- [ ] VLM 配置:API Key, Base URL, Model Name 输入框 +- [ ] 翻译配置:首选引擎 (Apple/MTransServer) +- [ ] 翻译配置:MTransServer URL +- [ ] 翻译配置:Fallback 开关 +- [ ] 配置持久化到 SettingsManager + +### US-016: 集成快捷键到 AppDelegate +As a user, I want the translation shortcut to work globally so that I can trigger translation from any app. + +**Acceptance Criteria:** +- [ ] 在 AppDelegate 或 HotKeyManager 注册翻译模式快捷键 +- [ ] 快捷键触发 TranslationFlowController 启动框选 +- [ ] 与现有快捷键不冲突 +- [ ] 快捷键禁用/启用状态正确响应 + +## Functional Requirements + +- FR-1: 翻译模式必须通过独立快捷键触发,与截图-标注流程完全分离 +- FR-2: 用户框选区域后,系统必须捕获该区域图像并传入 VLM +- FR-3: VLM 必须返回所有识别文本及其归一化边界框坐标 +- FR-4: 翻译服务必须支持 Apple Translation 和 MTransServer 两种引擎 +- FR-5: 翻译失败时必须自动尝试备选引擎(如 fallback 已启用) +- FR-6: 双语对照结果必须在独立窗口中显示,译文位置与原文对应 +- FR-7: 用户必须能够从结果窗口复制或保存双语对照图片 +- FR-8: 所有 VLM Provider 必须支持完整配置(API Key + Base URL + Model Name) +- FR-9: 处理过程中必须显示进度指示,支持用户取消 + +## Non-Goals + +- 不替换现有 OCR 功能(OCR 保留用于文字识别复制) +- 不与截图-标注流程集成(完全独立) +- ScreenCoder 不 fallback 到 OCR(仅使用 VLM) +- 不实现系统主题自动检测 +- 不支持自定义翻译 prompt +- 不支持翻译历史记录(本期) +- 不支持翻译结果编辑(本期) + +## Technical Considerations + +- 复用现有 `SelectionOverlayView` 进行区域框选,或创建轻量级版本 +- VLM 返回的 bbox 使用归一化坐标 (0-1),渲染时需转换为实际像素坐标 +- 考虑 VLM 调用的超时处理(建议 30s) +- MTransServer API 需确认实际端点格式是否为 `POST /translate` +- 使用 `@MainActor` 确保 UI 更新在主线程 +- 翻译请求考虑批量发送以减少 API 调用次数 + +## Success Metrics + +- 文本提取准确率 ≥ 90%(可读文本被正确识别) +- 边界框定位精度 ≥ 85%(译文位置与原文基本对应) +- 端到端延迟 < 5s(网络正常情况下) +- 两个翻译引擎均可正常工作 +- 用户可成功保存/复制双语对照图片 + +## Open Questions + +- MTransServer 批量翻译 API 是否支持?还是需要逐条调用? +- 是否需要支持指定源语言?还是始终自动检测? +- 双语对照窗口是否需要支持「仅显示译文」模式? +- 是否需要在结果窗口提供「重新翻译」按钮? \ No newline at end of file diff --git a/tasks/prd-text-translation.json b/tasks/prd-text-translation.json new file mode 100644 index 0000000..15314fb --- /dev/null +++ b/tasks/prd-text-translation.json @@ -0,0 +1,232 @@ +{ + "name": "Text Selection and Input Translation", + "description": "Add text selection translation (select text → hotkey → translation popup showing original + translated text) and input translation (hotkey → translate clipboard → insert into current input field) features to ScreenTranslate", + "branchName": "ralph/text-input-translation", + "userStories": [ + { + "id": "US-001", + "title": "Create TextSelectionService for capturing selected text", + "description": "As a developer, I need a service that can capture currently selected text from any application using macOS Accessibility APIs or clipboard simulation.", + "acceptanceCriteria": [ + "Create TextSelectionService.swift in Services directory", + "Implement clipboard-based capture: simulate Cmd+C via CGEvent to copy selection", + "Preserve original clipboard content before capture, restore after", + "Return selected text as String with source application info", + "Handle edge cases: no selection, protected text fields", + "Add error handling for accessibility/clipboard access failures", + "swift build compiles without errors" + ], + "priority": 1, + "passes": true, + "notes": "", + "dependsOn": [], + "completionNotes": "Completed by agent" + }, + { + "id": "US-002", + "title": "Add keyboard shortcut for text selection translation", + "description": "As a user, I want to press a hotkey to translate currently selected text in any application.", + "acceptanceCriteria": [ + "Add new KeyboardShortcut definition: textSelectionTranslationShortcut", + "Default shortcut: Command+Shift+T (or user-configurable)", + "Register hotkey in HotkeyManager with unique identifier", + "Integrate with AppDelegate hotkey registration flow", + "Shortcut should not conflict with existing capture shortcuts (Cmd+Shift+3/4)", + "swift build compiles without errors" + ], + "priority": 2, + "passes": true, + "notes": "", + "dependsOn": [ + "US-001" + ], + "completionNotes": "Completed by agent" + }, + { + "id": "US-003", + "title": "Create TextTranslationFlow for plain text translation", + "description": "As a developer, I need a translation flow that handles plain text input without OCR/image analysis.", + "acceptanceCriteria": [ + "Create TextTranslationFlow.swift in Features/TextTranslation directory", + "Accept plain text string as input", + "Reuse existing TranslationService for actual translation", + "Support source language auto-detection via TranslationEngine", + "Return BilingualSegment array with original and translated text", + "Handle translation errors gracefully with user feedback", + "swift build compiles without errors" + ], + "priority": 3, + "passes": true, + "notes": "", + "dependsOn": [ + "US-001" + ], + "completionNotes": "Completed by agent" + }, + { + "id": "US-004", + "title": "Create TextTranslationPopup window for showing translation results", + "description": "As a user, I want to see the original text and translated text in a popup window after selecting text and pressing the translation hotkey.", + "acceptanceCriteria": [ + "Create TextTranslationPopupController.swift and TextTranslationPopupView.swift", + "Window style: borderless panel with rounded corners (similar to TranslationPopover)", + "Display original text section with source language label", + "Display translated text section with target language label", + "Add visual separator between original and translation", + "Window positions near mouse cursor, respects screen bounds", + "Dismiss on Escape key, click outside, or focus loss", + "Support light/dark mode theming via existing DesignSystem", + "swift build compiles without errors" + ], + "priority": 4, + "passes": true, + "notes": "", + "dependsOn": [ + "US-003" + ], + "completionNotes": "Completed by agent" + }, + { + "id": "US-005", + "title": "Add copy and insert buttons to translation popup", + "description": "As a user, I want to copy the translation to clipboard or insert it into the current input field directly from the popup.", + "acceptanceCriteria": [ + "Add 'Copy' button that copies translated text using ClipboardService", + "Add 'Insert' button that types translated text into focused input field", + "Use CGEventPostToPSN or CGEventPost for keyboard simulation", + "Show visual feedback (checkmark) on successful action", + "Support keyboard shortcuts: Cmd+C for copy, Enter for insert", + "Insert action closes popup after typing text", + "swift build compiles without errors" + ], + "priority": 5, + "passes": true, + "notes": "", + "dependsOn": [ + "US-004" + ], + "completionNotes": "Completed by agent" + }, + { + "id": "US-006", + "title": "Wire up text selection translation in AppDelegate", + "description": "As a developer, I need to connect all components so the hotkey triggers the complete translation flow.", + "acceptanceCriteria": [ + "Add handleTextSelectionTranslation() method in AppDelegate", + "Capture selected text via TextSelectionService", + "Show loading indicator (can reuse existing loading UI)", + "Trigger TextTranslationFlow with captured text", + "Display TextTranslationPopup with translation result", + "Handle empty selection with user notification (no crash)", + "Prevent concurrent translation operations with isTranslating flag", + "swift build compiles without errors" + ], + "priority": 6, + "passes": true, + "notes": "", + "dependsOn": [ + "US-002", + "US-003", + "US-004", + "US-005" + ], + "completionNotes": "Completed by agent" + }, + { + "id": "US-007", + "title": "Add translate-and-insert keyboard shortcut", + "description": "As a user, I want a separate hotkey that translates clipboard content and directly inserts it into the current input field without showing a popup.", + "acceptanceCriteria": [ + "Add new KeyboardShortcut: translateAndInsertShortcut", + "Default shortcut: Command+Shift+I (user-configurable)", + "Register hotkey in HotkeyManager", + "Capture clipboard content via NSPasteboard", + "Translate using TextTranslationFlow", + "Insert translated text directly into focused input field via CGEvent", + "Show brief success notification (NSUserNotification or similar)", + "Handle empty clipboard with user notification", + "swift build compiles without errors" + ], + "priority": 7, + "passes": true, + "notes": "", + "dependsOn": [ + "US-003" + ], + "completionNotes": "Completed by agent" + }, + { + "id": "US-008", + "title": "Add settings UI for text translation hotkeys", + "description": "As a user, I want to configure the text selection and translate-and-insert hotkeys in the Settings window.", + "acceptanceCriteria": [ + "Add TextTranslationSettingsTab.swift in Features/Settings directory", + "Create UI section for 'Text Translation Shortcuts'", + "Show text selection translation hotkey with edit capability", + "Show translate-and-insert hotkey with edit capability", + "Reuse ShortcutRecorder component pattern from ShortcutSettingsTab", + "Save changes to AppSettings with proper UserDefaults persistence", + "Apply changes immediately without app restart", + "swift build compiles without errors" + ], + "priority": 8, + "passes": true, + "notes": "", + "dependsOn": [ + "US-002", + "US-007" + ], + "completionNotes": "Completed by agent" + }, + { + "id": "US-009", + "title": "Handle accessibility and input monitoring permissions", + "description": "As a user, I need proper prompts when the app requires accessibility permission for text capture and input simulation.", + "acceptanceCriteria": [ + "Request Accessibility permission before first text capture attempt", + "Show user-friendly dialog explaining why permission is needed", + "Provide button to open System Preferences > Security & Privacy > Accessibility", + "Handle permission denied with informative error in popup", + "Request Input Monitoring permission if needed for keyboard simulation", + "Cache permission status to avoid repeated system dialogs", + "swift build compiles without errors" + ], + "priority": 9, + "passes": true, + "notes": "", + "dependsOn": [ + "US-001" + ], + "completionNotes": "Completed by agent" + }, + { + "id": "US-010", + "title": "Integration testing and edge case handling", + "description": "As a developer, I need to ensure all features work correctly across different applications and scenarios.", + "acceptanceCriteria": [ + "Test text selection translation in Safari, Chrome, Mail, Notes, Terminal, VS Code", + "Test with CJK characters (Chinese, Japanese, Korean)", + "Test with RTL languages (Arabic, Hebrew)", + "Test translate-and-insert with empty clipboard", + "Test with very long text selections (truncate display if needed)", + "Test popup positioning at screen edges (top, bottom, left, right)", + "Test hotkey conflicts with system shortcuts", + "Test rapid successive translation requests (throttle/debounce)", + "All manual tests pass without crashes or hangs" + ], + "priority": 10, + "passes": true, + "notes": "", + "dependsOn": [ + "US-006", + "US-007", + "US-008", + "US-009" + ], + "completionNotes": "Completed by agent" + } + ], + "metadata": { + "updatedAt": "2026-02-13T07:30:40.575Z" + } +} \ No newline at end of file diff --git a/tasks/prd.json b/tasks/prd.json new file mode 100644 index 0000000..98c4ee8 --- /dev/null +++ b/tasks/prd.json @@ -0,0 +1,118 @@ +{ + "name": "Window Detection Auto-Select", + "description": "在区域选择模式中增加窗口自动识别能力,支持悬停高亮窗口并单击选取,同时保留拖拽自定义选区功能", + "branchName": "ralph/window-detection-auto-select", + "userStories": [ + { + "id": "US-001", + "title": "创建 WindowDetector 窗口检测服务", + "description": "作为开发者,我需要封装 CGWindowListCopyWindowInfo API 来检测鼠标光标下方的窗口", + "acceptanceCriteria": [ + "创建 WindowDetector.swift 文件在 ScreenTranslate/Services/ 目录", + "实现 WindowInfo 结构体包含 windowID、frame、ownerName、windowName、windowLayer", + "实现 windowUnderPoint(_:) 方法返回光标下方最顶层可见窗口", + "实现 visibleWindows() 方法返回所有可见窗口列表", + "过滤掉 kCGWindowLayer != 0 的系统级窗口(Dock、Menu Bar)", + "过滤掉自身应用的覆盖层窗口", + "窗口列表按 Z-order 排序", + "代码通过 Swift 编译,无警告" + ], + "priority": 1, + "passes": true, + "notes": "", + "dependsOn": [], + "completionNotes": "Completed by agent" + }, + { + "id": "US-002", + "title": "SelectionOverlayView 窗口高亮绘制", + "description": "作为用户,我悬停在窗口上时能看到高亮框标识出窗口边界", + "acceptanceCriteria": [ + "在 SelectionOverlayView 中新增 highlightedWindowRect 属性存储当前高亮窗口 frame", + "新增 windowDetector 实例用于窗口检测", + "在 mouseMoved 中调用 windowDetector.windowUnderPoint 检测光标下方窗口", + "将窗口 frame 从 Quartz 坐标系转换为 view 坐标系", + "在 draw(_:) 中绘制窗口高亮框:蓝色填充 (alpha 0.08) + 蓝色边框 (alpha 0.5, 2pt)", + "暗色覆盖层在窗口区域挖洞(使用 even-odd fill rule)", + "窗口高亮时显示尺寸标签(复用 drawDimensionsLabel)", + "光标移到无窗口区域时高亮框消失" + ], + "priority": 2, + "passes": true, + "notes": "", + "dependsOn": [ + "US-001" + ], + "completionNotes": "Completed by agent" + }, + { + "id": "US-003", + "title": "点击与拖拽区分逻辑", + "description": "作为用户,我可以单击选取高亮窗口,也可以拖拽进行自定义区域选择", + "acceptanceCriteria": [ + "新增 dragThreshold 常量(4px)用于区分点击和拖拽", + "新增 mouseDownPoint 属性记录鼠标按下位置", + "改造 mouseDown:记录起始点,刷新窗口缓存,不立即开始选区", + "改造 mouseDragged:计算移动距离,超过阈值后进入拖拽模式", + "拖拽模式启动时清除 highlightedWindowRect,设置 selectionStart/selectionCurrent", + "改造 mouseUp:判断 isDragging 状态,区分点击和拖拽", + "点击模式:若 highlightedWindowRect 存在,将其转换为 display-relative 坐标并调用 delegate", + "点击模式:若 highlightedWindowRect 不存在,调用 selectionOverlayDidCancel", + "拖拽模式:执行现有的选区完成逻辑", + "重置所有状态变量" + ], + "priority": 3, + "passes": true, + "notes": "", + "dependsOn": [ + "US-002" + ], + "completionNotes": "Completed by agent" + }, + { + "id": "US-004", + "title": "性能优化与边界情况处理", + "description": "作为开发者,我需要确保窗口检测流畅且能正确处理各种边界情况", + "acceptanceCriteria": [ + "实现窗口列表缓存机制:mouseDown 时刷新,mouseMoved 使用缓存", + "实现节流机制:mouseMoved 窗口检测 16ms 节流", + "实现增量更新:highlightedWindowRect 变化时才触发 needsDisplay", + "处理多显示器场景:每个显示器独立检测,使用全局屏幕坐标", + "处理窗口部分在屏幕外:高亮框裁剪到屏幕可见范围", + "处理极小窗口:小于 10x10 像素的窗口跳过不高亮", + "处理全屏应用窗口:正常检测和高亮", + "ESC 键在窗口高亮状态下正确取消并关闭覆盖层" + ], + "priority": 4, + "passes": true, + "notes": "", + "dependsOn": [ + "US-003" + ], + "completionNotes": "Completed by agent" + }, + { + "id": "US-005", + "title": "集成测试与验证", + "description": "作为开发者,我需要验证整个功能在截图和翻译模式下都能正常工作", + "acceptanceCriteria": [ + "区域截图模式:窗口高亮、单击选取、拖拽圈选功能正常", + "翻译模式:窗口高亮、单击选取、拖拽圈选功能正常", + "单击选取窗口后截取的图像范围精确匹配窗口 frame", + "多显示器环境下窗口检测在所有显示器上正常工作", + "快速移动鼠标时无明显卡顿(视觉流畅)", + "覆盖层关闭后内存正确释放,无泄漏" + ], + "priority": 5, + "passes": true, + "notes": "", + "dependsOn": [ + "US-004" + ], + "completionNotes": "Completed by agent" + } + ], + "metadata": { + "updatedAt": "2026-02-09T07:19:42.285Z" + } +} \ No newline at end of file