273 lines
10 KiB
Swift
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)
|
|
}
|
|
}
|