SPB Git

spb/zyquo-mlx Public MIT

The local MLX foundry for your Mac — run, fine-tune, quantize, and ship models. Nothing leaves your machine.

Swift 93.4% Python 3.8% Makefile 2.2% Shell 0.5%
8.2 KB · 249 lines swift
Raw Blame History
1//2//  WorkbenchView.swift3//  Zyquo MLX4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI1011/// The main workbench: 240pt translucent left navigator + contextual header12/// + section content (charter §4.2).13struct WorkbenchView: View {14    @State private var model = AppModel()1516    var body: some View {17        HStack(spacing: 0) {18            SidebarView(model: model)19                .frame(width: ZyquoTheme.sidebarWidth)2021            Rectangle()22                .fill(ZyquoTheme.border)23                .frame(width: ZyquoTheme.hairline)2425            VStack(spacing: 0) {26                HeaderBar(model: model)27                    .frame(height: ZyquoTheme.headerHeight)2829                Rectangle()30                    .fill(ZyquoTheme.border)31                    .frame(height: ZyquoTheme.hairline)3233                sectionContent34                    .frame(maxWidth: .infinity, maxHeight: .infinity)35            }36            .background(ZyquoTheme.background)37        }38        .frame(39            minWidth: ZyquoTheme.minWindowSize.width,40            minHeight: ZyquoTheme.minWindowSize.height)41        .task { await model.refresh() }42        .onReceive(NotificationCenter.default.publisher(for: .zyquoNavigate)) { note in43            if let section = note.object as? WorkbenchSection {44                model.selectedSection = section45            }46        }47    }4849    @ViewBuilder50    private var sectionContent: some View {51        switch model.selectedSection {52        case .models: ModelsView(model: model)53        case .datasets: DatasetsView(model: model)54        case .train: TrainView(model: model)55        case .convert: ConvertView(model: model)56        case .playground: PlaygroundView(model: model)57        case .evaluate: EvaluateView(model: model)58        }59    }60}6162// MARK: - Sidebar6364struct SidebarView: View {65    @Bindable var model: AppModel6667    var body: some View {68        VStack(alignment: .leading, spacing: 0) {69            // Wordmark70            HStack(spacing: ZyquoTheme.spacing8) {71                Image(systemName: "z.square.fill")72                    .font(.system(size: 22))73                    .foregroundStyle(ZyquoTheme.accent)74                Text("Zyquo MLX")75                    .font(ZyquoTheme.headlineFont)76                    .foregroundStyle(ZyquoTheme.textPrimary)77            }78            .padding(.horizontal, ZyquoTheme.spacing16)79            .padding(.top, ZyquoTheme.spacing20)80            .padding(.bottom, ZyquoTheme.spacing24)8182            ForEach(WorkbenchSection.allCases) { section in83                SidebarRow(84                    section: section,85                    isSelected: model.selectedSection == section,86                    badge: badge(for: section)87                ) {88                    withAnimation(ZyquoTheme.quickAnimation) {89                        model.selectedSection = section90                    }91                }92            }9394            Spacer()9596            SidebarFooter(model: model)97        }98        .background(.ultraThinMaterial)99    }100101    private func badge(for section: WorkbenchSection) -> String? {102        switch section {103        case .models: model.models.isEmpty ? nil : "\(model.models.count)"104        case .datasets: model.datasets.isEmpty ? nil : "\(model.datasets.count)"105        case .train: model.runs.isEmpty ? nil : "\(model.runs.count)"106        default: nil107        }108    }109}110111private struct SidebarRow: View {112    let section: WorkbenchSection113    let isSelected: Bool114    let badge: String?115    let action: () -> Void116117    @State private var isHovering = false118119    var body: some View {120        Button(action: action) {121            HStack(spacing: ZyquoTheme.spacing12) {122                Image(systemName: section.icon)123                    .font(.system(size: 14, weight: .medium))124                    .foregroundStyle(isSelected ? ZyquoTheme.accent : ZyquoTheme.textSecondary)125                    .frame(width: 20)126                Text(section.title)127                    .font(ZyquoTheme.bodyFont.weight(isSelected ? .semibold : .regular))128                    .foregroundStyle(isSelected ? ZyquoTheme.textPrimary : ZyquoTheme.textSecondary)129                Spacer()130                if let badge {131                    Text(badge)132                        .font(ZyquoTheme.captionFont)133                        .foregroundStyle(ZyquoTheme.textTertiary)134                }135            }136            .padding(.horizontal, ZyquoTheme.spacing12)137            .padding(.vertical, ZyquoTheme.spacing8)138            .background(139                RoundedRectangle(cornerRadius: ZyquoTheme.radiusSmall)140                    .fill(141                        isSelected142                            ? ZyquoTheme.accentSubtle143                            : isHovering ? ZyquoTheme.surfaceSecondary : .clear)144            )145            .contentShape(Rectangle())146        }147        .buttonStyle(.plain)148        .padding(.horizontal, ZyquoTheme.spacing8)149        .onHover { isHovering = $0 }150    }151}152153private struct SidebarFooter: View {154    @Bindable var model: AppModel155    @Environment(\.openSettings) private var openSettings156157    var body: some View {158        VStack(spacing: ZyquoTheme.spacing8) {159            Rectangle()160                .fill(ZyquoTheme.border)161                .frame(height: ZyquoTheme.hairline)162163            HStack(spacing: ZyquoTheme.spacing8) {164                Button {165                    openSettings()166                } label: {167                    Image(systemName: "gearshape")168                        .font(.system(size: 13))169                        .foregroundStyle(ZyquoTheme.textSecondary)170                }171                .buttonStyle(.plain)172                .help("Settings")173174                if model.activeJobCount > 0 {175                    HStack(spacing: ZyquoTheme.spacing4) {176                        ProgressView()177                            .controlSize(.small)178                        Text("\(model.activeJobCount)")179                            .font(ZyquoTheme.captionFont)180                            .foregroundStyle(ZyquoTheme.textSecondary)181                    }182                    .help("Active jobs")183                }184185                Spacer()186187                HStack(spacing: ZyquoTheme.spacing4) {188                    Circle()189                        .fill(memoryColor)190                        .frame(width: 7, height: 7)191                    Text(memoryLabel)192                        .font(ZyquoTheme.monoSmallFont)193                        .foregroundStyle(ZyquoTheme.textTertiary)194                }195                .help("GPU memory in use / unified memory")196            }197            .padding(.horizontal, ZyquoTheme.spacing16)198            .padding(.bottom, ZyquoTheme.spacing12)199        }200    }201202    private var memoryLabel: String {203        let used = Double(model.memoryUsedBytes) / 1_073_741_824204        let total = Double(model.memoryTotalBytes) / 1_073_741_824205        return String(format: "%.1f / %.0f GB", used, total)206    }207208    private var memoryColor: Color {209        let fraction = Double(model.memoryUsedBytes) / Double(max(1, model.memoryTotalBytes))210        if fraction > 0.75 { return ZyquoTheme.danger }211        if fraction > 0.5 { return ZyquoTheme.warning }212        return ZyquoTheme.success213    }214}215216// MARK: - Header217218struct HeaderBar: View {219    @Bindable var model: AppModel220221    var body: some View {222        HStack(spacing: ZyquoTheme.spacing12) {223            Text(model.selectedSection.title)224                .font(ZyquoTheme.titleFont)225                .foregroundStyle(ZyquoTheme.textPrimary)226227            Spacer()228229            if model.activeJobCount > 0 {230                StatusPill(231                    text: "\(model.activeJobCount) active job\(model.activeJobCount > 1 ? "s" : "")",232                    color: ZyquoTheme.accent)233            }234235            Button {236                Task { await model.refresh() }237            } label: {238                Image(systemName: "arrow.clockwise")239                    .font(.system(size: 12, weight: .medium))240                    .foregroundStyle(ZyquoTheme.textSecondary)241            }242            .buttonStyle(.plain)243            .help("Refresh library")244        }245        .padding(.horizontal, ZyquoTheme.spacing20)246        .background(ZyquoTheme.background)247    }248}249