Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

273 lines
10 KiB
Swift

import SwiftUI
import MacToolsPluginKit
@MainActor
final class TranslatorPanelModel: ObservableObject {
@Published var snapshot: TranslatorPanelSnapshot = .idle
}
/// Keeps the SwiftUI view tree alive and lets SwiftUI diff updates when `model.snapshot` changes.
/// This avoids rebuilding the NSHostingView for every snapshot and losing scroll position or text selection.
struct TranslatorPanelHostView: View {
@ObservedObject var model: TranslatorPanelModel
let localization: PluginLocalization
let onAction: (TranslatorPanelAction) -> Void
var body: some View {
TranslatorPanelView(snapshot: model.snapshot, localization: localization, onAction: onAction)
}
}
struct TranslatorPanelView: View {
let snapshot: TranslatorPanelSnapshot
let localization: PluginLocalization
let onAction: (TranslatorPanelAction) -> Void
init(
snapshot: TranslatorPanelSnapshot,
localization: PluginLocalization = PluginLocalization(bundle: .main),
onAction: @escaping (TranslatorPanelAction) -> Void
) {
self.snapshot = snapshot
self.localization = localization
self.onAction = onAction
}
var body: some View {
VStack(spacing: 8) {
sourceEditor
languageRow
if let tip = presentation.tip {
tipCard(tip)
}
providerRows
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(width: 606, height: 380, alignment: .top)
}
private var presentation: TranslatorPanelPresentation {
TranslatorPanelPresentation(snapshot: snapshot, localization: localization)
}
private var sourceEditor: some View {
VStack(spacing: 0) {
ZStack(alignment: .topLeading) {
if presentation.usesSourceCaretPlaceholder {
RoundedRectangle(cornerRadius: 1.5)
.fill(Color.accentColor.opacity(0.65))
.frame(width: 2.5, height: 18)
.padding(.leading, 11)
.padding(.top, 11)
} else {
ScrollView {
Text(presentation.sourceText)
.font(.body)
.foregroundStyle(.primary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 8)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
HStack(spacing: 8) {
sourceIconButton(
"speaker.wave.2",
help: localization.string("panel.source.speakHelp", defaultValue: "朗读原文"),
action: .speakSource,
isDisabled: presentation.sourceText.isEmpty
)
sourceIconButton(
"doc.on.doc",
help: localization.string("panel.source.copyHelp", defaultValue: "复制原文"),
action: .copySource,
isDisabled: presentation.sourceText.isEmpty
)
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.bottom, 10)
.frame(height: 36)
}
.frame(height: 86)
.background(panelCardColor, in: RoundedRectangle(cornerRadius: 8))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private var languageRow: some View {
HStack(spacing: 0) {
languageSegment(title: presentation.sourceLanguageTitle)
Spacer(minLength: 0)
Image(systemName: "arrow.left.arrow.right")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.primary)
.frame(width: 56)
Spacer(minLength: 0)
languageSegment(title: presentation.targetLanguageTitle)
}
.frame(height: 30)
.background(panelCardColor, in: RoundedRectangle(cornerRadius: 8))
}
private func languageSegment(title: String) -> some View {
HStack(spacing: 6) {
Text(title)
.font(.system(size: 14, weight: .semibold))
.lineLimit(1)
Image(systemName: "chevron.down")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.primary)
}
.frame(maxWidth: .infinity, minHeight: 30)
}
private func tipCard(_ tip: TranslatorPanelPresentation.Tip) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: tip.systemImage)
.font(.system(size: 15, weight: .semibold))
.symbolRenderingMode(.multicolor)
Text(tip.title)
.font(.system(size: 14, weight: .semibold))
}
Text(tip.message)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(1)
Button {
onAction(.openSettings)
} label: {
Label(tip.actionTitle, systemImage: "questionmark.bubble")
.font(.system(size: 14, weight: .semibold))
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(.accentColor)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, minHeight: 84, alignment: .leading)
.background(panelCardColor, in: RoundedRectangle(cornerRadius: 8))
}
private var providerRows: some View {
ScrollView {
VStack(spacing: 8) {
ForEach(presentation.providerRows) { row in
providerRow(row)
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.scrollIndicators(.never)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
private func providerRow(_ row: TranslatorPanelPresentation.ProviderRow) -> some View {
VStack(spacing: 0) {
HStack(spacing: 10) {
providerSymbol(row)
Text(row.title)
.font(.system(size: 14, weight: .medium))
.lineLimit(1)
Spacer()
if row.isLoading {
ProgressView()
.controlSize(.small)
}
Image(systemName: "chevron.left")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.primary)
}
.padding(.horizontal, 12)
.frame(height: 30)
if row.isExpanded {
VStack(alignment: .leading, spacing: 8) {
Text(row.bodyText)
.font(.body)
.foregroundStyle(row.isError ? .red : .primary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .topLeading)
HStack(spacing: 8) {
sourceIconButton(
"speaker.wave.2",
help: localization.string("panel.result.speakHelp", defaultValue: "朗读译文"),
action: .speakTranslation,
isDisabled: row.bodyText.isEmpty || row.isError
)
sourceIconButton(
"doc.on.doc",
help: localization.format(
"panel.result.copyProviderHelpFormat",
defaultValue: "复制%@译文",
row.title
),
action: row.id == "placeholder" ? .copyTranslation : .copyProviderTranslation(row.id),
isDisabled: !row.canCopy
)
sourceIconButton(
"arrow.clockwise",
help: localization.string("panel.retryHelp", defaultValue: "重试"),
action: .retry,
isDisabled: false
)
}
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.background(panelCardColor, in: RoundedRectangle(cornerRadius: 8))
}
private func providerSymbol(_ row: TranslatorPanelPresentation.ProviderRow) -> some View {
Image(systemName: row.symbolName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(providerTint(for: row.title))
.frame(width: 18, height: 18)
.background(.white.opacity(0.75), in: RoundedRectangle(cornerRadius: 5))
}
private func sourceIconButton(
_ systemName: String,
help: String,
action: TranslatorPanelAction,
isDisabled: Bool
) -> some View {
Button {
onAction(action)
} label: {
Image(systemName: systemName)
.font(.system(size: 16, weight: .medium))
.frame(width: 26, height: 26)
}
.buttonStyle(.borderless)
.foregroundStyle(.primary)
.background(Color(nsColor: .quaternaryLabelColor).opacity(0.18), in: RoundedRectangle(cornerRadius: 6))
.disabled(isDisabled)
.help(help)
}
private func providerTint(for title: String) -> Color {
let normalized = title.lowercased()
if normalized.contains("deepseek") {
return Color(red: 0.32, green: 0.42, blue: 1.0)
}
if normalized.contains("fireworks") {
return Color(red: 0.39, green: 0.16, blue: 0.95)
}
return .primary
}
private var panelCardColor: Color {
Color(nsColor: .controlBackgroundColor).opacity(0.72)
}
}