diff --git a/Sources/Kaset/KasetApp.swift b/Sources/Kaset/KasetApp.swift index a24bf6c4e..1a50e457b 100644 --- a/Sources/Kaset/KasetApp.swift +++ b/Sources/Kaset/KasetApp.swift @@ -2,15 +2,15 @@ import AppKit import SwiftUI extension EnvironmentValues { - @Entry var searchFocusTrigger: Binding = .constant(false) + @Entry var navigationSelection: Binding = .constant(nil) } extension EnvironmentValues { - @Entry var navigationSelection: Binding = .constant(nil) + @Entry var showCommandBar: Binding = .constant(false) } extension EnvironmentValues { - @Entry var showCommandBar: Binding = .constant(false) + @Entry var showSearchOverlay: Binding = .constant(false) } extension EnvironmentValues { @@ -49,9 +49,6 @@ struct KasetApp: App { @State private var settings = SettingsManager.shared @State private var podcastsAvailabilityService = PodcastsAvailabilityService() - /// Triggers search field focus when set to true. - @State private var searchFocusTrigger = false - /// Current navigation selection for keyboard navigation. @State private var navigationSelection: NavigationItem? = SettingsManager.shared.launchNavigationItem @@ -61,6 +58,9 @@ struct KasetApp: App { /// Whether the command bar is visible. @State private var showCommandBar = false + /// Whether the search overlay is visible. + @State private var showSearchOverlay = false + /// Whether the "What's New" sheet should be shown. @State private var showWhatsNew = false @@ -165,6 +165,7 @@ struct KasetApp: App { navigationSelection: self.$navigationSelection, youtubeNavigationSelection: self.$youtubeNavigationSelection, didCompleteStartupPlaybackCleanup: self.$didCompleteStartupPlaybackCleanup, + showSearchOverlayRequest: self.$showSearchOverlay, client: self.sharedClient, youtubeClient: self.sharedYouTubeClient ) @@ -182,9 +183,9 @@ struct KasetApp: App { .environment(self.syncedLyricsService) .environment(self.equalizerService) .environment(self.podcastsAvailabilityService) - .environment(\.searchFocusTrigger, self.$searchFocusTrigger) .environment(\.navigationSelection, self.$navigationSelection) .environment(\.showCommandBar, self.$showCommandBar) + .environment(\.showSearchOverlay, self.$showSearchOverlay) .environment(\.showWhatsNew, self.$showWhatsNew) .environment(\.usesLegacyMacOS15UI, self.settings.useLegacyMacOS15UI) .onAppear { @@ -431,18 +432,9 @@ struct KasetApp: App { Divider() - // Search - ⌘F + // Search - Command-F opens the floating search overlay for the active source. Button("Search") { - if self.settings.appSource == .video { - self.youtubeNavigationSelection = .search - return - } - self.navigationSelection = .search - // Trigger focus after a brief delay to allow view to appear - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(100)) - self.searchFocusTrigger = true - } + self.showSearchOverlay = true } .keyboardShortcut("f", modifiers: .command) diff --git a/Sources/Kaset/Services/SearchHistoryStore.swift b/Sources/Kaset/Services/SearchHistoryStore.swift new file mode 100644 index 000000000..7417edad2 --- /dev/null +++ b/Sources/Kaset/Services/SearchHistoryStore.swift @@ -0,0 +1,168 @@ +import Foundation +import Observation + +// MARK: - SearchHistoryStore + +/// Persists a small, ordered, de-duplicated list of recent search queries for a +/// given source (Music or YouTube). Backs the "Latest Searches" list in the +/// search overlay. Modeled on `FavoritesManager` persistence: JSON file in the +/// sandboxed Application Support folder, debounced off-main writes. +@MainActor +@Observable +final class SearchHistoryStore { + /// The search surface a store belongs to; determines the on-disk filename. + enum Source: String { + case music + case youtube + + var fileName: String { + "search-history-\(self.rawValue).json" + } + } + + /// Maximum number of recent queries kept. + static let maxItems = 30 + + /// Recent queries, most-recent first. + private(set) var items: [String] = [] + + private let source: Source + private let skipPersistence: Bool + private var saveTask: Task? + + // MARK: - Persistence + + private var fileURL: URL { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support", isDirectory: true) + let kasetDir = appSupport.appendingPathComponent("Kaset", isDirectory: true) + return kasetDir.appendingPathComponent(self.source.fileName) + } + + // MARK: - Initialization + + init(source: Source) { + self.source = source + // In UI test mode we keep history in-memory only so live user data is untouched. + if UITestConfig.isUITestMode { + self.skipPersistence = true + self.loadMockHistoryIfAvailable() + } else { + self.skipPersistence = false + self.load() + } + } + + /// Test initializer that never touches disk. + init(source: Source, skipPersistence: Bool) { + self.source = source + self.skipPersistence = skipPersistence + if !skipPersistence { + self.load() + } + } + + // MARK: - Load & Save + + /// Loads items from disk (once, at init). + func load() { + do { + guard FileManager.default.fileExists(atPath: self.fileURL.path) else { + DiagnosticsLogger.ui.debug("Search history file does not exist, starting fresh") + return + } + let data = try Data(contentsOf: self.fileURL) + let decoded = try JSONDecoder().decode([String].self, from: data) + self.items = Self.normalized(decoded) + DiagnosticsLogger.ui.info("Loaded \(self.items.count) \(self.source.rawValue) search history items") + } catch { + DiagnosticsLogger.ui.error("Failed to load search history: \(error.localizedDescription)") + self.items = [] + } + } + + /// Loads mock history from UI-test launch environment when provided. + private func loadMockHistoryIfAvailable() { + guard let raw = UITestConfig.environmentValue(for: UITestConfig.mockSearchHistoryKey), + let data = raw.data(using: .utf8), + let decoded = try? JSONDecoder().decode([String].self, from: data) + else { return } + self.items = Self.normalized(decoded) + } + + /// Persists the current items off the main actor, debounced. + private func save() { + guard !self.skipPersistence else { return } + + self.saveTask?.cancel() + let itemsSnapshot = self.items + let targetURL = self.fileURL + + self.saveTask = Task(priority: .utility) { + try? await Task.sleep(for: .milliseconds(100)) + guard !Task.isCancelled else { return } + + do { + let directory = targetURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(itemsSnapshot) + try data.write(to: targetURL, options: .atomic) + DiagnosticsLogger.ui.debug("Saved \(itemsSnapshot.count) search history items") + } catch { + DiagnosticsLogger.ui.error("Failed to save search history: \(error.localizedDescription)") + } + } + } + + // MARK: - Actions + + /// Records a query at the front, de-duplicating case-insensitively and capping the list. + /// Blank queries are ignored. + func record(_ query: String) { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + self.items.removeAll { $0.caseInsensitiveCompare(trimmed) == .orderedSame } + self.items.insert(trimmed, at: 0) + if self.items.count > Self.maxItems { + self.items.removeLast(self.items.count - Self.maxItems) + } + self.save() + } + + /// Removes a single recorded query, matching case-insensitively. Blank input + /// and queries not present are ignored (no save). + func remove(_ query: String) { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + let originalCount = self.items.count + self.items.removeAll { $0.caseInsensitiveCompare(trimmed) == .orderedSame } + guard self.items.count != originalCount else { return } + self.save() + } + + /// Clears all recent queries. + func clear() { + guard !self.items.isEmpty else { return } + self.items.removeAll() + self.save() + } + + // MARK: - Helpers + + /// Trims, drops blanks, de-duplicates case-insensitively (keeping first occurrence), + /// and caps to `maxItems`. Used when loading possibly-stale data from disk. + private static func normalized(_ raw: [String]) -> [String] { + var seen = Set() + var result: [String] = [] + for entry in raw { + let trimmed = entry.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + guard seen.insert(trimmed.lowercased()).inserted else { continue } + result.append(trimmed) + if result.count >= Self.maxItems { break } + } + return result + } +} diff --git a/Sources/Kaset/Utilities/AccessibilityIdentifiers.swift b/Sources/Kaset/Utilities/AccessibilityIdentifiers.swift index e64d83f5c..782325ac6 100644 --- a/Sources/Kaset/Utilities/AccessibilityIdentifiers.swift +++ b/Sources/Kaset/Utilities/AccessibilityIdentifiers.swift @@ -138,6 +138,24 @@ enum AccessibilityID { } } + // MARK: - Search Overlay + + enum SearchOverlay { + static let backdrop = "searchOverlay.backdrop" + static let window = "searchOverlay.window" + static let input = "searchOverlay.input" + static let returnHint = "searchOverlay.returnHint" + static let historyList = "searchOverlay.historyList" + + static func historyRow(index: Int) -> String { + "searchOverlay.history.\(index)" + } + + static func removeHistoryButton(index: Int) -> String { + "searchOverlay.removeHistoryButton.\(index)" + } + } + // MARK: - LibraryView enum Library { diff --git a/Sources/Kaset/Utilities/UITestConfig.swift b/Sources/Kaset/Utilities/UITestConfig.swift index c8a34fe24..207ef1bdb 100644 --- a/Sources/Kaset/Utilities/UITestConfig.swift +++ b/Sources/Kaset/Utilities/UITestConfig.swift @@ -11,6 +11,9 @@ enum UITestConfig { /// When present, skip auth and assume logged in. static let skipAuthArgument = "-SkipAuth" + /// When present, opens the search overlay as soon as the main window appears. + static let openSearchOverlayArgument = "-OpenSearchOverlay" + // MARK: - Environment Keys /// JSON-encoded mock home sections data. @@ -51,6 +54,15 @@ enum UITestConfig { /// Podcasts discovery surface. Used to UI-test sidebar visibility. static let mockPodcastsRegionUnavailableKey = "MOCK_PODCASTS_REGION_UNAVAILABLE" + /// JSON-encoded search history items for UI tests. + static let mockSearchHistoryKey = "MOCK_SEARCH_HISTORY" + + /// Initial search overlay query for UI tests. + static let mockSearchOverlayQueryKey = "MOCK_SEARCH_OVERLAY_QUERY" + + /// When true, open the search overlay after launch in UI tests. + static let openSearchOverlayKey = "OPEN_SEARCH_OVERLAY" + // MARK: - Detection /// Returns true if the app was launched in UI test mode. @@ -81,4 +93,10 @@ enum UITestConfig { static func environmentValue(for key: String) -> String? { ProcessInfo.processInfo.environment[key] } + + /// Returns true when UI tests should open the search overlay after launch. + static var shouldOpenSearchOverlay: Bool { + CommandLine.arguments.contains(openSearchOverlayArgument) + || ProcessInfo.processInfo.environment[openSearchOverlayKey] == "1" + } } diff --git a/Sources/Kaset/ViewModels/SearchViewModel.swift b/Sources/Kaset/ViewModels/SearchViewModel.swift index 482ec4955..07379a782 100644 --- a/Sources/Kaset/ViewModels/SearchViewModel.swift +++ b/Sources/Kaset/ViewModels/SearchViewModel.swift @@ -76,6 +76,9 @@ final class SearchViewModel { var selectedFilter: SearchFilter = .all { didSet { guard oldValue != self.selectedFilter, !self.query.isEmpty else { return } + // A batched overlay submit sets query + filter together and starts its + // own search; skip the reactive search this setter would otherwise run. + guard !self.suppressFilterSearch else { return } // If we've previously searched this query, perform a filtered search // to get the best results for the selected filter. If no prior @@ -157,6 +160,9 @@ final class SearchViewModel { @ObservationIgnored private var searchTask: Task? @ObservationIgnored private var suggestionsTask: Task? @ObservationIgnored private var suppressedSuggestionsQuery: String? + /// While true, `selectedFilter.didSet` skips its reactive search. Used by the + /// overlay submit path, which sets query + filter and starts a single search. + @ObservationIgnored private var suppressFilterSearch = false // swiftformat:enable modifierOrder private struct SearchAllAttempt { @@ -276,6 +282,20 @@ final class SearchViewModel { } } + /// Submits a query and filter as one intentional operation (used by the + /// search overlay). Sets both without triggering the `selectedFilter` + /// reactive search, then starts exactly one immediate search. + func searchImmediately(query submittedQuery: String, filter: SearchFilter = .all) { + let trimmed = submittedQuery.trimmingCharacters(in: .whitespacesAndNewlines) + + self.suppressFilterSearch = true + defer { self.suppressFilterSearch = false } + self.selectedFilter = filter + self.query = trimmed + + self.searchImmediately() + } + /// Performs a search with the current filter (no debounce, called when filter changes). private func searchWithFilter() { self.searchTask?.cancel() @@ -365,7 +385,7 @@ final class SearchViewModel { /// Performs the broadest search for the All filter by combining the mixed search response /// with the dedicated result-type searches. - private func searchAll(query: String, filter: SearchFilter) async throws -> SearchResponse { + private func searchAll(query: String, filter _: SearchFilter) async throws -> SearchResponse { async let mixedResults = self.attemptSearch(label: "mixed search") { try await self.client.search(query: query) } @@ -389,13 +409,6 @@ final class SearchViewModel { } let mixedAttempt = await mixedResults - if let mixedResponse = mixedAttempt.response, - !mixedResponse.isEmpty, - self.isCurrentSearch(query: query, filter: filter) - { - self.publishSearchResults(mixedResponse, query: query, filter: filter) - } - let attempts = await [ mixedAttempt, songResults, diff --git a/Sources/Kaset/Views/CommandBarView.swift b/Sources/Kaset/Views/CommandBarView.swift index d9b935dea..e6f56cfc5 100644 --- a/Sources/Kaset/Views/CommandBarView.swift +++ b/Sources/Kaset/Views/CommandBarView.swift @@ -30,7 +30,6 @@ struct CommandBarView: View { playerService: PlayerService, isPresented: Binding, navigationSelection: Binding, - searchFocusTrigger: Binding, searchViewModel: SearchViewModel? = nil ) { self.client = client @@ -48,10 +47,6 @@ struct CommandBarView: View { searchViewModel.searchImmediately() } isPresented.wrappedValue = false - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(100)) - searchFocusTrigger.wrappedValue = true - } }, dismissAction: { isPresented.wrappedValue = false @@ -273,7 +268,6 @@ private struct SuggestionChip: View { #Preview { @Previewable @State var isPresented = true @Previewable @State var navigationSelection: NavigationItem? - @Previewable @State var searchFocusTrigger = false let playerService = PlayerService() let authService = AuthService() let client = YTMusicClient(authService: authService, webKitManager: .shared) @@ -281,8 +275,7 @@ private struct SuggestionChip: View { client: client, playerService: playerService, isPresented: $isPresented, - navigationSelection: $navigationSelection, - searchFocusTrigger: $searchFocusTrigger + navigationSelection: $navigationSelection ) .padding(40) .frame(width: 600, height: 300) diff --git a/Sources/Kaset/Views/MainWindow.swift b/Sources/Kaset/Views/MainWindow.swift index 42bb30521..f26485eeb 100644 --- a/Sources/Kaset/Views/MainWindow.swift +++ b/Sources/Kaset/Views/MainWindow.swift @@ -19,6 +19,7 @@ struct MainWindow: View { static let commandBarTopPadding: CGFloat = 72 } + @Environment(\.colorScheme) private var colorScheme @Environment(AuthService.self) private var authService @Environment(PlayerService.self) private var playerService @Environment(YouTubePlayerService.self) private var youtubePlayerService @@ -26,7 +27,6 @@ struct MainWindow: View { @Environment(AccountService.self) private var accountService @Environment(SongLikeStatusManager.self) private var likeStatusManager @Environment(PodcastsAvailabilityService.self) private var podcastsAvailability - @Environment(\.searchFocusTrigger) private var searchFocusTrigger @Environment(\.showCommandBar) private var showCommandBar @Environment(\.showWhatsNew) private var showWhatsNew @Environment(\.usesLegacyMacOS15UI) private var usesLegacyMacOS15UI @@ -40,6 +40,9 @@ struct MainWindow: View { /// Whether startup guest playback cleanup has completed. @Binding var didCompleteStartupPlaybackCleanup: Bool + /// App-level request flag used by menu commands and sidebar rows to open search. + @Binding private var showSearchOverlayRequest: Bool + /// Shared API client used by all views and services. let client: any YTMusicClientProtocol @@ -54,6 +57,16 @@ struct MainWindow: View { @State private var showLoginSheet = false @State private var isCommandBarPresented = false + @State private var isSearchOverlayPresented = false + @State private var isSearchOverlaySearching = false + /// Draft text shown in the overlay input, kept separate from the result view + /// models so typing/opening the overlay never churns their search state. + @State private var searchOverlayDraftQuery = "" + /// Identifies the in-flight overlay submission so a stale completion cannot + /// close a newer overlay session. + @State private var activeSearchOverlayRunID: UUID? + @State private var musicSearchHistory = SearchHistoryStore(source: .music) + @State private var youtubeSearchHistory = SearchHistoryStore(source: .youtube) @State private var whatsNewToPresent: PresentedWhatsNew? @State private var selectedSidebarPinnedItem: SidebarPinnedItem? @State private var contentResetID = UUID() @@ -82,12 +95,14 @@ struct MainWindow: View { navigationSelection: Binding, youtubeNavigationSelection: Binding, didCompleteStartupPlaybackCleanup: Binding, + showSearchOverlayRequest: Binding, client: any YTMusicClientProtocol, youtubeClient: any YouTubeClientProtocol ) { self._navigationSelection = navigationSelection self._youtubeNavigationSelection = youtubeNavigationSelection self._didCompleteStartupPlaybackCleanup = didCompleteStartupPlaybackCleanup + self._showSearchOverlayRequest = showSearchOverlayRequest self.client = client self.youtubeClient = youtubeClient _youtubeStore = State(initialValue: YouTubeViewModelStore(client: youtubeClient)) @@ -108,11 +123,6 @@ struct MainWindow: View { _historyViewModel = State(initialValue: HistoryViewModel(client: client)) } - /// Access to the app delegate for persistent WebView. - private var appDelegate: AppDelegate? { - NSApplication.shared.delegate as? AppDelegate - } - var body: some View { @Bindable var player = self.playerService @@ -148,6 +158,7 @@ struct MainWindow: View { } .onAppear { DiagnosticsLogger.app.info("MainWindow: UI appeared") + self.presentSearchOverlayForUITestIfRequested() } .task { DiagnosticsLogger.app.info("MainWindow: Starting login check check...") @@ -167,6 +178,8 @@ struct MainWindow: View { transaction.animation = nil } } + + self.searchOverlayLayer } .sheet(isPresented: self.$showLoginSheet) { LoginSheet() @@ -214,6 +227,13 @@ struct MainWindow: View { self.showCommandBar.wrappedValue = false } } + .onChange(of: self.showSearchOverlayRequest) { _, newValue in + if newValue { + self.presentSearchOverlay() + self.showSearchOverlayRequest = false + } + } + .modifier(MusicSearchOverlayCompletionObserver(loadingState: self.searchViewModel?.loadingState, onChange: self.handleMusicSearchLoadingStateChange)) .onChange(of: self.usesLegacyMacOS15UI) { _, usesLegacyUI in if usesLegacyUI { self.isCommandBarPresented = false @@ -526,7 +546,6 @@ struct MainWindow: View { playerService: self.playerService, isPresented: self.$isCommandBarPresented, navigationSelection: self.$navigationSelection, - searchFocusTrigger: self.searchFocusTrigger, searchViewModel: self.searchViewModel ) } @@ -542,7 +561,7 @@ struct MainWindow: View { if let vm = exploreViewModel { ExploreView(viewModel: vm) } case .search: if let vm = searchViewModel { - SearchView(viewModel: vm, focusTrigger: self.searchFocusTrigger) + SearchView(viewModel: vm, showsSearchBar: false) } case .charts: if let vm = chartsViewModel { ChartsView(viewModel: vm) } @@ -848,7 +867,20 @@ struct MainWindow: View { await self.podcastsViewModel?.refresh() } } +} + +// MARK: - App Delegate + +extension MainWindow { + /// Access to the app delegate for persistent WebView. + private var appDelegate: AppDelegate? { + NSApplication.shared.delegate as? AppDelegate + } +} +// MARK: - Content Refresh + +extension MainWindow { /// Refreshes all content when switching accounts. /// /// This method is called when the user switches between their primary account @@ -877,6 +909,194 @@ struct MainWindow: View { } } +// MARK: - Search Overlay + +extension MainWindow { + /// Floating search overlay: a translucent backdrop (fade in/out) behind a + /// glass search window (scale + blur in/out), pinned near the top. + @ViewBuilder + private var searchOverlayLayer: some View { + if self.isSearchOverlayPresented { + ZStack { + Rectangle() + .fill(.ultraThinMaterial) + .overlay(self.colorScheme == .dark ? Color.black.opacity(0.25) : Color.clear) + .opacity(0.8) + .ignoresSafeArea() + .contentShape(Rectangle()) + .transition(.opacity) + .accessibilityIdentifier(AccessibilityID.SearchOverlay.backdrop) + .onTapGesture { self.dismissSearchOverlay() } + + VStack(spacing: 0) { + SearchOverlayView( + query: self.$searchOverlayDraftQuery, + hint: self.searchOverlayHint, + placeholder: String(localized: "Search"), + isSearching: self.isSearchOverlaySearching, + history: self.activeSearchHistory.items, + onSubmit: self.runSearchOverlayQuery, + onSelectHistory: self.selectSearchOverlayHistory, + onRemoveHistory: self.removeSearchOverlayHistory, + dismiss: self.dismissSearchOverlay + ) + .transition(.searchOverlayWindow) + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, Self.Layout.commandBarTopPadding) + } + } + } + + private var activeSearchHistory: SearchHistoryStore { + self.settings.appSource == .video ? self.youtubeSearchHistory : self.musicSearchHistory + } + + private var searchOverlayHint: String { + self.settings.appSource == .video + ? String(localized: "Search videos, channels, and playlists") + : String(localized: "Search by track title, album, or artist") + } + + private func presentSearchOverlayForUITestIfRequested() { + guard UITestConfig.shouldOpenSearchOverlay else { return } + self.presentSearchOverlay() + } + + /// Music completion lives on the always-present window (not inside the + /// conditional overlay subtree) so a `.loaded`/`.error` transition can never + /// be missed while the overlay is being inserted or removed. + private func handleMusicSearchLoadingStateChange(_ state: LoadingState?) { + guard self.isSearchOverlaySearching, + self.settings.appSource == .music, + self.activeSearchOverlayRunID != nil, + let state + else { return } + switch state { + case .loaded, .error: + self.finishSearchOverlay() + case .idle, .loading, .loadingMore: + break + } + } + + private func presentSearchOverlay() { + self.isSearchOverlaySearching = false + self.activeSearchOverlayRunID = nil + // Keep the draft only when the user is already looking at this source's + // search results; otherwise open with an empty field. + switch self.settings.appSource { + case .music: + self.searchOverlayDraftQuery = self.navigationSelection == .search + ? (self.searchViewModel?.query ?? "") + : "" + case .video: + self.searchOverlayDraftQuery = self.youtubeNavigationSelection == .search + ? self.youtubeStore.search.query + : "" + } + if UITestConfig.isUITestMode, + let mockQuery = UITestConfig.environmentValue(for: UITestConfig.mockSearchOverlayQueryKey) + { + self.searchOverlayDraftQuery = mockQuery + } + withAnimation(.easeInOut(duration: 0.22)) { + self.isSearchOverlayPresented = true + } + } + + private func dismissSearchOverlay() { + self.isSearchOverlaySearching = false + self.activeSearchOverlayRunID = nil + withAnimation(.easeInOut(duration: 0.22)) { + self.isSearchOverlayPresented = false + } + } + + /// Runs the current overlay query for the active source. Records history and + /// shows the shimmer until results resolve, then reveals the results page. + private func runSearchOverlayQuery() { + let query = self.searchOverlayDraftQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return } + + let runID = UUID() + self.activeSearchOverlayRunID = runID + self.isSearchOverlaySearching = true + + switch self.settings.appSource { + case .music: + guard let vm = self.searchViewModel else { return } + self.musicSearchHistory.record(query) + vm.searchImmediately(query: query, filter: .all) + // Fallback for a repeat query whose loaded state won't change (so the + // window-level loadingState observer wouldn't fire). + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(60)) + guard self.activeSearchOverlayRunID == runID, self.isSearchOverlaySearching else { return } + switch self.searchViewModel?.loadingState { + case .loaded, .error: + self.finishSearchOverlay() + default: + break + } + } + case .video: + let vm = self.youtubeStore.search + self.youtubeSearchHistory.record(query) + vm.query = query + Task { @MainActor in + await vm.search() + guard self.activeSearchOverlayRunID == runID else { return } + self.finishSearchOverlay() + } + } + } + + private func selectSearchOverlayHistory(_ query: String) { + self.searchOverlayDraftQuery = query + self.runSearchOverlayQuery() + } + + private func removeSearchOverlayHistory(_ query: String) { + switch self.settings.appSource { + case .music: self.musicSearchHistory.remove(query) + case .video: self.youtubeSearchHistory.remove(query) + } + } + + private func finishSearchOverlay() { + guard self.isSearchOverlaySearching else { return } + self.isSearchOverlaySearching = false + self.activeSearchOverlayRunID = nil + switch self.settings.appSource { + case .music: self.navigationSelection = .search + case .video: self.youtubeNavigationSelection = .search + } + withAnimation(.easeInOut(duration: 0.22)) { + self.isSearchOverlayPresented = false + } + } +} + +// MARK: - MusicSearchOverlayCompletionObserver + +/// Observes the Music search view model's loading state on the always-present +/// window so the search overlay's completion is never missed while the overlay +/// subtree is being inserted or removed. Extracted into a modifier to keep +/// `MainWindow.body` within the type-checker's budget. +private struct MusicSearchOverlayCompletionObserver: ViewModifier { + let loadingState: LoadingState? + let onChange: (LoadingState?) -> Void + + func body(content: Content) -> some View { + content.onChange(of: self.loadingState) { _, newValue in + self.onChange(newValue) + } + } +} + // MARK: - NavigationItem enum NavigationItem: String, Hashable, CaseIterable, Identifiable { @@ -965,6 +1185,7 @@ enum NavigationItem: String, Hashable, CaseIterable, Identifiable { navigationSelection: $navSelection, youtubeNavigationSelection: $youtubeNavSelection, didCompleteStartupPlaybackCleanup: .constant(true), + showSearchOverlayRequest: .constant(false), client: ytMusicClient, youtubeClient: YouTubeClient(authService: authService) ) diff --git a/Sources/Kaset/Views/Search/ScrollFadeMask.swift b/Sources/Kaset/Views/Search/ScrollFadeMask.swift new file mode 100644 index 000000000..482930245 --- /dev/null +++ b/Sources/Kaset/Views/Search/ScrollFadeMask.swift @@ -0,0 +1,67 @@ +import SwiftUI + +// MARK: - VerticalScrollFade + +/// Adds top and bottom fade masks to a vertical `ScrollView`, each shown only at +/// the edge that is actually scrollable: the top fade hides when scrolled to the +/// top, the bottom fade hides when scrolled to the bottom, and both show while in +/// the middle. Text under a fade dissolves to zero opacity. +/// +/// Uses the native `onScrollGeometryChange` observer (macOS 15+), no manual +/// offset preferences or overlays. +struct VerticalScrollFade: ViewModifier { + var fadeHeight: CGFloat = 22 + + @State private var showTopFade = false + @State private var showBottomFade = false + + private struct ScrollEdges: Equatable { + let atTop: Bool + let atBottom: Bool + } + + func body(content: Content) -> some View { + content + .onScrollGeometryChange(for: ScrollEdges.self) { geometry in + let topThreshold = geometry.contentInsets.top + 1 + let atTop = geometry.contentOffset.y <= topThreshold + let maxOffset = geometry.contentSize.height + - geometry.containerSize.height + + geometry.contentInsets.bottom + let atBottom = geometry.contentOffset.y >= maxOffset - 1 + return ScrollEdges(atTop: atTop, atBottom: atBottom) + } action: { _, edges in + self.showTopFade = !edges.atTop + self.showBottomFade = !edges.atBottom + } + .mask { + VStack(spacing: 0) { + LinearGradient( + colors: [.clear, .black], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: self.showTopFade ? self.fadeHeight : 0) + + Rectangle().fill(.black) + + LinearGradient( + colors: [.black, .clear], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: self.showBottomFade ? self.fadeHeight : 0) + } + .animation(.easeInOut(duration: 0.18), value: self.showTopFade) + .animation(.easeInOut(duration: 0.18), value: self.showBottomFade) + } + } +} + +extension View { + /// Fades the top/bottom edges of a vertical scroll view only when that edge + /// is scrollable. Apply to the `ScrollView` itself. + func verticalScrollFade(fadeHeight: CGFloat = 22) -> some View { + self.modifier(VerticalScrollFade(fadeHeight: fadeHeight)) + } +} diff --git a/Sources/Kaset/Views/Search/SearchHistoryRow.swift b/Sources/Kaset/Views/Search/SearchHistoryRow.swift new file mode 100644 index 000000000..c8fb7e400 --- /dev/null +++ b/Sources/Kaset/Views/Search/SearchHistoryRow.swift @@ -0,0 +1,89 @@ +import SwiftUI + +// MARK: - SearchHistoryRow + +/// A single "Latest Searches" row: a clock-history icon + the recorded query. +/// On hover the row highlights and reveals a trailing Remove button that deletes +/// just this query from history. The remove button is overlaid so revealing it +/// never shifts the row's layout. +struct SearchHistoryRow: View { + let query: String + let index: Int + let onSelect: () -> Void + let onRemove: () -> Void + + @State private var isHovered = false + + var body: some View { + Button { + HapticService.navigation() + self.onSelect() + } label: { + HStack(spacing: 8) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .frame(width: 19, alignment: .center) + + Text(self.query) + .font(.system(size: 14)) + .foregroundStyle(.primary) + .lineLimit(1) + + Spacer(minLength: 0) + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background( + self.isHovered ? Color.primary.opacity(0.08) : Color.clear, + in: .rect(cornerRadius: 8) + ) + .overlay(alignment: .trailing) { + RemoveHistoryButton(index: self.index, action: self.onRemove) + .opacity(self.isHovered ? 1 : 0) + .blur(radius: self.isHovered ? 0 : 6) + .padding(.trailing, 3) + .allowsHitTesting(self.isHovered) + } + .animation(.easeInOut(duration: 0.18), value: self.isHovered) + .onHover { self.isHovered = $0 } + .accessibilityIdentifier(AccessibilityID.SearchOverlay.historyRow(index: self.index)) + } +} + +// MARK: - RemoveHistoryButton + +/// The 26x26 "Remove" affordance revealed on history-row hover. Deletes just the +/// row's query from recent-search history. +struct RemoveHistoryButton: View { + let index: Int + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button { + HapticService.toggle() + self.action() + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 26, height: 26) + .background( + self.isHovered ? Color.primary.opacity(0.10) : Color.clear, + in: .rect(cornerRadius: 5) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(String(localized: "Remove from Search History")) + .accessibilityLabel(String(localized: "Remove from Search History")) + .accessibilityIdentifier(AccessibilityID.SearchOverlay.removeHistoryButton(index: self.index)) + .animation(.easeInOut(duration: 0.15), value: self.isHovered) + .onHover { self.isHovered = $0 } + } +} diff --git a/Sources/Kaset/Views/Search/SearchOverlayView.swift b/Sources/Kaset/Views/Search/SearchOverlayView.swift new file mode 100644 index 000000000..cffb45dd5 --- /dev/null +++ b/Sources/Kaset/Views/Search/SearchOverlayView.swift @@ -0,0 +1,330 @@ +import SwiftUI + +// MARK: - SearchOverlayWindowTransitionModifier + +private struct SearchOverlayWindowTransitionModifier: ViewModifier { + let blurRadius: CGFloat + let opacity: Double + let scale: CGFloat + + func body(content: Content) -> some View { + content + .opacity(self.opacity) + .scaleEffect(self.scale) + .blur(radius: self.blurRadius) + } +} + +extension AnyTransition { + /// Search overlay window transition: BlurIn/BlurOut plus a tiny scale and fade. + static var searchOverlayWindow: AnyTransition { + .modifier( + active: SearchOverlayWindowTransitionModifier(blurRadius: 8, opacity: 0, scale: 0.96), + identity: SearchOverlayWindowTransitionModifier(blurRadius: 0, opacity: 1, scale: 1) + ) + } +} + +// MARK: - SearchOverlayView + +/// Floating glass search window shown as an overlay (replaces the old search +/// page as the entry point). Source-agnostic: the query binding, history, and +/// actions are injected so both Music and YouTube can reuse it. +/// +/// - Appears/disappears with Scale + Blur (driven by the presenting container). +/// - Shows a Return hint only when text is entered (Blur in/out). +/// - Lists recent searches; up to 5 hug, more than 5 scroll with top/bottom fades. +/// - While `isSearching`, the whole block shimmers and input is blocked. +struct SearchOverlayView: View { + @Binding var query: String + let hint: String + let placeholder: String + let isSearching: Bool + let history: [String] + let onSubmit: () -> Void + let onSelectHistory: (String) -> Void + let onRemoveHistory: (String) -> Void + let dismiss: () -> Void + + @FocusState private var isInputFocused: Bool + @Namespace private var namespace + + private static let windowWidth: CGFloat = 440 + private static let historyMaxHeight: CGFloat = 200 + private static let cornerRadius: CGFloat = 16 + private static let topBlockShape = UnevenRoundedRectangle( + cornerRadii: RectangleCornerRadii( + topLeading: cornerRadius, + bottomLeading: 0, + bottomTrailing: 0, + topTrailing: cornerRadius + ), + style: .continuous + ) + private static let middleBlockShape = UnevenRoundedRectangle( + cornerRadii: RectangleCornerRadii( + topLeading: 0, + bottomLeading: 0, + bottomTrailing: 0, + topTrailing: 0 + ), + style: .continuous + ) + private static let bottomBlockShape = UnevenRoundedRectangle( + cornerRadii: RectangleCornerRadii( + topLeading: 0, + bottomLeading: cornerRadius, + bottomTrailing: cornerRadius, + topTrailing: 0 + ), + style: .continuous + ) + + private var trimmedQuery: String { + self.query.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var hasText: Bool { + !self.trimmedQuery.isEmpty + } + + private var showsHistory: Bool { + !self.filteredHistory.isEmpty + } + + private var filteredHistory: [String] { + let query = self.trimmedQuery + guard !query.isEmpty else { return self.history } + return self.history.filter { item in + item.localizedCaseInsensitiveContains(query) + } + } + + private var historyCompletionSuffix: String? { + let query = self.query + guard !query.isEmpty else { return nil } + guard let match = self.history.first(where: { item in + item.count > query.count && item.range(of: query, options: [.caseInsensitive, .anchored]) != nil + }) else { return nil } + return String(match.dropFirst(query.count)) + } + + private var inputBlockShape: UnevenRoundedRectangle { + self.showsHistory ? Self.middleBlockShape : Self.bottomBlockShape + } + + var body: some View { + CompatGlassContainer(spacing: -1) { + VStack(alignment: .leading, spacing: -1) { + self.headerHint + self.inputRow + if self.showsHistory { + self.historyBlock + } + } + .frame(width: Self.windowWidth) + .overlay { + if self.isSearching { + SearchShimmerOverlay() + .clipShape(.rect(cornerRadius: 16)) + .allowsHitTesting(false) + .transition(.opacity) + } + } + } + .compatGlassTransition(.materialize) + .animation(.easeInOut(duration: 0.2), value: self.isSearching) + .onAppear { self.isInputFocused = true } + .onExitCommand { self.dismiss() } + } + + // MARK: - Header hint + + private var headerHint: some View { + HStack(spacing: 8) { + Image(systemName: "questionmark.circle") + .font(.system(size: 12)) + .foregroundStyle(self.hintColor) + + Text(self.hint) + .font(.system(size: 13)) + .foregroundStyle(self.hintColor) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .compatGlass(interactive: true, in: Self.topBlockShape) + .compatGlassID("searchOverlay.hint", in: self.namespace) + } + + /// Hint icon/text: white at 70% in dark mode, a legible equivalent in light. + private var hintColor: Color { + Color.primary.opacity(0.7) + } + + // MARK: - Input row + + private var inputRow: some View { + HStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .font(.system(size: 16)) + .foregroundStyle(.secondary) + + TextField(self.placeholder, text: self.$query) + .textFieldStyle(.plain) + .font(.system(size: 16)) + .focused(self.$isInputFocused) + .disabled(self.isSearching) + .onSubmit(self.submit) + .onKeyPress(.return, action: self.submitFromKeyPress) + .onKeyPress(.tab, action: self.acceptHistoryCompletion) + .onKeyPress(.rightArrow, action: self.acceptHistoryCompletion) + .overlay(alignment: .leading) { + self.historyCompletionOverlay + } + .accessibilityIdentifier(AccessibilityID.SearchOverlay.input) + + self.returnHint + } + .padding(16) + .compatGlass(interactive: true, in: self.inputBlockShape) + .compatGlassID("searchOverlay.inputBlock", in: self.namespace) + .contentShape(Rectangle()) + .onTapGesture { + // Tapping anywhere in the input block focuses the field. + guard !self.isSearching else { return } + self.isInputFocused = true + } + } + + @ViewBuilder + private var historyCompletionOverlay: some View { + if let completionSuffix = self.historyCompletionSuffix { + HStack(spacing: 0) { + Text(self.query) + .foregroundStyle(.clear) + Text(completionSuffix) + .foregroundStyle(.primary.opacity(0.35)) + Spacer(minLength: 0) + } + .font(.system(size: 16)) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + + private var returnHint: some View { + Button(action: self.submit) { + Image(systemName: "return") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .opacity(self.hasText ? 0.6 : 0) + .blur(radius: self.hasText ? 0 : 6) + .allowsHitTesting(self.hasText && !self.isSearching) + .animation(.easeInOut(duration: 0.2), value: self.hasText) + .accessibilityHidden(!self.hasText) + .accessibilityLabel(String(localized: "Search")) + .accessibilityIdentifier(AccessibilityID.SearchOverlay.returnHint) + } + + // MARK: - History block + + private var historyBlock: some View { + VStack(alignment: .leading, spacing: 8) { + if self.filteredHistory.count > 5 { + ScrollView(.vertical, showsIndicators: false) { + self.historyRows + .padding(.vertical, 1) + } + .frame(maxHeight: Self.historyMaxHeight) + .verticalScrollFade(fadeHeight: 36) + } else { + self.historyRows + } + } + .padding(8) + .compatGlass(interactive: true, in: Self.bottomBlockShape) + .compatGlassID("searchOverlay.historyBlock", in: self.namespace) + .disabled(self.isSearching) + } + + private var historyRows: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(self.filteredHistory.enumerated()), id: \.element) { index, item in + SearchHistoryRow( + query: item, + index: index, + onSelect: { self.onSelectHistory(item) }, + onRemove: { self.onRemoveHistory(item) } + ) + } + } + } + + // MARK: - Actions + + private func submit() { + guard self.hasText, !self.isSearching else { return } + HapticService.success() + self.onSubmit() + } + + private func submitFromKeyPress() -> KeyPress.Result { + guard self.hasText, !self.isSearching else { return .ignored } + self.submit() + return .handled + } + + private func acceptHistoryCompletion() -> KeyPress.Result { + guard let completionSuffix = self.historyCompletionSuffix, !completionSuffix.isEmpty else { + return .ignored + } + self.query += completionSuffix + return .handled + } +} + +// MARK: - SearchShimmerOverlay + +/// A subtle moving highlight swept across the whole window while a search runs. +private struct SearchShimmerOverlay: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.colorScheme) private var colorScheme + + /// Highlight color per theme: a brighter white sweep in dark mode; a darker + /// tinted sweep in light mode (where a white `.plusLighter` sweep is invisible). + private var highlightColor: Color { + self.colorScheme == .dark + ? Color.white.opacity(0.18) + : Color.black.opacity(0.5) + } + + private var blendMode: BlendMode { + self.colorScheme == .dark ? .plusLighter : .multiply + } + + var body: some View { + if self.reduceMotion { + self.highlightColor.opacity(0.5) + } else { + TimelineView(.animation) { context in + let phase = (context.date.timeIntervalSinceReferenceDate * 0.9) + .truncatingRemainder(dividingBy: 1) + GeometryReader { proxy in + let width = proxy.size.width + LinearGradient( + colors: [.clear, self.highlightColor, .clear], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: width * 0.6) + .offset(x: -width * 0.6 + phase * (width * 1.6)) + .blendMode(self.blendMode) + } + } + } + } +} diff --git a/Sources/Kaset/Views/SearchView.swift b/Sources/Kaset/Views/SearchView.swift index 4a7dbacde..8a938420a 100644 --- a/Sources/Kaset/Views/SearchView.swift +++ b/Sources/Kaset/Views/SearchView.swift @@ -16,25 +16,39 @@ struct SearchView: View { /// External trigger for focusing the search field (from keyboard shortcut). @Binding var focusTrigger: Bool + /// Whether the in-page search bar is shown. When false the view is a + /// results-only page (the search overlay owns the input). + let showsSearchBar: Bool + @FocusState private var isSearchFieldFocused: Bool /// Index of currently selected suggestion for keyboard navigation. @State private var selectedSuggestionIndex: Int = -1 /// Initializes SearchView with optional focus trigger binding. - init(viewModel: SearchViewModel, focusTrigger: Binding = .constant(false)) { + init( + viewModel: SearchViewModel, + focusTrigger: Binding = .constant(false), + showsSearchBar: Bool = true + ) { _viewModel = State(initialValue: viewModel) _focusTrigger = focusTrigger + self.showsSearchBar = showsSearchBar } var body: some View { NavigationStack(path: self.$navigationPath) { VStack(spacing: 0) { - // Search bar - self.searchBar - .zIndex(1) + if self.showsSearchBar { + // Search bar + self.searchBar + .zIndex(1) - Divider() + Divider() + } else if self.viewModel.shouldShowFilters { + self.resultsFilterBar + Divider() + } // Content self.contentView @@ -52,11 +66,15 @@ struct SearchView: View { .playerBarMusicNavigation(path: self.$navigationPath) } .onAppear { - self.isSearchFieldFocused = true + if self.showsSearchBar { + self.isSearchFieldFocused = true + } } .onChange(of: self.focusTrigger) { _, newValue in if newValue { - self.isSearchFieldFocused = true + if self.showsSearchBar { + self.isSearchFieldFocused = true + } self.focusTrigger = false } } @@ -232,6 +250,12 @@ struct SearchView: View { .buttonStyle(.chip(isSelected: self.viewModel.selectedFilter == filter)) } + private var resultsFilterBar: some View { + self.filterChips + .padding(.horizontal, 24) + .padding(.vertical, 12) + } + // MARK: - Content @ViewBuilder @@ -247,9 +271,9 @@ struct SearchView: View { switch self.viewModel.loadingState { case .idle: self.emptyStateView - case .loading, .loadingMore: + case .loading: LoadingView(String(localized: "Searching...")) - case .loaded: + case .loaded, .loadingMore: if self.viewModel.filteredItems.isEmpty { self.noResultsView } else { @@ -300,8 +324,8 @@ struct SearchView: View { private var resultsView: some View { ScrollView { LazyVStack(spacing: 0) { - ForEach(self.viewModel.filteredItems) { item in - self.resultRow(item) + ForEach(Array(self.viewModel.filteredItems.enumerated()), id: \.element.id) { index, item in + self.resultRow(item, index: index) Divider() .padding(.leading, 72) } @@ -347,7 +371,7 @@ struct SearchView: View { } } - private func resultRow(_ item: SearchResultItem) -> some View { + private func resultRow(_ item: SearchResultItem, index: Int) -> some View { HoverObservingRow { isHovered in Button { self.handleItemTap(item) @@ -417,6 +441,7 @@ struct SearchView: View { .contentShape(Rectangle()) } .buttonStyle(.interactiveRow(cornerRadius: 6)) + .accessibilityIdentifier(AccessibilityID.Search.resultRow(index: index)) } .contextMenu { self.contextMenuItems(for: item) diff --git a/Sources/Kaset/Views/Sidebar.swift b/Sources/Kaset/Views/Sidebar.swift index cc26ae93e..532077200 100644 --- a/Sources/Kaset/Views/Sidebar.swift +++ b/Sources/Kaset/Views/Sidebar.swift @@ -9,6 +9,7 @@ struct Sidebar: View { @Binding var selection: NavigationItem? @Binding var pinnedSelection: SidebarPinnedItem? + @Environment(\.showSearchOverlay) private var showSearchOverlay @Environment(AuthService.self) private var authService @Environment(SidebarPinnedItemsManager.self) private var sidebarPinnedItemsManager @Environment(PodcastsAvailabilityService.self) private var podcastsAvailability @@ -17,7 +18,7 @@ struct Sidebar: View { List { // Main navigation Section { - self.navigationRow(.search) + self.searchRow .accessibilityIdentifier(AccessibilityID.Sidebar.searchItem) self.navigationRow(.home) @@ -95,6 +96,18 @@ struct Sidebar: View { return nil } + /// The Search row opens the floating search overlay instead of navigating to a page. + private var searchRow: some View { + KasetSidebarRow( + title: NavigationItem.search.displayName, + systemImage: NavigationItem.search.icon, + isSelected: self.currentSidebarSelection == .navigation(.search) + ) { + HapticService.navigation() + self.showSearchOverlay.wrappedValue = true + } + } + private func navigationRow(_ item: NavigationItem) -> some View { KasetSidebarRow( title: item.displayName, diff --git a/Sources/Kaset/Views/YouTube/YouTubeContentView.swift b/Sources/Kaset/Views/YouTube/YouTubeContentView.swift index 31aa2c179..316347a8d 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeContentView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeContentView.swift @@ -138,7 +138,7 @@ struct YouTubeContentView: View { case .home: YouTubeHomeView(viewModel: self.store.home) case .search: - YouTubeSearchView(viewModel: self.store.search) + YouTubeSearchView(viewModel: self.store.search, showsSearchBar: false) case .explore: YouTubeExploreView(viewModel: self.store.explore) case .shorts: diff --git a/Sources/Kaset/Views/YouTube/YouTubeSearchView.swift b/Sources/Kaset/Views/YouTube/YouTubeSearchView.swift index 982f024c0..191ac7644 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeSearchView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeSearchView.swift @@ -5,6 +5,12 @@ import SwiftUI /// YouTube search: query field, result-kind filter, and mixed result list. struct YouTubeSearchView: View { @Bindable var viewModel: YouTubeSearchViewModel + + /// Whether the in-page search field is shown. When false the view is a + /// results-only page (the search overlay owns the input); the filter picker + /// stays visible. + var showsSearchBar: Bool = true + @FocusState private var isSearchFieldFocused: Bool var body: some View { @@ -44,37 +50,39 @@ struct YouTubeSearchView: View { private var searchHeader: some View { VStack(spacing: 10) { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - - TextField( - String(localized: "Search YouTube"), - text: self.$viewModel.query - ) - .textFieldStyle(.plain) - .focused(self.$isSearchFieldFocused) - .onSubmit { - Task { - await self.viewModel.search() + if self.showsSearchBar { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + + TextField( + String(localized: "Search YouTube"), + text: self.$viewModel.query + ) + .textFieldStyle(.plain) + .focused(self.$isSearchFieldFocused) + .onSubmit { + Task { + await self.viewModel.search() + } } - } - .accessibilityIdentifier(AccessibilityID.YouTubeContent.searchField) - - if !self.viewModel.query.isEmpty { - Button { - self.viewModel.query = "" - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) + .accessibilityIdentifier(AccessibilityID.YouTubeContent.searchField) + + if !self.viewModel.query.isEmpty { + Button { + self.viewModel.query = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "Clear search")) } - .buttonStyle(.plain) - .accessibilityLabel(String(localized: "Clear search")) } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.quaternary.opacity(0.5), in: Capsule()) } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(.quaternary.opacity(0.5), in: Capsule()) Picker(String(localized: "Filter"), selection: self.$viewModel.selectedFilter) { ForEach(YouTubeSearchFilter.allCases) { filter in diff --git a/Sources/Kaset/Views/YouTube/YouTubeSidebar.swift b/Sources/Kaset/Views/YouTube/YouTubeSidebar.swift index 62f96c4d8..8971171ee 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeSidebar.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeSidebar.swift @@ -9,13 +9,14 @@ import SwiftUI /// shared footer (source toggle + profile) at the bottom. struct YouTubeSidebar: View { @Binding var selection: YouTubeNavigationItem? + @Environment(\.showSearchOverlay) private var showSearchOverlay @Environment(AuthService.self) private var authService var body: some View { List { // Main navigation Section { - self.row(for: .search) + self.searchRow self.row(for: .home) if self.hasPersonalAccount { self.row(for: .subscriptions) @@ -51,6 +52,19 @@ struct YouTubeSidebar: View { self.authService.hasPersonalAccount } + /// The Search row opens the floating search overlay instead of navigating to a page. + private var searchRow: some View { + KasetSidebarRow( + title: YouTubeNavigationItem.search.displayName, + systemImage: YouTubeNavigationItem.search.icon, + isSelected: self.selection == .search + ) { + HapticService.navigation() + self.showSearchOverlay.wrappedValue = true + } + .accessibilityIdentifier(AccessibilityID.YouTubeSidebar.item(for: .search)) + } + private func row(for item: YouTubeNavigationItem) -> some View { KasetSidebarRow( title: item.displayName, diff --git a/Tests/KasetTests/SearchHistoryStoreTests.swift b/Tests/KasetTests/SearchHistoryStoreTests.swift new file mode 100644 index 000000000..dc87bf7eb --- /dev/null +++ b/Tests/KasetTests/SearchHistoryStoreTests.swift @@ -0,0 +1,123 @@ +import Foundation +import Testing +@testable import Kaset + +/// Tests for SearchHistoryStore (in-memory, persistence skipped). +@Suite(.serialized, .tags(.service), .timeLimit(.minutes(1))) +@MainActor +struct SearchHistoryStoreTests { + private func makeStore() -> SearchHistoryStore { + SearchHistoryStore(source: .music, skipPersistence: true) + } + + @Test("Initial state is empty") + func initialStateEmpty() { + #expect(self.makeStore().items.isEmpty) + } + + @Test("Record inserts most-recent first") + func recordInsertsMostRecentFirst() { + let store = self.makeStore() + store.record("daft punk") + store.record("radiohead") + + #expect(store.items == ["radiohead", "daft punk"]) + } + + @Test("Record trims whitespace and ignores blank queries") + func recordTrimsAndIgnoresBlank() { + let store = self.makeStore() + store.record(" weezer ") + store.record(" ") + store.record("") + + #expect(store.items == ["weezer"]) + } + + @Test("Record de-duplicates case-insensitively and moves match to front") + func recordDeduplicatesCaseInsensitively() { + let store = self.makeStore() + store.record("Radiohead") + store.record("Daft Punk") + store.record("radiohead") + + #expect(store.items == ["radiohead", "Daft Punk"]) + } + + @Test("Record caps the list at maxItems, dropping the oldest") + func recordCapsAtMaxItems() { + let store = self.makeStore() + for index in 0 ..< (SearchHistoryStore.maxItems + 5) { + store.record("query-\(index)") + } + + #expect(store.items.count == SearchHistoryStore.maxItems) + // Newest first; the very first queries fell off the end. + #expect(store.items.first == "query-\(SearchHistoryStore.maxItems + 4)") + #expect(!store.items.contains("query-0")) + } + + @Test("Clear removes all items") + func clearRemovesAll() { + let store = self.makeStore() + store.record("one") + store.record("two") + + store.clear() + + #expect(store.items.isEmpty) + } + + @Test("Remove deletes only the matching item, preserving order of the rest") + func removeDeletesOnlyMatch() { + let store = self.makeStore() + store.record("one") + store.record("two") + store.record("three") + + store.remove("two") + + // Newest-first order preserved for the survivors. + #expect(store.items == ["three", "one"]) + } + + @Test("Remove matches case-insensitively") + func removeIsCaseInsensitive() { + let store = self.makeStore() + store.record("Radiohead") + store.record("Daft Punk") + + store.remove("radiohead") + + #expect(store.items == ["Daft Punk"]) + } + + @Test("Remove trims input before matching") + func removeTrimsInput() { + let store = self.makeStore() + store.record("weezer") + + store.remove(" weezer ") + + #expect(store.items.isEmpty) + } + + @Test("Remove is a no-op for blank or absent queries") + func removeNoOpForBlankOrAbsent() { + let store = self.makeStore() + store.record("one") + store.record("two") + + store.remove("") + store.remove(" ") + store.remove("does-not-exist") + + #expect(store.items == ["two", "one"]) + } + + @Test("Music and YouTube stores use distinct source files") + func distinctSourceFiles() { + #expect(SearchHistoryStore.Source.music.fileName == "search-history-music.json") + #expect(SearchHistoryStore.Source.youtube.fileName == "search-history-youtube.json") + } +} diff --git a/Tests/KasetTests/SearchViewModelTests.swift b/Tests/KasetTests/SearchViewModelTests.swift index 7e5784e83..5eabf429f 100644 --- a/Tests/KasetTests/SearchViewModelTests.swift +++ b/Tests/KasetTests/SearchViewModelTests.swift @@ -256,8 +256,8 @@ struct SearchViewModelTests { #expect(self.mockClient.searchQueries.count == 7) } - @Test("All filter publishes mixed results before category searches complete") - func allFilterPublishesMixedResultsBeforeCategorySearchesComplete() async { + @Test("All filter waits for final merged results before publishing") + func allFilterWaitsForFinalMergedResultsBeforePublishing() async { let categoryGate = AsyncGate() let mixedSong = TestFixtures.makeSong(id: "mixed-song", title: "Mixed Song") let categorySong = TestFixtures.makeSong(id: "category-song", title: "Category Song") @@ -290,17 +290,17 @@ struct SearchViewModelTests { self.viewModel.searchImmediately() await self.waitUntil( - self.viewModel.results.songs.map(\.id) == ["mixed-song"] && self.viewModel.loadingState == .loaded, - description: "mixed results first paint" + self.mockClient.completedSearchEndpoints == [.mixed], + description: "mixed search to complete while categories are gated" ) - #expect(self.viewModel.results.albums.isEmpty) + #expect(self.viewModel.results.isEmpty) + #expect(self.viewModel.loadingState == .loading) #expect(self.viewModel.shouldShowFilters == false) - #expect(self.mockClient.completedSearchEndpoints == [.mixed]) await categoryGate.open() await self.waitUntil( self.viewModel.results.songs.map(\.id).contains("category-song") && self.viewModel.results.albums.count == 1, - description: "category-enriched all-filter results" + description: "final merged all-filter results" ) #expect(self.viewModel.results.songs.map(\.id) == ["mixed-song", "category-song"]) @@ -334,9 +334,10 @@ struct SearchViewModelTests { self.viewModel.selectedFilter = .all self.viewModel.searchImmediately() await self.waitUntil( - self.viewModel.results.songs.map(\.id) == ["old-mixed"], - description: "old mixed first paint" + self.mockClient.completedSearchEndpoints == [.mixed], + description: "old mixed request to complete while old categories are gated" ) + #expect(self.viewModel.results.isEmpty) self.mockClient.mixedSearchResponse = SearchResponse( songs: [TestFixtures.makeSong(id: "new-mixed", title: "New Mixed")], diff --git a/Tests/KasetUITests/AccountSwitcherUITests.swift b/Tests/KasetUITests/AccountSwitcherUITests.swift index 6fc0a3db6..89645c765 100644 --- a/Tests/KasetUITests/AccountSwitcherUITests.swift +++ b/Tests/KasetUITests/AccountSwitcherUITests.swift @@ -209,9 +209,6 @@ final class AccountSwitcherUITests: KasetUITestCase { return } - // Get initial profile state by checking accessibility label - let initialLabel = profileButton.label - profileButton.click() let popover = app.popovers.firstMatch diff --git a/Tests/KasetUITests/HomeViewUITests.swift b/Tests/KasetUITests/HomeViewUITests.swift index cffaaf5ef..c8f77570c 100644 --- a/Tests/KasetUITests/HomeViewUITests.swift +++ b/Tests/KasetUITests/HomeViewUITests.swift @@ -75,10 +75,12 @@ final class HomeViewUITests: KasetUITestCase { navigateToHome() - // Navigate to Search + // Open Search overlay navigateToSearch() - let searchTitle = app.staticTexts["Search"] - XCTAssertTrue(waitForElement(searchTitle)) + let searchField = app.textFields[TestAccessibilityID.SearchOverlay.input] + XCTAssertTrue(waitForElement(searchField)) + app.typeKey(.escape, modifierFlags: []) + XCTAssertTrue(waitForElementToDisappear(searchField)) // Navigate back to Home navigateToHome() diff --git a/Tests/KasetUITests/KasetUITestCase.swift b/Tests/KasetUITests/KasetUITestCase.swift index 43cb85783..056d43e36 100644 --- a/Tests/KasetUITests/KasetUITestCase.swift +++ b/Tests/KasetUITests/KasetUITestCase.swift @@ -1,3 +1,4 @@ +import AppKit @preconcurrency import XCTest // MARK: - TestAccessibilityID @@ -27,6 +28,26 @@ enum TestAccessibilityID { static func suggestion(index: Int) -> String { "searchView.suggestion.\(index)" } + + static func resultRow(index: Int) -> String { + "searchView.result.\(index)" + } + } + + enum SearchOverlay { + static let backdrop = "searchOverlay.backdrop" + static let window = "searchOverlay.window" + static let input = "searchOverlay.input" + static let returnHint = "searchOverlay.returnHint" + static let historyList = "searchOverlay.historyList" + + static func historyRow(index: Int) -> String { + "searchOverlay.history.\(index)" + } + + static func removeHistoryButton(index: Int) -> String { + "searchOverlay.removeHistoryButton.\(index)" + } } enum MainWindow { @@ -118,8 +139,9 @@ struct MockFavoriteItem { /// Base class for Kaset UI tests. /// Provides common setup, launch configuration, and helper methods. -@MainActor class KasetUITestCase: XCTestCase { + private static let appBundleIdentifier = "com.sertacozercan.Kaset" + /// The application under test. var app: XCUIApplication! @@ -131,34 +153,72 @@ class KasetUITestCase: XCTestCase { // Stop immediately when a failure occurs continueAfterFailure = false - // Create new app instance pointing to installed Kaset.app - let appURL = URL(fileURLWithPath: "/Applications/Kaset.app") - if FileManager.default.fileExists(atPath: appURL.path) { - self.app = XCUIApplication(url: appURL) - } else { - self.app = XCUIApplication(bundleIdentifier: "com.sertacozercan.Kaset") - } + // Create new app instance. Prefer the freshly-built local app bundle; + // fall back to /Applications only when the local bundle is unavailable. + let explicitAppPath = ProcessInfo.processInfo.environment["KASET_UI_TEST_APP_PATH"] + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let localBuildPath = repositoryRoot + .appendingPathComponent(".build/app/Kaset.app") + .path + let preferredAppPath = explicitAppPath ?? localBuildPath + let appURL = FileManager.default.fileExists(atPath: preferredAppPath) + ? URL(fileURLWithPath: preferredAppPath) + : URL(fileURLWithPath: "/Applications/Kaset.app") + Self.terminateRunningKaset() + + let bundleIdentifier = Self.appBundleIdentifier + let configuredApp = MainActor.assumeIsolated { + let application = if FileManager.default.fileExists(atPath: appURL.path) { + XCUIApplication(url: appURL) + } else { + XCUIApplication(bundleIdentifier: bundleIdentifier) + } + + // Add UI test mode arguments + application.launchArguments.append("-UITestMode") + application.launchArguments.append("-SkipAuth") - // Add UI test mode arguments - self.app.launchArguments.append("-UITestMode") - self.app.launchArguments.append("-SkipAuth") + // Also set via environment (more reliable with XCUIApplication(url:)) + application.launchEnvironment["UI_TEST_MODE"] = "1" + application.launchEnvironment["SKIP_AUTH"] = "1" - // Also set via environment (more reliable with XCUIApplication(url:)) - self.app.launchEnvironment["UI_TEST_MODE"] = "1" - self.app.launchEnvironment["SKIP_AUTH"] = "1" + // Disable animations for faster, more reliable tests + application.launchArguments.append("-UIAnimationsDisabled") - // Disable animations for faster, more reliable tests - self.app.launchArguments.append("-UIAnimationsDisabled") + return application + } + self.app = configuredApp } override func tearDownWithError() throws { + let application = self.app self.app = nil + MainActor.assumeIsolated { + application?.terminate() + } try super.tearDownWithError() } + private static func terminateRunningKaset() { + for runningApp in NSRunningApplication.runningApplications(withBundleIdentifier: self.appBundleIdentifier) { + runningApp.terminate() + let deadline = Date().addingTimeInterval(3) + while !runningApp.isTerminated, Date() < deadline { + RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05)) + } + if !runningApp.isTerminated { + runningApp.forceTerminate() + } + } + } + // MARK: - Launch Helpers /// Launches the app with mock home sections. + @MainActor func launchWithMockHome(sectionCount: Int = 3, itemsPerSection: Int = 5) { let sections = (0 ..< sectionCount).map { sectionIndex in [ @@ -186,6 +246,7 @@ class KasetUITestCase: XCTestCase { } /// Launches the app with mock search results. + @MainActor func launchWithMockSearch(songCount: Int = 5) { let songs = (0 ..< songCount).map { index in [ @@ -206,6 +267,7 @@ class KasetUITestCase: XCTestCase { } /// Launches the app with mock library playlists. + @MainActor func launchWithMockLibrary(playlistCount: Int = 3) { let playlists = (0 ..< playlistCount).map { index in [ @@ -225,6 +287,7 @@ class KasetUITestCase: XCTestCase { } /// Launches the app with a mock current track (player has something playing). + @MainActor func launchWithMockPlayer(isPlaying: Bool = true, hasVideo: Bool = false) { let track: [String: Any] = [ "id": "current-track", @@ -247,12 +310,14 @@ class KasetUITestCase: XCTestCase { } /// Launches the app with a mock current track that has video available. + @MainActor func launchWithMockPlayerWithVideo(isPlaying: Bool = true) { self.launchWithMockPlayer(isPlaying: isPlaying, hasVideo: true) } /// Launches the app with mock favorites. /// - Parameter items: Array of favorite item configurations. + @MainActor func launchWithMockFavorites(_ items: [MockFavoriteItem]) { let favorites = items.map { item -> [String: Any] in var dict: [String: Any] = [ @@ -317,6 +382,7 @@ class KasetUITestCase: XCTestCase { } /// Launches the app with mock player state and mock favorites. + @MainActor func launchWithMockPlayerAndFavorites( isPlaying: Bool = true, hasVideo: Bool = false, @@ -402,6 +468,7 @@ class KasetUITestCase: XCTestCase { } /// Launches the app with default configuration (logged in, no specific mock data). + @MainActor func launchDefault() { self.app.launch() } @@ -410,10 +477,11 @@ class KasetUITestCase: XCTestCase { /// Waits for an element to exist with a timeout. @discardableResult + @MainActor func waitForElement( _ element: XCUIElement, timeout: TimeInterval = 5, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) -> Bool { let predicate = NSPredicate(format: "exists == true") @@ -429,10 +497,11 @@ class KasetUITestCase: XCTestCase { /// Waits for an element to be hittable (visible and interactable). @discardableResult + @MainActor func waitForHittable( _ element: XCUIElement, timeout: TimeInterval = 5, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) -> Bool { let predicate = NSPredicate(format: "isHittable == true") @@ -448,11 +517,12 @@ class KasetUITestCase: XCTestCase { /// Waits for element count to match expected value. @discardableResult + @MainActor func waitForElementCount( _ query: XCUIElementQuery, count: Int, timeout: TimeInterval = 5, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) -> Bool { let predicate = NSPredicate(format: "count == \(count)") @@ -472,10 +542,11 @@ class KasetUITestCase: XCTestCase { /// Waits for an element to disappear with a timeout. @discardableResult + @MainActor func waitForElementToDisappear( _ element: XCUIElement, timeout: TimeInterval = 5, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) -> Bool { let predicate = NSPredicate(format: "exists == false") @@ -492,6 +563,7 @@ class KasetUITestCase: XCTestCase { // MARK: - Navigation Helpers /// Navigates to a sidebar item by accessibility identifier. + @MainActor func navigateToSidebarItem(_ accessibilityID: String) { // Find by accessibility identifier first, fall back to label var sidebarItem = self.app.buttons[accessibilityID].firstMatch @@ -517,6 +589,7 @@ class KasetUITestCase: XCTestCase { } /// Navigates to a sidebar item by label text. + @MainActor func navigateToSidebarItemByLabel(_ label: String) { // Wait for sidebar to be ready with extended timeout for UI test startup let sidebarItem = self.app.staticTexts[label].firstMatch @@ -538,26 +611,31 @@ class KasetUITestCase: XCTestCase { } /// Navigates to Home via sidebar. + @MainActor func navigateToHome() { self.navigateToSidebarItem(TestAccessibilityID.Sidebar.homeItem) } /// Navigates to Search via sidebar. + @MainActor func navigateToSearch() { self.navigateToSidebarItem(TestAccessibilityID.Sidebar.searchItem) } /// Navigates to Explore via sidebar. + @MainActor func navigateToExplore() { self.navigateToSidebarItem(TestAccessibilityID.Sidebar.exploreItem) } /// Navigates to Library via sidebar. + @MainActor func navigateToLibrary() { self.navigateToSidebarItem(TestAccessibilityID.Sidebar.libraryItem) } /// Navigates to Liked Music via sidebar. + @MainActor func navigateToLikedMusic() { self.navigateToSidebarItem(TestAccessibilityID.Sidebar.likedMusicItem) } diff --git a/Tests/KasetUITests/SearchViewUITests.swift b/Tests/KasetUITests/SearchViewUITests.swift index 5c994e821..7a8eb773a 100644 --- a/Tests/KasetUITests/SearchViewUITests.swift +++ b/Tests/KasetUITests/SearchViewUITests.swift @@ -1,131 +1,104 @@ import XCTest -/// UI tests for the SearchView. +/// UI tests for the search overlay and results page flow. @MainActor final class SearchViewUITests: KasetUITestCase { - // MARK: - Search Field - - func testSearchFieldExists() { - launchDefault() - - navigateToSearch() - - // Search field should be present - let searchField = app.textFields[TestAccessibilityID.Search.searchField] - XCTAssertTrue(waitForElement(searchField), "Search field should exist") + private func launchWithSearchOverlay(songCount: Int? = nil, history: [String] = [], query: String? = nil) { + self.app.launchArguments.append("-OpenSearchOverlay") + self.app.launchEnvironment["OPEN_SEARCH_OVERLAY"] = "1" + + if let query { + self.app.launchEnvironment["MOCK_SEARCH_OVERLAY_QUERY"] = query + } + + if !history.isEmpty, + let data = try? JSONSerialization.data(withJSONObject: history), + let json = String(data: data, encoding: .utf8) + { + self.app.launchEnvironment["MOCK_SEARCH_HISTORY"] = json + } + + if let songCount { + let songs = (0 ..< songCount).map { index in + [ + "id": "search-song-\(index)", + "title": "Search Result \(index)", + "artist": "Search Artist \(index)", + "videoId": "search-video-\(index)", + ] + } + + if let data = try? JSONSerialization.data(withJSONObject: ["songs": songs]), + let json = String(data: data, encoding: .utf8) + { + self.app.launchEnvironment["MOCK_SEARCH_RESULTS"] = json + } + } + + self.app.launch() } - func testSearchFieldAcceptsInput() { - launchDefault() - - navigateToSearch() - - let searchField = app.textFields[TestAccessibilityID.Search.searchField] - XCTAssertTrue(waitForHittable(searchField)) - - // Type in the search field - searchField.click() - searchField.typeText("test query") - - // Verify text was entered - XCTAssertEqual(searchField.value as? String, "test query") - } - - func testClearButtonAppearsWithText() { - launchDefault() - - navigateToSearch() - - let searchField = app.textFields[TestAccessibilityID.Search.searchField] - XCTAssertTrue(waitForHittable(searchField)) + // MARK: - Overlay Presentation - // Initially no clear button - searchField.click() - searchField.typeText("test") + func testSearchOverlayOpensFromLaunchArgument() { + self.launchWithSearchOverlay() - // Clear button should appear (X icon) - let clearButton = app.buttons[TestAccessibilityID.Search.clearButton] - XCTAssertTrue(clearButton.waitForExistence(timeout: 3), "Clear button should appear") + let searchField = self.app.textFields[TestAccessibilityID.SearchOverlay.input] + XCTAssertTrue(self.waitForElement(searchField), "Search overlay input should exist") } - // MARK: - Empty State + func testSearchOverlayShowsSubmitAffordanceForPrefilledQuery() { + self.launchWithSearchOverlay(query: "test") - func testEmptyStateShownInitially() { - launchDefault() + let searchField = self.app.textFields[TestAccessibilityID.SearchOverlay.input] + XCTAssertTrue(self.waitForElement(searchField)) - navigateToSearch() - - // Empty state message should be visible - let emptyStateText = app.staticTexts["Search for your favorite music"] - XCTAssertTrue(waitForElement(emptyStateText, timeout: 5), "Empty state text should be visible") + let submitButton = self.app.buttons[TestAccessibilityID.SearchOverlay.returnHint] + XCTAssertTrue(self.waitForElement(submitButton), "Search overlay submit button should appear for a non-empty query") } // MARK: - Search Execution - func testSearchSubmitTriggersSearch() { - launchWithMockSearch(songCount: 5) - - navigateToSearch() - - let searchField = app.textFields[TestAccessibilityID.Search.searchField] - XCTAssertTrue(waitForHittable(searchField)) - - searchField.click() - searchField.typeText("test\n") // Type and press Enter - - // Wait for results or loading state - // The search should be triggered - Thread.sleep(forTimeInterval: 1) // Brief wait for state change - } + func testSearchOverlaySubmitShowsResultsPage() { + self.launchWithSearchOverlay(songCount: 5, query: "test") - // MARK: - Filter Chips + let searchField = self.app.textFields[TestAccessibilityID.SearchOverlay.input] + XCTAssertTrue(self.waitForElement(searchField)) - func testFilterChipsExistAfterSearch() { - launchWithMockSearch(songCount: 5) + let submitButton = self.app.buttons[TestAccessibilityID.SearchOverlay.returnHint] + XCTAssertTrue(self.waitForElement(submitButton), "Search overlay submit button should appear") + submitButton.click() - navigateToSearch() + XCTAssertTrue(self.waitForElementToDisappear(searchField, timeout: 10), "Search overlay input should close after search") - let searchField = app.textFields[TestAccessibilityID.Search.searchField] - XCTAssertTrue(waitForHittable(searchField)) + let title = self.app.staticTexts["Search"] + XCTAssertTrue(self.waitForElement(title), "Search title should be visible") - searchField.click() - searchField.typeText("test\n") + let firstResult = self.app.buttons[TestAccessibilityID.Search.resultRow(index: 0)] + XCTAssertTrue(self.waitForElement(firstResult, timeout: 10), "Search results should be visible") - // Wait for filter chips (they appear after search results) - // Filter chips are buttons with category names - Thread.sleep(forTimeInterval: 2) + XCTAssertTrue(self.app.buttons["All"].exists, "Results page should keep the search category tabs") + XCTAssertTrue(self.app.buttons["Songs"].exists, "Results page should show the Songs category tab") + XCTAssertTrue(self.app.buttons["Albums"].exists, "Results page should show the Albums category tab") + XCTAssertTrue(self.app.buttons["Artists"].exists, "Results page should show the Artists category tab") - // Look for any filter-like buttons - let allFilterButton = app.buttons["All"] - // If results exist, filters should appear + let inPageField = self.app.textFields[TestAccessibilityID.Search.searchField] + XCTAssertFalse(inPageField.exists, "Results page should not show the old in-page search field") } - // MARK: - Keyboard Navigation + // MARK: - Search History - func testSearchFieldIsFocusedOnAppear() { - launchDefault() - - navigateToSearch() - - // The search field should be ready for input - let searchField = app.textFields[TestAccessibilityID.Search.searchField] - XCTAssertTrue(waitForElement(searchField)) - - // Type directly - if focused, it should work - app.typeText("quick search") - - // Verify text was entered - XCTAssertEqual(searchField.value as? String, "quick search") - } + func testHistoryRowSelectionSubmitsSearch() { + self.launchWithSearchOverlay(songCount: 1, history: ["success"], query: "suc") - // MARK: - Navigation Integration + let searchField = self.app.textFields[TestAccessibilityID.SearchOverlay.input] + XCTAssertTrue(self.waitForElement(searchField)) - func testSearchNavigationTitle() { - launchDefault() + let firstHistoryRow = self.app.buttons[TestAccessibilityID.SearchOverlay.historyRow(index: 0)] + XCTAssertTrue(self.waitForElement(firstHistoryRow), "Matching history row should appear while typing") - navigateToSearch() + firstHistoryRow.click() - let title = app.staticTexts["Search"] - XCTAssertTrue(waitForElement(title), "Search title should be visible") + XCTAssertTrue(self.waitForElementToDisappear(searchField, timeout: 10), "Search overlay input should close after selecting history") } } diff --git a/Tests/KasetUITests/SidebarUITests.swift b/Tests/KasetUITests/SidebarUITests.swift index edde890dc..8924db0d5 100644 --- a/Tests/KasetUITests/SidebarUITests.swift +++ b/Tests/KasetUITests/SidebarUITests.swift @@ -39,13 +39,9 @@ final class SidebarUITests: KasetUITestCase { navigateToSearch() - // Verify Search view is displayed - let navigationTitle = app.staticTexts["Search"] - XCTAssertTrue(waitForElement(navigationTitle), "Search navigation title should be visible") - - // Search field should be present - let searchField = app.textFields.firstMatch - XCTAssertTrue(searchField.exists, "Search field should exist") + // Verify Search overlay is displayed + let searchField = app.textFields[TestAccessibilityID.SearchOverlay.input] + XCTAssertTrue(waitForElement(searchField), "Search overlay input should be visible") } func testNavigateToExplore() { @@ -83,10 +79,12 @@ final class SidebarUITests: KasetUITestCase { func testNavigationPersistsAfterSwitching() { launchDefault() - // Navigate to Search + // Open Search overlay navigateToSearch() - let searchTitle = app.staticTexts["Search"] - XCTAssertTrue(waitForElement(searchTitle)) + let searchField = app.textFields[TestAccessibilityID.SearchOverlay.input] + XCTAssertTrue(waitForElement(searchField)) + app.typeKey(.escape, modifierFlags: []) + XCTAssertTrue(waitForElementToDisappear(searchField)) // Navigate to Explore navigateToExplore()