Files
OSGKeyboard/OSGKeyboardExt/Views/TranslationChip.swift
T
Rocky 05e005e9ce feat: cursor navigation, key sounds, dictionary tooling, key security
Batch of in-progress app work from the working tree.

- feat(keyboard): CursorNavigation + CursorDragPad for caret movement;
  KeyboardSoundFeedback for system key click sounds
- feat(dictionary): DictionaryAliasGenerator + PersonalDictionaryEntrySheet;
  TranscriptPostProcessor quality gate; retire DictionaryLearner
- feat(ui): TabBarVisibility handling; drop PageHeaderRow /
  PageHeaderConfirmButton; refresh views and localizable strings
- fix(security): move the hardcoded DeepSeek key out of
  PreconfiguredKeys.swift into a gitignored PreconfiguredKeys.local.swift
  (seeded from .example by generate-xcodeproj.sh)
- docs(agents): add Conventional Commits versioning + bilingual changelog rules
- chore(gitignore): ignore PreconfiguredKeys.local.swift, .cache/, pycache

Custom language model / lexicon work stays on
feature/custom-language-model-asr. Changelog bullets added under
[Unreleased]; no version bump.
2026-07-05 18:27:23 +08:00

148 lines
6.1 KiB
Swift

// TranslationChip.swift
// OSGKeyboard · Keyboard Extension
//
// Compact chip rendered to the right of `LocaleChip` on the keyboard
// top bar. Doubles as both the on/off switch and the target-language
// picker — same Menu pattern as `LocaleChip` so muscle memory transfers.
//
// v0.2.1 follow-up: removed the explicit on/off toggle entry. The
// chip is now a pure picker over the 11 catalog rows (off + 10
// locales); selecting "不翻译" turns translation off, selecting any
// locale turns it on with that target. `translationEnabled` is
// derived from the locale id so the chip / pipeline read the same
// source of truth.
//
// v0.2.1 final review: dropped the "needs cloud" warning state —
// both engines now run the translate-and-polish step (the local
// engine routes through DeepSeek via
// `ProviderConfig.localModeProviderId`). The chip is therefore just
// off / on, with the same accent treatment either way.
//
// Visual states:
// • off → dim outline, "翻译" chip label (menu first row = "不翻译")
// • on (any engine) → accent fill, "→ EN" / "→ 日本語" style label
//
// Stays in the same visual family as `CloudEngineChip` / `LocaleChip`
// (Capsule + 28 pt min height + 6 pt vertical padding) so the top bar
// doesn't grow when translation is enabled.
import SwiftUI
import OSGKeyboardShared
struct TranslationChip: View, Equatable {
/// Passed in as a value (not read from `@Environment`) so the chip can
/// be wrapped in `.equatable()` at the call site: `EquatableView`
/// suppresses environment-driven refreshes, so injecting the palette
/// here keeps colours correct across dark/light switches.
let palette: ThemePalette
/// The active target-locale id (`offLocaleId` == translation off).
let targetLocaleId: String
/// Writes the picked locale id — wired to `state.setTranslationTargetLocaleId`.
let onSelect: (String) -> Void
/// Only `palette` and `targetLocaleId` drive the visuals; the
/// `onSelect` closure is deliberately excluded from equality. Because
/// the keyboard polls the App Group at 1 Hz (each poll re-publishes the
/// `KeyboardState`), the parent view re-renders every second. Without
/// this, SwiftUI would rebuild the `Menu` on every poll — dismissing an
/// open picker or snapping its scroll position back to the top. With
/// `.equatable()` the picker is rebuilt only on a real state change.
nonisolated static func == (lhs: TranslationChip, rhs: TranslationChip) -> Bool {
lhs.palette == rhs.palette && lhs.targetLocaleId == rhs.targetLocaleId
}
var body: some View {
Menu {
// v0.2.1 follow-up: pure picker over the full catalog,
// including `offLocaleId` at the top so "turn off" is one
// tap from any enabled state. Picking a row writes
// `translationTargetLocaleId`; `translationEnabled` is
// derived from it.
ForEach(TranslationLanguageCatalog.all) { language in
Button {
onSelect(language.id)
} label: {
if language.id == targetLocaleId {
Label(displayLabel(for: language), systemImage: "checkmark")
} else {
Text(displayLabel(for: language))
}
}
}
} label: {
label
}
.menuStyle(.button)
.accessibilityLabel(ExtL10n.text("keyboard.translation.a11y"))
.accessibilityHint(ExtL10n.text("keyboard.translation.a11yHint"))
}
@ViewBuilder
private var label: some View {
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
let enabled = targetLocaleId != TranslationLanguageCatalog.offLocaleId
HStack(spacing: 4) {
Image(systemName: enabled ? "character.bubble" : "character.bubble.fill")
Text(chipLabel(target: target, enabled: enabled))
Image(systemName: "chevron.down")
.font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(foreground(enabled: enabled))
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 6)
.frame(minHeight: 28)
.background(background(enabled: enabled), in: Capsule())
.overlay(Capsule().stroke(stroke(enabled: enabled), lineWidth: 0.5))
}
private func displayLabel(for language: TranslationLanguage) -> String {
if language.id == TranslationLanguageCatalog.offLocaleId {
return ExtL10n.string("keyboard.translation.offMenu")
}
return language.nativeName
}
private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String {
if !enabled {
return ExtL10n.string("keyboard.translation.chip")
}
// Short form: "→EN" / "→日" style. Falls back to the prompt
// language name for languages without a chip-style abbreviation
// (e.g. French → "FR" via the 2-letter prefix).
let short = shortLabel(for: target)
return "→\(short)"
}
private func shortLabel(for target: TranslationLanguage) -> String {
switch target.id {
case "en": return "EN"
case "zh-Hans": return "中"
case "zh-Hant": return "繁"
case "ja": return "日"
case "ko": return "韩"
case "fr": return "FR"
case "de": return "DE"
case "es": return "ES"
case "ru": return "RU"
case "pt": return "PT"
default: return target.promptLanguageName
}
}
private func foreground(enabled: Bool) -> Color {
if enabled { return palette.accent }
return palette.textPrimary
}
private func background(enabled: Bool) -> Color {
if enabled { return palette.accent.opacity(0.15) }
return palette.surfaceElevated
}
private func stroke(enabled: Bool) -> Color {
if enabled { return palette.accent.opacity(0.35) }
return palette.divider
}
}