SPB Git

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%
11.0 KB · 315 lines swift
Raw Blame History
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