// // AddressBar.swift // Prisme // // Author: Simon-Pierre Boucher // import SwiftUI /// The single entry field ("barre à intention", P0). Two states: /// - compact: security indicator + host, reload/stop — one tap to edit; /// - editing: text field with interpretation proposals above. The bar never /// guesses in silence: the user confirms a proposal by tapping it or by /// submitting, which picks the first (most likely) one. /// Searches can go through DuckDuckGo or Google — both are proposed on every /// query, and the default engine is switchable right from the proposal card. /// Page load progress is drawn inside the capsule itself. struct AddressBar: View { let page: WebPageProxy? let accent: Color var history: HistoryStore? let onIntent: (EntryIntent) -> Void var onRecall: ((PageVisit) -> Void)? var onOpenReader: (() -> Void)? @State private var text = "" @State private var isEditing = false @FocusState private var fieldFocused: Bool @AppStorage("prisme.defaultSearchEngine") private var defaultEngineRaw = SearchEngine.duckDuckGo.rawValue private var defaultEngine: SearchEngine { SearchEngine(rawValue: defaultEngineRaw) ?? .duckDuckGo } private var proposals: [EntryIntent] { isEditing ? IntentResolver.propose(for: text, preferring: defaultEngine) : [] } var body: some View { VStack(spacing: Spacing.s) { if isEditing { proposalCard .transition(.move(edge: .bottom).combined(with: .opacity)) } bar } .animation(.spring(duration: 0.28), value: isEditing) .animation(.spring(duration: 0.28), value: proposals) } // MARK: - Bar private var bar: some View { ZStack { if isEditing { editingField } else { compactDisplay } } .padding(.horizontal, Spacing.l) .frame(height: 52) .glassEffect(.regular.interactive(), in: RoundedRectangle(cornerRadius: Radius.xl)) .overlay(alignment: .bottom) { progressLine } } private var compactDisplay: some View { Button { text = page?.url?.absoluteString ?? "" isEditing = true } label: { HStack(spacing: Spacing.s) { if let url = page?.url { Image(systemName: url.scheme == "https" ? "lock.fill" : "globe") .font(.footnote) .foregroundStyle(url.scheme == "https" ? accent : .secondary) Text(url.host() ?? url.absoluteString) .lineLimit(1) .foregroundStyle(.primary) } else { Image(systemName: "magnifyingglass") .font(.footnote) .foregroundStyle(.secondary) Text("Rechercher ou saisir une adresse") .lineLimit(1) .foregroundStyle(.secondary) } Spacer() } .padding(.leading, page?.url != nil && onOpenReader != nil ? 36 : 0) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityIdentifier("addressBar.compact") .overlay(alignment: .leading) { // Reader entry, always visible once a page is loaded — the // same affordance Safari trained everyone on. if page?.url != nil, let onOpenReader { Button(action: onOpenReader) { Image(systemName: "doc.plaintext") .font(.footnote.weight(.semibold)) .foregroundStyle(accent) .frame(width: 32, height: 32) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityIdentifier("addressBar.reader") } } .overlay(alignment: .trailing) { if page?.url != nil { Button { if page?.isLoading == true { page?.stopLoading() } else { page?.reload() } } label: { Image(systemName: page?.isLoading == true ? "xmark" : "arrow.clockwise") .font(.footnote.weight(.semibold)) .foregroundStyle(.secondary) .frame(width: 32, height: 32) .contentShape(Rectangle()) } .buttonStyle(.plain) } } } private var editingField: some View { HStack(spacing: Spacing.s) { Image(systemName: "magnifyingglass") .font(.footnote) .foregroundStyle(accent) TextField("Rechercher ou saisir une adresse", text: $text) .focused($fieldFocused) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.webSearch) .submitLabel(.go) .onSubmit(confirmFirstProposal) .accessibilityIdentifier("addressBar.field") .task { fieldFocused = true } .onChange(of: fieldFocused) { _, focused in if !focused { isEditing = false } } if !text.isEmpty { Button { text = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } .buttonStyle(.plain) } } } private var progressLine: some View { GeometryReader { geometry in if let page, page.isLoading { Capsule() .fill(accent) .frame(width: max(12, geometry.size.width * page.estimatedProgress), height: 3) .animation(.easeOut(duration: 0.25), value: page.estimatedProgress) } } .frame(height: 3) .padding(.horizontal, Spacing.m) .padding(.bottom, 3) } // MARK: - Proposals /// Pages remembered by the semantic history that match the input — /// recall is part of the intent bar, not a separate mode. private var recallMatches: [PageVisit] { guard isEditing, text.trimmingCharacters(in: .whitespaces).count >= 2 else { return [] } return Array((history?.search(text, limit: 3) ?? []).prefix(2)) } @ViewBuilder private var proposalCard: some View { VStack(spacing: 0) { ForEach(recallMatches, id: \.urlString) { visit in recallRow(visit) Divider().padding(.leading, 58) } ForEach(Array(proposals.enumerated()), id: \.element) { index, intent in proposalRow(intent, isPrimary: index == 0) Divider().padding(.leading, 58) } enginePicker } .glassEffect(.regular, in: RoundedRectangle(cornerRadius: Radius.l)) } private func recallRow(_ visit: PageVisit) -> some View { Button { fieldFocused = false isEditing = false onRecall?(visit) } label: { HStack(spacing: Spacing.m) { Image(systemName: "clock.arrow.circlepath") .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) .frame(width: 34, height: 34) .background(.quaternary.opacity(0.5), in: Circle()) VStack(alignment: .leading, spacing: 1) { Text(visit.title) .lineLimit(1) .foregroundStyle(.primary) Text("Déjà visité · \(visit.host)") .font(.caption) .foregroundStyle(.secondary) } Spacer() } .padding(.horizontal, Spacing.m) .padding(.vertical, Spacing.s + 2) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityIdentifier("proposal.recall") } private func proposalRow(_ intent: EntryIntent, isPrimary: Bool) -> some View { Button { confirm(intent) } label: { HStack(spacing: Spacing.m) { Image(systemName: intent.symbol) .font(.subheadline.weight(.semibold)) .foregroundStyle(tint(for: intent)) .frame(width: 34, height: 34) .background(tint(for: intent).opacity(0.14), in: Circle()) VStack(alignment: .leading, spacing: 1) { Text(intent.label) .lineLimit(1) .foregroundStyle(.primary) .fontWeight(isPrimary ? .medium : .regular) Text(intent.detail) .font(.caption) .foregroundStyle(.secondary) } Spacer() if isPrimary { Image(systemName: "return") .font(.caption) .foregroundStyle(.tertiary) } } .padding(.horizontal, Spacing.m) .padding(.vertical, Spacing.s + 2) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityIdentifier(accessibilityID(for: intent)) } private func accessibilityID(for intent: EntryIntent) -> String { switch intent { case .navigate: "proposal.navigate" case .search(_, let engine): "proposal.search.\(engine.rawValue)" } } /// Explicit default-engine control — visible, never a hidden setting. private var enginePicker: some View { HStack { Text("Moteur par défaut") .font(.caption) .foregroundStyle(.secondary) Spacer() Picker("Moteur par défaut", selection: $defaultEngineRaw) { ForEach(SearchEngine.allCases) { engine in Text(engine.name).tag(engine.rawValue) } } .pickerStyle(.segmented) .fixedSize() } .padding(.horizontal, Spacing.m) .padding(.vertical, Spacing.s) } private func tint(for intent: EntryIntent) -> Color { switch intent { case .navigate: accent case .search(_, let engine): engine == .duckDuckGo ? Color.orange : Color(red: 0.26, green: 0.52, blue: 0.96) } } private func confirmFirstProposal() { guard let first = proposals.first else { return } confirm(first) } private func confirm(_ intent: EntryIntent) { fieldFocused = false isEditing = false onIntent(intent) } }