// // InputBarView.swift // Zyquo Cloud // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Floating input card docked at the bottom: auto-growing multiline editor, // attach button, parameter quick-toggle, circular send button (⌘↩), // attachment thumbnails, drag-and-drop with dashed accent highlight. // import SwiftUI import UniformTypeIdentifiers struct InputBarView: View { @Binding var text: String @Binding var attachments: [Attachment] let isStreaming: Bool let supportsVision: Bool var onSend: () -> Void var onStop: () -> Void var parametersContent: () -> AnyView @State private var dropTargeted = false @State private var showingParameters = false @EnvironmentObject private var appearance: AppearanceStore @FocusState private var editorFocused: Bool private var canSend: Bool { !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty } var body: some View { VStack(spacing: ZyquoSpacing.xs) { if !attachments.isEmpty { attachmentStrip } HStack(alignment: .bottom, spacing: ZyquoSpacing.xs) { attachButton editor parameterToggle sendButton } } .padding(ZyquoSpacing.sm) .background( RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) .fill(ZyquoColor.surface) .overlay( RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) .strokeBorder( dropTargeted ? ZyquoColor.accent : ZyquoColor.border, style: StrokeStyle( lineWidth: dropTargeted ? 1.5 : ZyquoMetrics.hairline, dash: dropTargeted ? [6, 4] : [] ) ) ) ) .zyquoSoftShadow() .padding(.horizontal, ZyquoSpacing.sm) .padding(.bottom, ZyquoSpacing.sm) .onDrop(of: [.fileURL, .image], isTargeted: $dropTargeted) { providers in handleDrop(providers) } } // MARK: - Pieces private var editor: some View { ZStack(alignment: .topLeading) { if text.isEmpty { Text("Message…") .font(ZyquoFont.body(size: appearance.chatFontSize)) .foregroundStyle(ZyquoColor.textTertiary) .padding(.top, 2) .allowsHitTesting(false) } TextEditor(text: $text) .font(ZyquoFont.body(size: appearance.chatFontSize)) .foregroundStyle(ZyquoColor.textPrimary) .scrollContentBackground(.hidden) .frame(minHeight: 22, maxHeight: 200) .fixedSize(horizontal: false, vertical: text.count < 2000) .focused($editorFocused) .onAppear { editorFocused = true } } } private var attachButton: some View { Button { presentFilePicker() } label: { Image(systemName: "paperclip") .font(.system(size: 14)) .foregroundStyle(ZyquoColor.textSecondary) .frame(width: 26, height: 26) } .buttonStyle(.plain) .zyquoHoverHighlight() .help(supportsVision ? "Attach images or text files" : "Attach text files") } private var parameterToggle: some View { Button { showingParameters.toggle() } label: { Image(systemName: "slider.horizontal.3") .font(.system(size: 13)) .foregroundStyle(ZyquoColor.textSecondary) .frame(width: 26, height: 26) } .buttonStyle(.plain) .zyquoHoverHighlight() .help("Generation parameters") .popover(isPresented: $showingParameters, arrowEdge: .top) { parametersContent() } } private var sendButton: some View { Button { isStreaming ? onStop() : (canSend ? onSend() : ()) } label: { Image(systemName: isStreaming ? "stop.fill" : "arrow.up") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.white) .frame(width: 28, height: 28) .background( Circle().fill( isStreaming ? ZyquoColor.danger : (canSend ? ZyquoColor.accent : ZyquoColor.textTertiary) ) ) } .buttonStyle(PressableButtonStyle()) .keyboardShortcut(.return, modifiers: .command) .help(isStreaming ? "Stop generating" : "Send (⌘↩)") } private var attachmentStrip: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: ZyquoSpacing.xs) { ForEach(attachments) { attachment in AttachmentThumbnail(attachment: attachment) { attachments.removeAll { $0.id == attachment.id } } } } } .frame(height: ZyquoMetrics.attachmentThumbnail + 8) } // MARK: - Attachment intake private static let textExtensions: Set = [ "txt", "md", "markdown", "csv", "json", "yaml", "yml", "xml", "log", "swift", "py", "js", "ts", "jsx", "tsx", "html", "css", "sh", "zsh", "bash", "sql", "go", "rs", "c", "h", "cpp", "hpp", "m", "mm", "java", "rb", "php", "toml", "ini", "cfg", "tex", ] private static let imageExtensions: Set = ["png", "jpg", "jpeg", "webp", "gif"] private func presentFilePicker() { let panel = NSOpenPanel() panel.allowsMultipleSelection = true panel.canChooseDirectories = false panel.begin { response in guard response == .OK else { return } for url in panel.urls { ingest(url: url) } } } private func handleDrop(_ providers: [NSItemProvider]) -> Bool { var handled = false for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { handled = true provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { item, _ in guard let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) else { return } DispatchQueue.main.async { ingest(url: url) } } } return handled } private func ingest(url: URL) { let ext = url.pathExtension.lowercased() guard let data = try? Data(contentsOf: url) else { return } if Self.imageExtensions.contains(ext) { let mime = ext == "jpg" ? "image/jpeg" : "image/\(ext)" attachments.append( Attachment(kind: .image, fileName: url.lastPathComponent, data: data, mimeType: mime) ) } else if Self.textExtensions.contains(ext) || (String(data: data, encoding: .utf8) != nil && data.count < 512_000) { attachments.append( Attachment(kind: .textFile, fileName: url.lastPathComponent, data: data, mimeType: "text/plain") ) } } } /// 56pt thumbnail with a remove button; images preview, text files show an icon. private struct AttachmentThumbnail: View { let attachment: Attachment var onRemove: () -> Void var body: some View { ZStack(alignment: .topTrailing) { Group { if attachment.kind == .image, let image = NSImage(data: attachment.data) { Image(nsImage: image) .resizable() .aspectRatio(contentMode: .fill) } else { VStack(spacing: 2) { Image(systemName: "doc.text") .font(.system(size: 16)) .foregroundStyle(ZyquoColor.textSecondary) Text(attachment.fileName) .font(.system(size: 8)) .foregroundStyle(ZyquoColor.textTertiary) .lineLimit(1) } .padding(4) } } .frame(width: ZyquoMetrics.attachmentThumbnail, height: ZyquoMetrics.attachmentThumbnail) .background(ZyquoColor.surfaceSecondary) .clipShape(RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)) Button(action: onRemove) { Image(systemName: "xmark.circle.fill") .font(.system(size: 12)) .foregroundStyle(ZyquoColor.textSecondary) .background(Circle().fill(ZyquoColor.surface)) } .buttonStyle(.plain) .offset(x: 5, y: -5) } .padding(.top, 4) } }