spb/prisme Public MIT
Navigateur iOS intelligent — chaque page comprise localement avant d'être affichée. SwiftUI · WebKit · Foundation Models, 100% on-device.
Swift 96.7%
JavaScript 3.3%
1//2// TabSwitcherView.swift3// Prisme4//5// Author: Simon-Pierre Boucher <contact@spboucher.ai>6//78import SwiftUI910/// Tab overview, sectioned by identity container. Intention-based grouping11/// (P0) will be *proposed* on top of this later — never imposed.12struct TabSwitcherView: View {13 let model: BrowserModel1415 @Environment(\.dismiss) private var dismiss16 @State private var showHistory = false1718 private let columns = [GridItem(.adaptive(minimum: 160), spacing: Spacing.m)]1920 var body: some View {21 NavigationStack {22 Group {23 if model.tabs.tabs.isEmpty {24 ContentUnavailableView(25 "Aucun onglet",26 systemImage: "square.on.square",27 description: Text("Touchez + pour ouvrir un onglet dans l'univers actif.")28 )29 } else {30 grid31 }32 }33 .navigationTitle("Onglets")34 .navigationBarTitleDisplayMode(.inline)35 .toolbar {36 ToolbarItem(placement: .cancellationAction) {37 Button("OK") { dismiss() }38 .fontWeight(.semibold)39 }40 ToolbarItem(placement: .primaryAction) {41 Button {42 model.newTab()43 dismiss()44 } label: {45 Image(systemName: "plus")46 }47 }48 ToolbarItem(placement: .secondaryAction) {49 Button {50 showHistory = true51 } label: {52 Label("Historique", systemImage: "clock.arrow.circlepath")53 }54 .accessibilityIdentifier("tabs.history")55 }56 }57 .sheet(isPresented: $showHistory) {58 HistoryView(59 history: model.history,60 containers: model.containers,61 onOpen: { visit in62 model.open(visit)63 dismiss()64 }65 )66 }67 }68 }6970 private var grid: some View {71 ScrollView {72 LazyVGrid(columns: columns, alignment: .leading, spacing: Spacing.m) {73 ForEach(model.containers.all) { container in74 let tabs = model.tabs.tabs(in: container.id)75 if !tabs.isEmpty {76 Section {77 ForEach(tabs) { tab in78 TabCard(79 tab: tab,80 container: container,81 isActive: tab.id == model.tabs.activeTabID,82 onSelect: {83 model.activate(tab)84 dismiss()85 },86 onClose: {87 withAnimation(.spring(duration: 0.3)) {88 model.tabs.close(tab)89 }90 }91 )92 .transition(.scale(scale: 0.85).combined(with: .opacity))93 }94 } header: {95 HStack(spacing: Spacing.s) {96 Image(systemName: container.symbol)97 .font(.subheadline.weight(.semibold))98 Text(container.name)99 .font(.headline)100 Text("\(tabs.count)")101 .font(.subheadline.weight(.semibold))102 .foregroundStyle(.secondary)103 }104 .foregroundStyle(container.color.color)105 .padding(.top, Spacing.l)106 }107 }108 }109 }110 .padding(Spacing.l)111 }112 }113}114115// MARK: - Card116117private struct TabCard: View {118 let tab: Tab119 let container: IdentityContainer120 let isActive: Bool121 let onSelect: () -> Void122 let onClose: () -> Void123124 var body: some View {125 Button(action: onSelect) {126 VStack(alignment: .leading, spacing: 0) {127 // Container-colored crown.128 Rectangle()129 .fill(container.color.gradient)130 .frame(height: 5)131132 VStack(alignment: .leading, spacing: Spacing.s) {133 HStack(alignment: .top) {134 siteBadge135 Spacer(minLength: Spacing.s)136 Button(action: onClose) {137 Image(systemName: "xmark")138 .font(.caption.bold())139 .foregroundStyle(.secondary)140 .frame(width: 26, height: 26)141 .background(.quaternary, in: Circle())142 .contentShape(Circle())143 }144 .buttonStyle(.plain)145 }146147 Text(tab.displayTitle)148 .font(.subheadline.weight(.semibold))149 .lineLimit(2)150 .multilineTextAlignment(.leading)151 .foregroundStyle(.primary)152153 if let host = tab.page.url?.host() {154 Text(host)155 .font(.caption)156 .foregroundStyle(.secondary)157 .lineLimit(1)158 }159 }160 .padding(Spacing.m)161 }162 .frame(maxWidth: .infinity, minHeight: 118, alignment: .topLeading)163 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: Radius.l))164 .overlay {165 RoundedRectangle(cornerRadius: Radius.l)166 .strokeBorder(167 isActive ? AnyShapeStyle(container.color.color) : AnyShapeStyle(.quaternary),168 lineWidth: isActive ? 2.5 : 1169 )170 }171 .clipShape(RoundedRectangle(cornerRadius: Radius.l))172 .shadow(color: .black.opacity(isActive ? 0.14 : 0.06), radius: 10, y: 4)173 }174 .buttonStyle(.plain)175 }176177 /// Favicon placeholder: first letter of the host in the container tint.178 private var siteBadge: some View {179 let letter = tab.page.url?.host()?180 .replacingOccurrences(of: "www.", with: "")181 .prefix(1).uppercased()182 return ZStack {183 Circle()184 .fill(container.color.color.opacity(0.16))185 .frame(width: 30, height: 30)186 if let letter, !letter.isEmpty {187 Text(letter)188 .font(.footnote.bold())189 .foregroundStyle(container.color.color)190 } else {191 Image(systemName: "globe")192 .font(.footnote.weight(.semibold))193 .foregroundStyle(container.color.color)194 }195 }196 }197}198