mirror of
https://github.com/carey314/mio-plugin-clipper.git
synced 2026-08-10 07:44:32 +00:00
Quick AI actions on clipboard or selected text. Four actions: - 翻译 (中/英/日) - 总结 (3-bullet Chinese) - 改语气 (正式/口语/友好/严谨) - 邮件回复 Powered by DeepSeek with Server-Sent Events streaming for typewriter feedback (~600ms first byte). Two source modes: clipboard (no permission) and selected-text via NSAccessibility (graceful fallback to clipboard + permission banner if denied). Five-state UI machine: idle → input → loading (cancellable) → result → missing-config. Last 20 actions persisted to UserDefaults. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
376 lines
13 KiB
Swift
376 lines
13 KiB
Swift
//
|
||
// ExpandedView.swift
|
||
// 划词侠 plugin
|
||
//
|
||
// Top-level panel container — 380×520 to match design spec. Renders
|
||
// the title bar + state-driven body + footer source-toggle.
|
||
//
|
||
// States (per spec):
|
||
// A — IDLE last action result + history
|
||
// B — INPUT picked source preview + 2x2 action grid + segmented opts
|
||
// C — LOADING spinner + cancel
|
||
// D — RESULT scrollable text + 复制 / 重新生成 / 完成
|
||
// E — MISSING CFG onboarding card pointing to clipper-config.json
|
||
//
|
||
|
||
import SwiftUI
|
||
import AppKit
|
||
|
||
struct ExpandedView: View {
|
||
@ObservedObject var store: ClipperStore = .shared
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
// Notch reservation strip — same 40pt pattern as 看盘侠.
|
||
// Host's floating back-chevron lives in this band.
|
||
Color.clear.frame(height: 40)
|
||
topBar
|
||
body_
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
footer
|
||
}
|
||
.frame(width: 380, height: 520)
|
||
.background(
|
||
ZStack {
|
||
ClipperTheme.panelBg
|
||
RadialGradient(
|
||
colors: [Color.white.opacity(0.04), Color.clear],
|
||
center: .top,
|
||
startRadius: 4, endRadius: 220
|
||
)
|
||
}
|
||
)
|
||
.clipShape(
|
||
UnevenRoundedRectangle(
|
||
cornerRadii: .init(topLeading: 0, bottomLeading: 28, bottomTrailing: 28, topTrailing: 0)
|
||
)
|
||
)
|
||
.onAppear {
|
||
store.activate()
|
||
}
|
||
}
|
||
|
||
// MARK: - Top bar
|
||
|
||
private var topBar: some View {
|
||
HStack(spacing: 10) {
|
||
HStack(spacing: 8) {
|
||
Circle()
|
||
.fill(ClipperTheme.lime)
|
||
.frame(width: 7, height: 7)
|
||
.shadow(color: ClipperTheme.lime.opacity(0.6), radius: 4)
|
||
Text("划词侠")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundColor(ClipperTheme.fgPrimary)
|
||
}
|
||
Spacer()
|
||
|
||
Button {
|
||
store.refreshSource()
|
||
} label: {
|
||
Image(systemName: "arrow.down.doc")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(ClipperTheme.fg55)
|
||
.frame(width: 28, height: 28)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("从当前源重新读取文本")
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 14)
|
||
.padding(.bottom, 8)
|
||
}
|
||
|
||
// MARK: - Body switch
|
||
|
||
@ViewBuilder
|
||
private var body_: some View {
|
||
switch store.viewState {
|
||
case .missingConfig:
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
MissingConfigCard(store: store)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.bottom, 12)
|
||
}
|
||
case .idle:
|
||
idleBody
|
||
case .input(let text):
|
||
inputBody(text: text)
|
||
case .loading(let label):
|
||
loadingBody(action: label)
|
||
case .result(let label, let output, _):
|
||
ScrollView {
|
||
VStack(spacing: 10) {
|
||
if store.permissionDenied {
|
||
AccessibilityBanner(onOpenSettings: openAccessibilityPrefs)
|
||
}
|
||
ResultView(store: store, actionLabel: label, output: output)
|
||
.frame(minHeight: 320)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 4)
|
||
.padding(.bottom, 8)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - State A: idle
|
||
|
||
private var idleBody: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
if store.permissionDenied {
|
||
AccessibilityBanner(onOpenSettings: openAccessibilityPrefs)
|
||
}
|
||
|
||
// Hint card
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "doc.on.clipboard")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundColor(ClipperTheme.lime)
|
||
Text("复制文本以激活")
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundColor(ClipperTheme.fgPrimary)
|
||
}
|
||
Text("先把任意文本复制到粘贴板(⌘C),再点上方的刷新按钮,下面就会出现 4 个动作。")
|
||
.font(.system(size: 11.5))
|
||
.foregroundColor(ClipperTheme.fg55)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
Button {
|
||
store.refreshSource()
|
||
} label: {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "arrow.down.doc")
|
||
.font(.system(size: 11, weight: .semibold))
|
||
Text("从粘贴板读取")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
}
|
||
.foregroundColor(Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255))
|
||
.padding(.horizontal, 14)
|
||
.frame(height: 30)
|
||
.background(Capsule().fill(ClipperTheme.lime))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.top, 4)
|
||
}
|
||
.padding(14)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 14)
|
||
.fill(ClipperTheme.overlay04)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 14)
|
||
.stroke(ClipperTheme.overlay08, lineWidth: 0.5)
|
||
)
|
||
)
|
||
|
||
if let err = store.lastError {
|
||
errorChip(text: err)
|
||
}
|
||
|
||
if !store.history.isEmpty {
|
||
historySection
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 4)
|
||
.padding(.bottom, 12)
|
||
}
|
||
}
|
||
|
||
private var historySection: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("最近 3 条")
|
||
.font(.system(size: 10.5, weight: .semibold))
|
||
.foregroundColor(ClipperTheme.fg45)
|
||
.padding(.leading, 4)
|
||
ForEach(store.history.prefix(3)) { entry in
|
||
historyRow(entry)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func historyRow(_ entry: HistoryEntry) -> some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack(spacing: 6) {
|
||
Text(entry.actionLabel)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundColor(ClipperTheme.lime)
|
||
Spacer()
|
||
Text(relative(entry.timestamp))
|
||
.font(.system(size: 10))
|
||
.foregroundColor(ClipperTheme.fg40)
|
||
.monospacedDigit()
|
||
}
|
||
Text(entry.outputPreview)
|
||
.font(.system(size: 11.5))
|
||
.foregroundColor(ClipperTheme.fg70)
|
||
.lineLimit(2)
|
||
.multilineTextAlignment(.leading)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 8)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 10)
|
||
.fill(ClipperTheme.overlay04)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 10)
|
||
.stroke(ClipperTheme.overlay08, lineWidth: 0.5)
|
||
)
|
||
)
|
||
}
|
||
|
||
// MARK: - State B: input picked
|
||
|
||
private func inputBody(text: String) -> some View {
|
||
ScrollView {
|
||
VStack(spacing: 12) {
|
||
if store.permissionDenied {
|
||
AccessibilityBanner(onOpenSettings: openAccessibilityPrefs)
|
||
}
|
||
SourcePreview(text: text)
|
||
if let err = store.lastError {
|
||
errorChip(text: err)
|
||
}
|
||
ActionGrid(store: store)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 4)
|
||
.padding(.bottom, 12)
|
||
}
|
||
}
|
||
|
||
// MARK: - State C: loading
|
||
|
||
private func loadingBody(action: String) -> some View {
|
||
VStack(spacing: 14) {
|
||
Spacer()
|
||
ProgressView()
|
||
.progressViewStyle(.circular)
|
||
.scaleEffect(1.2)
|
||
.tint(ClipperTheme.lime)
|
||
Text("处理中…")
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundColor(ClipperTheme.fgPrimary)
|
||
Text(action)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(ClipperTheme.fg55)
|
||
Button {
|
||
store.cancelCurrent()
|
||
} label: {
|
||
Text("取消")
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundColor(ClipperTheme.danger)
|
||
.padding(.horizontal, 16)
|
||
.frame(height: 28)
|
||
.background(
|
||
Capsule()
|
||
.stroke(ClipperTheme.danger.opacity(0.5), lineWidth: 0.5)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.top, 4)
|
||
Spacer()
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
|
||
// MARK: - shared bits
|
||
|
||
private func errorChip(text: String) -> some View {
|
||
HStack(alignment: .top, spacing: 6) {
|
||
Image(systemName: "exclamationmark.circle.fill")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(ClipperTheme.danger)
|
||
.padding(.top, 1)
|
||
Text(text)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(ClipperTheme.fg85)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(8)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.fill(ClipperTheme.danger.opacity(0.08))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.stroke(ClipperTheme.danger.opacity(0.25), lineWidth: 0.5)
|
||
)
|
||
)
|
||
}
|
||
|
||
private func relative(_ d: Date) -> String {
|
||
let s = Int(Date().timeIntervalSince(d))
|
||
if s < 60 { return "\(s)s 前" }
|
||
if s < 3600 { return "\(s / 60)分前" }
|
||
if s < 86400 { return "\(s / 3600)时前" }
|
||
return "\(s / 86400)天前"
|
||
}
|
||
|
||
private func openAccessibilityPrefs() {
|
||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
|
||
NSWorkspace.shared.open(url)
|
||
}
|
||
}
|
||
|
||
// MARK: - Footer (source toggle)
|
||
|
||
private var footer: some View {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "arrow.triangle.2.circlepath")
|
||
.font(.system(size: 10))
|
||
.foregroundColor(ClipperTheme.fg45)
|
||
Text("源")
|
||
.font(.system(size: 10.5, weight: .medium))
|
||
.foregroundColor(ClipperTheme.fg45)
|
||
|
||
ForEach(TextSourceMode.allCases) { mode in
|
||
let isSel = store.sourceMode == mode
|
||
Button {
|
||
store.setSourceMode(mode)
|
||
} label: {
|
||
Text(mode.label)
|
||
.font(.system(size: 10.5, weight: .semibold))
|
||
.foregroundColor(isSel
|
||
? Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255)
|
||
: ClipperTheme.fg70)
|
||
.padding(.horizontal, 10)
|
||
.frame(height: 22)
|
||
.background(
|
||
Capsule().fill(isSel ? ClipperTheme.lime : ClipperTheme.overlay04)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
Spacer()
|
||
if let cfg = store.config {
|
||
Text(cfg.deepseekApiKey.isEmpty ? "未配置" : "已配置 ✓")
|
||
.font(.system(size: 10))
|
||
.foregroundColor(cfg.deepseekApiKey.isEmpty ? ClipperTheme.amber : ClipperTheme.fg45)
|
||
} else {
|
||
Text("未配置")
|
||
.font(.system(size: 10))
|
||
.foregroundColor(ClipperTheme.amber)
|
||
}
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 8)
|
||
.background(
|
||
Rectangle()
|
||
.fill(Color.clear)
|
||
.overlay(
|
||
Rectangle()
|
||
.fill(ClipperTheme.overlay04)
|
||
.frame(height: 0.5),
|
||
alignment: .top
|
||
)
|
||
)
|
||
}
|
||
}
|