# Toolbar A SwiftUI Liquid Glass composer kit for AI chat interfaces. `Toolbar` provides the primitives modern AI input bars are built from — multi-line editor, file / image / path attachments, slash commands, voice input, and a unified Liquid Glass surface — and lets you compose them declaratively. There is no monolithic `Toolbar` view: you put what you need inside a `ToolbarContainer`. ## Requirements | | | |---|---| | Swift | 6.2+ | | Platforms | iOS 26+ / iPadOS 26+ / macOS 26+ | | Xcode | 26+ | ## Installation Swift Package Manager: ```swift .package(url: "https://github.com/1amageek/Toolbar.git", branch: "main") ``` Then add `Toolbar` to your target dependencies. ## Quick start A minimal composer with a menu, editor, and adaptive trailing button (Send / Stop / Voice): ```swift import SwiftUI import Toolbar struct ChatView: View { @State private var text = "" @State private var height: CGFloat = ToolbarControlMetrics.circleDiameter @State private var isFocused = false @State private var isStreaming = false var body: some View { ScrollView { messageList } .safeAreaInset(edge: .bottom) { ToolbarContainer { HStack(alignment: .bottom, spacing: 8) { ToolbarMenuButton { Button("File", systemImage: "doc") { pickFile() } Button("Image", systemImage: "photo") { pickImage() } Button("Folder", systemImage: "folder"){ pickFolder() } } label: { Image(systemName: "plus") } ToolbarEditor( text: $text, contentHeight: $height, isFocused: $isFocused, placeholder: "Message..." ) .frame( minHeight: ToolbarControlMetrics.circleDiameter, maxHeight: max(ToolbarControlMetrics.circleDiameter, min(height, 220)) ) if isStreaming { StopButton(action: cancel) } else { SendButton(isEnabled: !text.isEmpty, action: send) } } } } } } ``` `ToolbarContainer` paints **one** continuous Liquid Glass slab and wraps slab-local children in a `GlassEffectContainer`, so glass-circle buttons and attachment chips morph cohesively with the slab. Floating surfaces such as slash command suggestions belong in `.popup { }`, outside the slab. Embed the toolbar via `.safeAreaInset(edge: .bottom)` on the message scroll view — never directly inside a `VStack`. Use `ToolbarContainer(contentInsets:)` for padding inside the glass slab. Use regular `.padding(...)` outside the container for spacing around the whole toolbar. ## Voice + accessory area `.accessory { }` inserts a view above the composer content within the same slab. Use it for ephemeral state strips: attachment chips, live waveforms during recording, "transcribing…" indicators, error banners, draft previews, suggestion chips, upload progress. ```swift @State private var voiceState: VoiceState = .idle @State private var amplitudes: [Float] = [] @State private var amplitudeSource: (any VoiceAmplitudeSource)? = nil ToolbarContainer { HStack(alignment: .bottom, spacing: 8) { // ... menu, editor ... if text.isEmpty { VoiceButton( provider: voiceProvider, onResult: { result in /* finalized text/audio */ }, onStateChange: { state in withAnimation(.smooth(duration: 0.25)) { voiceState = state } amplitudeSource = (state == .recording) ? voiceProvider as? any VoiceAmplitudeSource : nil } ) } else { SendButton(isEnabled: true, action: send) } } } .accessory { switch voiceState { case .idle: EmptyView() case .recording: VoiceWaveform(amplitudes: amplitudes) .transition(.opacity.combined(with: .scale(scale: 0.96))) case .transcribing: TranscribingIndicator() .transition(.opacity.combined(with: .scale(scale: 0.96))) } } .voiceAmplitudes(from: amplitudeSource, into: $amplitudes) ``` The `.transcribing` state exists so the accessory **stays visible** between `stopRecording()` and the final result — without it the bar would briefly collapse and reflow once the transcript arrives. ## Slash commands Slash popup goes in `.popup(isPresented:)`, outside the toolbar slab. Present it only while the editor text is in slash-command mode: ```swift ToolbarContainer { HStack(alignment: .bottom, spacing: 8) { // ... editor with $text driving `matches` via SlashCommandProvider ... } } .popup(isPresented: text.hasPrefix("/")) { SlashCommandPopup( commands: matches, selectedIndex: selectedIndex, onSelect: commit ) } ``` Implement `SlashCommandProvider` against your own command source, or use the bundled `StaticSlashCommandProvider` for fixed lists. ## Attachments Attachments are data, not views. `ToolbarAttachment` exposes only the metadata the default renderer needs: display name, icon, optional badge label / symbol, and semantic tint. Use `URLAttachment` for dropped or pasted URLs; it classifies common images, documents, spreadsheets, presentations, code, archives, audio, and video through `AttachmentResolver`. `FileAttachment`, `ImageAttachment`, and `PathAttachment` remain available when callers want to force a specific treatment. Render attachments as fixed-height previews with `AttachmentChip` in the accessory area above the editor row. Image attachments keep their aspect ratio and render as rounded thumbnails; other attachments render as extension badges. The thumbnail radius is inset-aware so its curve aligns with the outer toolbar slab: ```swift ToolbarContainer { HStack(alignment: .bottom, spacing: 8) { // ... menu, editor, trailing action ... } } .accessory { if !attachments.isEmpty { AttachmentStrip(attachments: attachments) { attachment in remove(attachment) } } } ``` For inline `[[marker]]` attachments rendered directly inside the editor text, conform to `InlineAttachmentRenderer`. ## Public surface | Component | Role | |---|---| | `ToolbarContainer` | Liquid Glass slab + `GlassEffectContainer` morph domain | | `.popup { }` | Modifier inserting a floating surface above the slab | | `.accessory { }` | Modifier inserting an ephemeral strip above the content | | `.footer { }` | Modifier inserting persistent controls below the content | | `ToolbarEditor` | Cross-platform multi-line editor (`NSTextView` / `UITextView` backed) | | `ToolbarMenuButton` | Glass-circle menu styled to match other buttons | | `SendButton` / `StopButton` | Trailing action buttons with shared metrics | | `VoiceButton` | Mic ↔ stop, drives a `VoiceInputProvider` and emits `VoiceState` | | `VoiceWaveform` | Bar-graph visualization fed by `VoiceAmplitudeSource` | | `TranscribingIndicator` | Progress strip for the post-recording analysis state | | `AttachmentChip` | Fixed-height preview for any `ToolbarAttachment` | | `AttachmentStrip` | Horizontal attachment row with scroll clipping disabled | | `URLAttachment` | Standard URL-backed attachment with extension / UTType classification | | `AttachmentResolver` | Standard category, symbol, badge, and tint resolver for URL attachments | | `SlashCommandPopup` | Glass popup list of matches | | `GlassCircleButtonStyle` | Re-usable circular Liquid Glass button style | | `ToolbarControlMetrics` | Platform-tuned `circleDiameter` / `symbolSize` (28/13 macOS, 40/17 iOS) | Protocols you implement: `VoiceInputProvider`, `VoiceAmplitudeSource`, `SlashCommandProvider`, `ToolbarAttachment` / `ToolbarURLAttachment`, `InlineAttachmentRenderer`. ## Footer controls Use `.footer { }` for persistent composer controls that belong below the editor, such as attachment menus, workspace selectors, model selectors, and send / stop buttons: ```swift ToolbarContainer { ToolbarEditor( text: $text, contentHeight: $height, isFocused: $isFocused, placeholder: "Ask anything..." ) .frame(minHeight: 96, maxHeight: 180, alignment: .topLeading) } .footer { HStack(spacing: 8) { ToolbarMenuButton { Button("File", systemImage: "doc") { pickFile() } Button("Image", systemImage: "photo") { pickImage() } } label: { Image(systemName: "plus") } WorkspacePickerChip(selection: $workspace) Spacer() SendButton(isEnabled: !text.isEmpty, action: send) } } ``` ## Design philosophy - **Declarative composition** — no environment-modifier soup. The composer body is just SwiftUI views in an `HStack` / `VStack`. - **One slab, one morph domain** — children that need glass (popup, capsule chips, circle buttons) share the container's `GlassEffectContainer` so shape transitions stay fluid. - **Provider-driven I/O** — voice, slash commands, and inline attachment rendering are protocols. The library never talks to Whisper, Speech, OCR, or any concrete backend. - **Cross-platform internals stay private** — `ToolbarEditor` is the only place AppKit / UIKit leaks, and that leak is sealed behind the public SwiftUI surface. See [`DESIGN.md`](DESIGN.md) for the full architecture write-up. ## License MIT — see [`LICENSE`](LICENSE).