276d2bb6-619d-4888-ba11-8a6.../Sources/ui/ExpandedView.swift
徐翔宇 4e22618364 v0.1.0: initial 划词侠 — AI Text Actions plugin for MioIsland
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>
2026-04-27 20:21:17 +08:00

376 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
)
)
}
}