Files
oai-swift/oAI/Views/Screens/ModelSelectorView.swift
T
rune f1d739f309 Drop color tints from glass effects — rendering as solid blocks, not glass
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.
2026-08-21 15:22:11 +02:00

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 }
)
}