276d2bb6-619d-4888-ba11-8a6.../Sources/ui/ExpandedView.swift

376 lines
13 KiB
Swift
Raw Normal View History

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