spb/zyquo-cloud Public MIT
Native macOS AI chat client for 12 cloud providers — your keys, every cloud model, one beautiful chat.
Swift 97.4%
Shell 1.7%
Makefile 1%
1//2// InputBarView.swift3// Zyquo Cloud4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// Floating input card docked at the bottom: auto-growing multiline editor,9// attach button, parameter quick-toggle, circular send button (⌘↩),10// attachment thumbnails, drag-and-drop with dashed accent highlight.11//1213import SwiftUI14import UniformTypeIdentifiers1516struct InputBarView: View {17 @Binding var text: String18 @Binding var attachments: [Attachment]19 let isStreaming: Bool20 let supportsVision: Bool21 var onSend: () -> Void22 var onStop: () -> Void23 var parametersContent: () -> AnyView2425 @State private var dropTargeted = false26 @State private var showingParameters = false27 @EnvironmentObject private var appearance: AppearanceStore28 @FocusState private var editorFocused: Bool2930 private var canSend: Bool {31 !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty32 }3334 var body: some View {35 VStack(spacing: ZyquoSpacing.xs) {36 if !attachments.isEmpty {37 attachmentStrip38 }39 HStack(alignment: .bottom, spacing: ZyquoSpacing.xs) {40 attachButton41 editor42 parameterToggle43 sendButton44 }45 }46 .padding(ZyquoSpacing.sm)47 .background(48 RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)49 .fill(ZyquoColor.surface)50 .overlay(51 RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)52 .strokeBorder(53 dropTargeted ? ZyquoColor.accent : ZyquoColor.border,54 style: StrokeStyle(55 lineWidth: dropTargeted ? 1.5 : ZyquoMetrics.hairline,56 dash: dropTargeted ? [6, 4] : []57 )58 )59 )60 )61 .zyquoSoftShadow()62 .padding(.horizontal, ZyquoSpacing.sm)63 .padding(.bottom, ZyquoSpacing.sm)64 .onDrop(of: [.fileURL, .image], isTargeted: $dropTargeted) { providers in65 handleDrop(providers)66 }67 }6869 // MARK: - Pieces7071 private var editor: some View {72 ZStack(alignment: .topLeading) {73 if text.isEmpty {74 Text("Message…")75 .font(ZyquoFont.body(size: appearance.chatFontSize))76 .foregroundStyle(ZyquoColor.textTertiary)77 .padding(.top, 2)78 .allowsHitTesting(false)79 }80 TextEditor(text: $text)81 .font(ZyquoFont.body(size: appearance.chatFontSize))82 .foregroundStyle(ZyquoColor.textPrimary)83 .scrollContentBackground(.hidden)84 .frame(minHeight: 22, maxHeight: 200)85 .fixedSize(horizontal: false, vertical: text.count < 2000)86 .focused($editorFocused)87 .onAppear { editorFocused = true }88 }89 }9091 private var attachButton: some View {92 Button {93 presentFilePicker()94 } label: {95 Image(systemName: "paperclip")96 .font(.system(size: 14))97 .foregroundStyle(ZyquoColor.textSecondary)98 .frame(width: 26, height: 26)99 }100 .buttonStyle(.plain)101 .zyquoHoverHighlight()102 .help(supportsVision ? "Attach images or text files" : "Attach text files")103 }104105 private var parameterToggle: some View {106 Button {107 showingParameters.toggle()108 } label: {109 Image(systemName: "slider.horizontal.3")110 .font(.system(size: 13))111 .foregroundStyle(ZyquoColor.textSecondary)112 .frame(width: 26, height: 26)113 }114 .buttonStyle(.plain)115 .zyquoHoverHighlight()116 .help("Generation parameters")117 .popover(isPresented: $showingParameters, arrowEdge: .top) {118 parametersContent()119 }120 }121122 private var sendButton: some View {123 Button {124 isStreaming ? onStop() : (canSend ? onSend() : ())125 } label: {126 Image(systemName: isStreaming ? "stop.fill" : "arrow.up")127 .font(.system(size: 13, weight: .semibold))128 .foregroundStyle(.white)129 .frame(width: 28, height: 28)130 .background(131 Circle().fill(132 isStreaming133 ? ZyquoColor.danger134 : (canSend ? ZyquoColor.accent : ZyquoColor.textTertiary)135 )136 )137 }138 .buttonStyle(PressableButtonStyle())139 .keyboardShortcut(.return, modifiers: .command)140 .help(isStreaming ? "Stop generating" : "Send (⌘↩)")141 }142143 private var attachmentStrip: some View {144 ScrollView(.horizontal, showsIndicators: false) {145 HStack(spacing: ZyquoSpacing.xs) {146 ForEach(attachments) { attachment in147 AttachmentThumbnail(attachment: attachment) {148 attachments.removeAll { $0.id == attachment.id }149 }150 }151 }152 }153 .frame(height: ZyquoMetrics.attachmentThumbnail + 8)154 }155156 // MARK: - Attachment intake157158 private static let textExtensions: Set<String> = [159 "txt", "md", "markdown", "csv", "json", "yaml", "yml", "xml", "log",160 "swift", "py", "js", "ts", "jsx", "tsx", "html", "css", "sh", "zsh",161 "bash", "sql", "go", "rs", "c", "h", "cpp", "hpp", "m", "mm", "java",162 "rb", "php", "toml", "ini", "cfg", "tex",163 ]164 private static let imageExtensions: Set<String> = ["png", "jpg", "jpeg", "webp", "gif"]165166 private func presentFilePicker() {167 let panel = NSOpenPanel()168 panel.allowsMultipleSelection = true169 panel.canChooseDirectories = false170 panel.begin { response in171 guard response == .OK else { return }172 for url in panel.urls { ingest(url: url) }173 }174 }175176 private func handleDrop(_ providers: [NSItemProvider]) -> Bool {177 var handled = false178 for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {179 handled = true180 provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { item, _ in181 guard let data = item as? Data,182 let url = URL(dataRepresentation: data, relativeTo: nil) else { return }183 DispatchQueue.main.async { ingest(url: url) }184 }185 }186 return handled187 }188189 private func ingest(url: URL) {190 let ext = url.pathExtension.lowercased()191 guard let data = try? Data(contentsOf: url) else { return }192 if Self.imageExtensions.contains(ext) {193 let mime = ext == "jpg" ? "image/jpeg" : "image/\(ext)"194 attachments.append(195 Attachment(kind: .image, fileName: url.lastPathComponent, data: data, mimeType: mime)196 )197 } else if Self.textExtensions.contains(ext) || (String(data: data, encoding: .utf8) != nil && data.count < 512_000) {198 attachments.append(199 Attachment(kind: .textFile, fileName: url.lastPathComponent, data: data, mimeType: "text/plain")200 )201 }202 }203}204205/// 56pt thumbnail with a remove button; images preview, text files show an icon.206private struct AttachmentThumbnail: View {207 let attachment: Attachment208 var onRemove: () -> Void209210 var body: some View {211 ZStack(alignment: .topTrailing) {212 Group {213 if attachment.kind == .image, let image = NSImage(data: attachment.data) {214 Image(nsImage: image)215 .resizable()216 .aspectRatio(contentMode: .fill)217 } else {218 VStack(spacing: 2) {219 Image(systemName: "doc.text")220 .font(.system(size: 16))221 .foregroundStyle(ZyquoColor.textSecondary)222 Text(attachment.fileName)223 .font(.system(size: 8))224 .foregroundStyle(ZyquoColor.textTertiary)225 .lineLimit(1)226 }227 .padding(4)228 }229 }230 .frame(width: ZyquoMetrics.attachmentThumbnail, height: ZyquoMetrics.attachmentThumbnail)231 .background(ZyquoColor.surfaceSecondary)232 .clipShape(RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous))233234 Button(action: onRemove) {235 Image(systemName: "xmark.circle.fill")236 .font(.system(size: 12))237 .foregroundStyle(ZyquoColor.textSecondary)238 .background(Circle().fill(ZyquoColor.surface))239 }240 .buttonStyle(.plain)241 .offset(x: 5, y: -5)242 }243 .padding(.top, 4)244 }245}246