SPB Git

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%
9.0 KB · 246 lines swift
Raw Blame History
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