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// AddressBar.swift3// Prisme4//5// Author: Simon-Pierre Boucher <contact@spboucher.ai>6//78import SwiftUI910/// The single entry field ("barre à intention", P0). Two states:11/// - compact: security indicator + host, reload/stop — one tap to edit;12/// - editing: text field with interpretation proposals above. The bar never13/// guesses in silence: the user confirms a proposal by tapping it or by14/// submitting, which picks the first (most likely) one.15/// Searches can go through DuckDuckGo or Google — both are proposed on every16/// query, and the default engine is switchable right from the proposal card.17/// Page load progress is drawn inside the capsule itself.18struct AddressBar: View {19 let page: WebPageProxy?20 let accent: Color21 var history: HistoryStore?22 let onIntent: (EntryIntent) -> Void23 var onRecall: ((PageVisit) -> Void)?24 var onOpenReader: (() -> Void)?2526 @State private var text = ""27 @State private var isEditing = false28 @FocusState private var fieldFocused: Bool29 @AppStorage("prisme.defaultSearchEngine")30 private var defaultEngineRaw = SearchEngine.duckDuckGo.rawValue3132 private var defaultEngine: SearchEngine {33 SearchEngine(rawValue: defaultEngineRaw) ?? .duckDuckGo34 }3536 private var proposals: [EntryIntent] {37 isEditing ? IntentResolver.propose(for: text, preferring: defaultEngine) : []38 }3940 var body: some View {41 VStack(spacing: Spacing.s) {42 if isEditing {43 proposalCard44 .transition(.move(edge: .bottom).combined(with: .opacity))45 }46 bar47 }48 .animation(.spring(duration: 0.28), value: isEditing)49 .animation(.spring(duration: 0.28), value: proposals)50 }5152 // MARK: - Bar5354 private var bar: some View {55 ZStack {56 if isEditing {57 editingField58 } else {59 compactDisplay60 }61 }62 .padding(.horizontal, Spacing.l)63 .frame(height: 52)64 .glassEffect(.regular.interactive(), in: RoundedRectangle(cornerRadius: Radius.xl))65 .overlay(alignment: .bottom) { progressLine }66 }6768 private var compactDisplay: some View {69 Button {70 text = page?.url?.absoluteString ?? ""71 isEditing = true72 } label: {73 HStack(spacing: Spacing.s) {74 if let url = page?.url {75 Image(systemName: url.scheme == "https" ? "lock.fill" : "globe")76 .font(.footnote)77 .foregroundStyle(url.scheme == "https" ? accent : .secondary)78 Text(url.host() ?? url.absoluteString)79 .lineLimit(1)80 .foregroundStyle(.primary)81 } else {82 Image(systemName: "magnifyingglass")83 .font(.footnote)84 .foregroundStyle(.secondary)85 Text("Rechercher ou saisir une adresse")86 .lineLimit(1)87 .foregroundStyle(.secondary)88 }89 Spacer()90 }91 .padding(.leading, page?.url != nil && onOpenReader != nil ? 36 : 0)92 .contentShape(Rectangle())93 }94 .buttonStyle(.plain)95 .accessibilityIdentifier("addressBar.compact")96 .overlay(alignment: .leading) {97 // Reader entry, always visible once a page is loaded — the98 // same affordance Safari trained everyone on.99 if page?.url != nil, let onOpenReader {100 Button(action: onOpenReader) {101 Image(systemName: "doc.plaintext")102 .font(.footnote.weight(.semibold))103 .foregroundStyle(accent)104 .frame(width: 32, height: 32)105 .contentShape(Rectangle())106 }107 .buttonStyle(.plain)108 .accessibilityIdentifier("addressBar.reader")109 }110 }111 .overlay(alignment: .trailing) {112 if page?.url != nil {113 Button {114 if page?.isLoading == true {115 page?.stopLoading()116 } else {117 page?.reload()118 }119 } label: {120 Image(systemName: page?.isLoading == true ? "xmark" : "arrow.clockwise")121 .font(.footnote.weight(.semibold))122 .foregroundStyle(.secondary)123 .frame(width: 32, height: 32)124 .contentShape(Rectangle())125 }126 .buttonStyle(.plain)127 }128 }129 }130131 private var editingField: some View {132 HStack(spacing: Spacing.s) {133 Image(systemName: "magnifyingglass")134 .font(.footnote)135 .foregroundStyle(accent)136137 TextField("Rechercher ou saisir une adresse", text: $text)138 .focused($fieldFocused)139 .textInputAutocapitalization(.never)140 .autocorrectionDisabled()141 .keyboardType(.webSearch)142 .submitLabel(.go)143 .onSubmit(confirmFirstProposal)144 .accessibilityIdentifier("addressBar.field")145 .task { fieldFocused = true }146 .onChange(of: fieldFocused) { _, focused in147 if !focused { isEditing = false }148 }149150 if !text.isEmpty {151 Button {152 text = ""153 } label: {154 Image(systemName: "xmark.circle.fill")155 .foregroundStyle(.secondary)156 }157 .buttonStyle(.plain)158 }159 }160 }161162 private var progressLine: some View {163 GeometryReader { geometry in164 if let page, page.isLoading {165 Capsule()166 .fill(accent)167 .frame(width: max(12, geometry.size.width * page.estimatedProgress), height: 3)168 .animation(.easeOut(duration: 0.25), value: page.estimatedProgress)169 }170 }171 .frame(height: 3)172 .padding(.horizontal, Spacing.m)173 .padding(.bottom, 3)174 }175176 // MARK: - Proposals177178 /// Pages remembered by the semantic history that match the input —179 /// recall is part of the intent bar, not a separate mode.180 private var recallMatches: [PageVisit] {181 guard isEditing, text.trimmingCharacters(in: .whitespaces).count >= 2 else { return [] }182 return Array((history?.search(text, limit: 3) ?? []).prefix(2))183 }184185 @ViewBuilder186 private var proposalCard: some View {187 VStack(spacing: 0) {188 ForEach(recallMatches, id: \.urlString) { visit in189 recallRow(visit)190 Divider().padding(.leading, 58)191 }192 ForEach(Array(proposals.enumerated()), id: \.element) { index, intent in193 proposalRow(intent, isPrimary: index == 0)194 Divider().padding(.leading, 58)195 }196 enginePicker197 }198 .glassEffect(.regular, in: RoundedRectangle(cornerRadius: Radius.l))199 }200201 private func recallRow(_ visit: PageVisit) -> some View {202 Button {203 fieldFocused = false204 isEditing = false205 onRecall?(visit)206 } label: {207 HStack(spacing: Spacing.m) {208 Image(systemName: "clock.arrow.circlepath")209 .font(.subheadline.weight(.semibold))210 .foregroundStyle(.secondary)211 .frame(width: 34, height: 34)212 .background(.quaternary.opacity(0.5), in: Circle())213214 VStack(alignment: .leading, spacing: 1) {215 Text(visit.title)216 .lineLimit(1)217 .foregroundStyle(.primary)218 Text("Déjà visité · \(visit.host)")219 .font(.caption)220 .foregroundStyle(.secondary)221 }222 Spacer()223 }224 .padding(.horizontal, Spacing.m)225 .padding(.vertical, Spacing.s + 2)226 .contentShape(Rectangle())227 }228 .buttonStyle(.plain)229 .accessibilityIdentifier("proposal.recall")230 }231232 private func proposalRow(_ intent: EntryIntent, isPrimary: Bool) -> some View {233 Button {234 confirm(intent)235 } label: {236 HStack(spacing: Spacing.m) {237 Image(systemName: intent.symbol)238 .font(.subheadline.weight(.semibold))239 .foregroundStyle(tint(for: intent))240 .frame(width: 34, height: 34)241 .background(tint(for: intent).opacity(0.14), in: Circle())242243 VStack(alignment: .leading, spacing: 1) {244 Text(intent.label)245 .lineLimit(1)246 .foregroundStyle(.primary)247 .fontWeight(isPrimary ? .medium : .regular)248 Text(intent.detail)249 .font(.caption)250 .foregroundStyle(.secondary)251 }252253 Spacer()254255 if isPrimary {256 Image(systemName: "return")257 .font(.caption)258 .foregroundStyle(.tertiary)259 }260 }261 .padding(.horizontal, Spacing.m)262 .padding(.vertical, Spacing.s + 2)263 .contentShape(Rectangle())264 }265 .buttonStyle(.plain)266 .accessibilityIdentifier(accessibilityID(for: intent))267 }268269 private func accessibilityID(for intent: EntryIntent) -> String {270 switch intent {271 case .navigate: "proposal.navigate"272 case .search(_, let engine): "proposal.search.\(engine.rawValue)"273 }274 }275276 /// Explicit default-engine control — visible, never a hidden setting.277 private var enginePicker: some View {278 HStack {279 Text("Moteur par défaut")280 .font(.caption)281 .foregroundStyle(.secondary)282 Spacer()283 Picker("Moteur par défaut", selection: $defaultEngineRaw) {284 ForEach(SearchEngine.allCases) { engine in285 Text(engine.name).tag(engine.rawValue)286 }287 }288 .pickerStyle(.segmented)289 .fixedSize()290 }291 .padding(.horizontal, Spacing.m)292 .padding(.vertical, Spacing.s)293 }294295 private func tint(for intent: EntryIntent) -> Color {296 switch intent {297 case .navigate:298 accent299 case .search(_, let engine):300 engine == .duckDuckGo ? Color.orange : Color(red: 0.26, green: 0.52, blue: 0.96)301 }302 }303304 private func confirmFirstProposal() {305 guard let first = proposals.first else { return }306 confirm(first)307 }308309 private func confirm(_ intent: EntryIntent) {310 fieldFocused = false311 isEditing = false312 onIntent(intent)313 }314}315