Rune caught this live too: content is now visible (last commit fixed that), but the tinted glass (.regular.tint(.blue) etc.) rendered as a solid, opaque, saturated color block instead of translucent frosted glass — looked like a plain filled button, not Liquid Glass. Dropped .tint() from all four Phase 1 conversions (tabButton, mcpSidebarRow, and ModelSelectorView's filter/category/favorites/sort chips) — the selection/active signal already comes from the icon/text foreground color turning blue (or yellow, or the category color), which was untouched by any of this. The glass itself is now always .regular with no tint. tabButton's unselected state also switched from .buttonStyle(.glass) to .buttonStyle(.plain) — restores the original "only the selected tab shows any background" look, since with the tint gone a plain .glass button style for every tab would visually flatten the selected/unselected distinction back down to icon color alone.
475 lines
18 KiB
Swift
475 lines
18 KiB
Swift
//
|
|
// ModelSelectorView.swift
|
|
// Confab
|
|
//
|
|
// Model selection screen
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of Confab.
|
|
//
|
|
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
|
|
// You may use, study, modify, and share it for any noncommercial
|
|
// purpose. Commercial use — including selling Confab or any part of
|
|
// it, standalone or bundled into another product or service —
|
|
// requires a separate commercial license from the copyright holder.
|
|
//
|
|
// See the LICENSE file or
|
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
// the full license text. For commercial licensing, contact Rune
|
|
// Olsen via <https://confab.no>.
|
|
|
|
|
|
import SwiftUI
|
|
|
|
struct ModelSelectorView: View {
|
|
let models: [ModelInfo]
|
|
let selectedModel: ModelInfo?
|
|
let onSelect: (ModelInfo) -> Void
|
|
|
|
@Environment(\.dismiss) var dismiss
|
|
@State private var searchText = ""
|
|
@State private var filterVision = false
|
|
@State private var filterTools = false
|
|
@State private var filterOnline = false
|
|
@State private var filterImageGen = false
|
|
@State private var filterThinking = false
|
|
@State private var filterFavorites = false
|
|
@State private var selectedCategory: ModelCategory? = nil
|
|
@State private var showCategoryPicker = false
|
|
@State private var keyboardIndex: Int = -1
|
|
@State private var sortOrder: ModelSortOrder = .default
|
|
@State private var selectedInfoModel: ModelInfo? = nil
|
|
@Bindable private var settings = SettingsService.shared
|
|
|
|
private var categoriesWithModels: Set<ModelCategory> {
|
|
Set(models.flatMap(\.categories))
|
|
}
|
|
|
|
private var filteredModels: [ModelInfo] {
|
|
let q = searchText.lowercased()
|
|
let filtered = models.filter { model in
|
|
let matchesSearch = searchText.isEmpty ||
|
|
model.name.lowercased().contains(q) ||
|
|
model.id.lowercased().contains(q) ||
|
|
model.description?.lowercased().contains(q) == true
|
|
|
|
let matchesVision = !filterVision || model.capabilities.vision
|
|
let matchesTools = !filterTools || model.capabilities.tools
|
|
let matchesOnline = !filterOnline || model.capabilities.online
|
|
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
|
|
let matchesThinking = !filterThinking || model.capabilities.thinking
|
|
let matchesFavorites = !filterFavorites || settings.favoriteModelIds.contains(model.id)
|
|
let matchesCategory = selectedCategory == nil || model.categories.contains(selectedCategory!)
|
|
|
|
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking && matchesFavorites && matchesCategory
|
|
}
|
|
|
|
let favIds = settings.favoriteModelIds
|
|
switch sortOrder {
|
|
case .default:
|
|
return filtered.sorted { a, b in
|
|
let aFav = favIds.contains(a.id)
|
|
let bFav = favIds.contains(b.id)
|
|
if aFav != bFav { return aFav }
|
|
return false
|
|
}
|
|
case .priceLowHigh:
|
|
return filtered.sorted { $0.pricing.prompt < $1.pricing.prompt }
|
|
case .priceHighLow:
|
|
return filtered.sorted { $0.pricing.prompt > $1.pricing.prompt }
|
|
case .contextHighLow:
|
|
return filtered.sorted { $0.contextLength > $1.contextLength }
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
// Search bar
|
|
TextField("Search models...", text: $searchText)
|
|
.textFieldStyle(.roundedBorder)
|
|
.padding()
|
|
.onChange(of: searchText) {
|
|
keyboardIndex = -1
|
|
}
|
|
|
|
// Filters + Sort
|
|
GlassEffectContainer(spacing: 12) {
|
|
HStack(spacing: 12) {
|
|
FilterToggle(isOn: $filterVision, icon: "\u{1F441}\u{FE0F}", label: "Vision")
|
|
FilterToggle(isOn: $filterTools, icon: "\u{1F527}", label: "Tools")
|
|
FilterToggle(isOn: $filterOnline, icon: "\u{1F310}", label: "Online")
|
|
FilterToggle(isOn: $filterImageGen, icon: "\u{1F3A8}", label: "Image Gen")
|
|
FilterToggle(isOn: $filterThinking, icon: "\u{1F9E0}", label: "Thinking")
|
|
|
|
Spacer()
|
|
|
|
// Category picker (only shown when at least one category has models)
|
|
if !categoriesWithModels.isEmpty {
|
|
Button(action: { showCategoryPicker.toggle() }) {
|
|
HStack(spacing: 4) {
|
|
if let cat = selectedCategory {
|
|
Circle().fill(cat.color).frame(width: 7, height: 7)
|
|
Text(LocalizedStringKey(cat.rawValue))
|
|
} else {
|
|
Image(systemName: "tag")
|
|
Text("Category")
|
|
}
|
|
}
|
|
.font(.caption)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.glassEffect(.regular, in: .rect(cornerRadius: 6))
|
|
.foregroundColor(selectedCategory != nil ? selectedCategory!.color : .secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Filter by category")
|
|
.popover(isPresented: $showCategoryPicker, arrowEdge: .bottom) {
|
|
CategoryPickerPopover(
|
|
categoriesWithModels: categoriesWithModels,
|
|
selectedCategory: $selectedCategory,
|
|
onSelect: { keyboardIndex = -1 }
|
|
)
|
|
}
|
|
} // end if !categoriesWithModels.isEmpty
|
|
|
|
// Favorites filter star
|
|
Button(action: { filterFavorites.toggle(); keyboardIndex = -1 }) {
|
|
Image(systemName: filterFavorites ? "star.fill" : "star")
|
|
.font(.caption)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.glassEffect(.regular, in: .rect(cornerRadius: 6))
|
|
.foregroundColor(filterFavorites ? .yellow : .secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Show favorites only")
|
|
|
|
// Sort menu
|
|
Menu {
|
|
ForEach(ModelSortOrder.allCases, id: \.rawValue) { order in
|
|
Button {
|
|
sortOrder = order
|
|
keyboardIndex = -1
|
|
} label: {
|
|
if sortOrder == order {
|
|
Label(order.label, systemImage: "checkmark")
|
|
} else {
|
|
Text(order.label)
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "arrow.up.arrow.down")
|
|
Text("Sort")
|
|
}
|
|
.font(.caption)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.glassEffect(.regular, in: .rect(cornerRadius: 6))
|
|
.foregroundColor(sortOrder != .default ? .blue : .secondary)
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
.fixedSize()
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.bottom, 12)
|
|
|
|
Divider()
|
|
|
|
// Model list
|
|
if filteredModels.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Models Found",
|
|
systemImage: "magnifyingglass",
|
|
description: Text("Try adjusting your search or filters")
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
ScrollViewReader { proxy in
|
|
List(Array(filteredModels.enumerated()), id: \.element.id) { index, model in
|
|
ModelRowView(
|
|
model: model,
|
|
isSelected: model.id == selectedModel?.id,
|
|
isKeyboardHighlighted: index == keyboardIndex,
|
|
isFavorite: settings.favoriteModelIds.contains(model.id),
|
|
onSelect: { onSelect(model) },
|
|
onFavorite: { settings.toggleFavoriteModel(model.id) },
|
|
onInfo: { selectedInfoModel = model }
|
|
)
|
|
.id(model.id)
|
|
}
|
|
.listStyle(.plain)
|
|
.onChange(of: keyboardIndex) { _, newIndex in
|
|
if newIndex >= 0 && newIndex < filteredModels.count {
|
|
withAnimation {
|
|
proxy.scrollTo(filteredModels[newIndex].id, anchor: .center)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(minWidth: 740, minHeight: 500)
|
|
.navigationTitle("Select Model")
|
|
#if os(macOS)
|
|
.onKeyPress(.downArrow) {
|
|
if keyboardIndex < filteredModels.count - 1 {
|
|
keyboardIndex += 1
|
|
}
|
|
return .handled
|
|
}
|
|
.onKeyPress(.upArrow) {
|
|
if keyboardIndex > 0 {
|
|
keyboardIndex -= 1
|
|
} else if keyboardIndex == -1 && !filteredModels.isEmpty {
|
|
keyboardIndex = 0
|
|
}
|
|
return .handled
|
|
}
|
|
.onKeyPress(.return) {
|
|
if keyboardIndex >= 0 && keyboardIndex < filteredModels.count {
|
|
onSelect(filteredModels[keyboardIndex])
|
|
return .handled
|
|
}
|
|
return .ignored
|
|
}
|
|
#endif
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
if let selected = selectedModel,
|
|
let index = filteredModels.firstIndex(where: { $0.id == selected.id }) {
|
|
keyboardIndex = index
|
|
}
|
|
}
|
|
.sheet(item: $selectedInfoModel) { model in
|
|
ModelInfoView(model: model)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sort Order
|
|
|
|
enum ModelSortOrder: String, CaseIterable {
|
|
case `default`
|
|
case priceLowHigh
|
|
case priceHighLow
|
|
case contextHighLow
|
|
|
|
var label: LocalizedStringKey {
|
|
switch self {
|
|
case .default: return "Default"
|
|
case .priceLowHigh: return "Price: Low to High"
|
|
case .priceHighLow: return "Price: High to Low"
|
|
case .contextHighLow: return "Context: High to Low"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Filter Toggle
|
|
|
|
struct FilterToggle: View {
|
|
@Binding var isOn: Bool
|
|
let icon: String
|
|
let label: LocalizedStringKey
|
|
|
|
var body: some View {
|
|
Button(action: { isOn.toggle() }) {
|
|
HStack(spacing: 4) {
|
|
Text(icon)
|
|
Text(label)
|
|
.lineLimit(1)
|
|
}
|
|
.font(.caption)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.glassEffect(.regular, in: .rect(cornerRadius: 6))
|
|
.foregroundColor(isOn ? .blue : .secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.fixedSize()
|
|
}
|
|
}
|
|
|
|
// MARK: - Category Picker Popover
|
|
|
|
struct CategoryPickerPopover: View {
|
|
let categoriesWithModels: Set<ModelCategory>
|
|
@Binding var selectedCategory: ModelCategory?
|
|
let onSelect: () -> Void
|
|
|
|
private let columns = [GridItem(.adaptive(minimum: 130), spacing: 8)]
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Text("Filter by Category")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
if selectedCategory != nil {
|
|
Button("Clear") {
|
|
selectedCategory = nil
|
|
onSelect()
|
|
}
|
|
.font(.caption)
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.blue)
|
|
}
|
|
}
|
|
|
|
LazyVGrid(columns: columns, spacing: 8) {
|
|
ForEach(ModelCategory.allCases.filter { categoriesWithModels.contains($0) }, id: \.rawValue) { cat in
|
|
let isSelected = selectedCategory == cat
|
|
Button {
|
|
selectedCategory = isSelected ? nil : cat
|
|
onSelect()
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: cat.systemImage)
|
|
.font(.caption)
|
|
.frame(width: 14)
|
|
Text(LocalizedStringKey(cat.rawValue))
|
|
.font(.caption)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 7)
|
|
.background(isSelected ? cat.color.opacity(0.18) : Color.gray.opacity(0.08))
|
|
.foregroundColor(isSelected ? cat.color : .primary)
|
|
.cornerRadius(6)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.strokeBorder(isSelected ? cat.color.opacity(0.45) : Color.clear, lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
.padding(14)
|
|
.frame(minWidth: 360)
|
|
}
|
|
}
|
|
|
|
// MARK: - Model Row
|
|
|
|
struct ModelRowView: View {
|
|
let model: ModelInfo
|
|
let isSelected: Bool
|
|
var isKeyboardHighlighted: Bool = false
|
|
var isFavorite: Bool = false
|
|
let onSelect: () -> Void
|
|
var onFavorite: (() -> Void)? = nil
|
|
let onInfo: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 8) {
|
|
// Selectable main content
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 6) {
|
|
if let onFavorite {
|
|
Button(action: onFavorite) {
|
|
Image(systemName: isFavorite ? "star.fill" : "star")
|
|
.font(.system(size: 13))
|
|
.foregroundColor(isFavorite ? .yellow : .secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(isFavorite ? "Remove from favorites" : "Add to favorites")
|
|
}
|
|
Text(model.name)
|
|
.font(.headline)
|
|
.foregroundColor(isSelected ? .blue : .primary)
|
|
|
|
if isSelected {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.blue)
|
|
}
|
|
}
|
|
|
|
Text(model.id)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
if let description = model.description {
|
|
Text(description)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
|
|
HStack(spacing: 12) {
|
|
Label(model.contextLengthDisplay, systemImage: "text.alignleft")
|
|
Label(model.promptPriceDisplay + "/1M", systemImage: "dollarsign.circle")
|
|
}
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { onSelect() }
|
|
|
|
// Right side: capabilities + category dots + info button
|
|
VStack(alignment: .trailing, spacing: 6) {
|
|
// Capability icons
|
|
HStack(spacing: 4) {
|
|
if model.capabilities.vision { Text("\u{1F441}\u{FE0F}").font(.caption) }
|
|
if model.capabilities.tools { Text("\u{1F527}").font(.caption) }
|
|
if model.capabilities.online { Text("\u{1F310}").font(.caption) }
|
|
if model.capabilities.imageGeneration { Text("\u{1F3A8}").font(.caption) }
|
|
if model.capabilities.thinking { Text("\u{1F9E0}").font(.caption) }
|
|
}
|
|
|
|
// Category dots
|
|
if !model.categories.isEmpty {
|
|
HStack(spacing: 3) {
|
|
ForEach(model.categories, id: \.rawValue) { cat in
|
|
Circle()
|
|
.fill(cat.color)
|
|
.frame(width: 7, height: 7)
|
|
.help(cat.rawValue)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Info button
|
|
Button(action: onInfo) {
|
|
Image(systemName: "info.circle")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.frame(width: 20, height: 20)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Show model info")
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
.padding(.vertical, 6)
|
|
.padding(.horizontal, isKeyboardHighlighted ? 4 : 0)
|
|
.background(
|
|
isKeyboardHighlighted
|
|
? RoundedRectangle(cornerRadius: 6).fill(Color.accentColor.opacity(0.15))
|
|
: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ModelSelectorView(
|
|
models: ModelInfo.mockModels,
|
|
selectedModel: ModelInfo.mockModels.first,
|
|
onSelect: { _ in }
|
|
)
|
|
}
|