SPB Git

spb/zyquo-local Public MIT

Native macOS AI chat that runs LLMs 100% locally on Apple Silicon with MLX — no cloud, no API keys.

Swift 97.2% Shell 1.8% Makefile 1%
3.9 KB · 125 lines swift
Raw Blame History
1//2//  Components.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI1011/// RAM verdict badge (Fits / Tight / Too large) for THIS Mac.12struct VerdictBadge: View {13    let verdict: MemoryAdvisor.Verdict1415    private var color: Color {16        switch verdict {17        case .fits: ZyquoTheme.success18        case .tight: ZyquoTheme.warning19        case .tooLarge: ZyquoTheme.danger20        }21    }2223    var body: some View {24        Text(verdict.label)25            .font(ZyquoTheme.caption.weight(.medium))26            .foregroundStyle(color)27            .padding(.horizontal, ZyquoTheme.Spacing.xs)28            .padding(.vertical, 2)29            .background(color.opacity(0.12), in: Capsule())30    }31}3233/// Small neutral badge for quantization / params ("4bit", "8B"…).34struct InfoBadge: View {35    let text: String3637    var body: some View {38        Text(text)39            .font(ZyquoTheme.caption)40            .foregroundStyle(ZyquoTheme.textSecondary)41            .padding(.horizontal, ZyquoTheme.Spacing.xs)42            .padding(.vertical, 2)43            .background(ZyquoTheme.surfaceSecondary, in: Capsule())44            .overlay(Capsule().stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline))45    }46}4748/// 0.97 press scale + 80 ms hover ease, per the motion spec.49struct PressableButtonStyle: ButtonStyle {50    func makeBody(configuration: Configuration) -> some View {51        configuration.label52            .scaleEffect(configuration.isPressed ? 0.97 : 1)53            .animation(.easeOut(duration: 0.08), value: configuration.isPressed)54    }55}5657/// Row that highlights on hover with surfaceSecondary.58struct HoverHighlight: ViewModifier {59    @State private var hovering = false60    var cornerRadius: CGFloat = ZyquoTheme.Radius.s6162    func body(content: Content) -> some View {63        content64            .background(65                RoundedRectangle(cornerRadius: cornerRadius)66                    .fill(hovering ? ZyquoTheme.surfaceSecondary : .clear)67            )68            .onHover { inside in69                withAnimation(.easeOut(duration: 0.08)) { hovering = inside }70            }71    }72}7374extension View {75    func hoverHighlight(cornerRadius: CGFloat = ZyquoTheme.Radius.s) -> some View {76        modifier(HoverHighlight(cornerRadius: cornerRadius))77    }7879    /// Ultra-soft floating-panel shadow from the spec.80    func floatingShadow() -> some View {81        shadow(color: ZyquoTheme.shadowColor, radius: ZyquoTheme.shadowRadius, y: ZyquoTheme.shadowY)82    }83}8485/// The Z wordmark glyph drawn from the icon's geometry (used in the sidebar86/// and empty states).87struct ZyquoGlyph: View {88    var size: CGFloat = 2089    var color: Color = ZyquoTheme.accent9091    var body: some View {92        Canvas { context, canvasSize in93            let s = canvasSize.width / 102494            var path = Path()95            path.move(to: CGPoint(x: 300 * s, y: 265 * s))96            path.addLine(to: CGPoint(x: 724 * s, y: 265 * s))97            path.addLine(to: CGPoint(x: 300 * s, y: 759 * s))98            path.addLine(to: CGPoint(x: 724 * s, y: 759 * s))99            context.stroke(100                path,101                with: .color(color),102                style: StrokeStyle(lineWidth: 130 * s, lineCap: .round, lineJoin: .round)103            )104        }105        .frame(width: size, height: size)106    }107}108109/// Formats byte counts consistently.110func formatBytes(_ bytes: Int64) -> String {111    ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)112}113114/// "2.3 GB/s", "418 MB/s"…115func formatSpeed(_ bytesPerSecond: Double) -> String {116    formatBytes(Int64(bytesPerSecond)) + "/s"117}118119/// "3 min left", "12 s left"…120func formatETA(_ seconds: TimeInterval) -> String {121    if seconds < 90 { return "\(Int(seconds)) s left" }122    if seconds < 5400 { return "\(Int(seconds / 60)) min left" }123    return "\(Int(seconds / 3600)) h \(Int(seconds.truncatingRemainder(dividingBy: 3600) / 60)) min left"124}125