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