diff --git a/Microfiche/ContentView.swift b/Microfiche/ContentView.swift index de8fdb4..242a366 100644 --- a/Microfiche/ContentView.swift +++ b/Microfiche/ContentView.swift @@ -69,9 +69,11 @@ struct ContentView: View { static let minimumWidth: CGFloat = 240 static let idealWidth: CGFloat = 280 static let maximumWidth: CGFloat = 360 - static let autoCollapseWidth: CGFloat = 220 } + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.toggleSidebar) private var toggleSidebar + @State private var selection: Selection? @State private var imageFiles: [ImageFile] = [] @State private var libraryLoadGeneration = UUID() @@ -88,13 +90,17 @@ struct ContentView: View { @State private var scrollToID: UUID? @State private var gridColumnCount: Int = 1 @State private var detailViewFile: ImageFile? - @State private var isMetadataInspectorPresented = true + @AppStorage("isMetadataInspectorPresented") private var isMetadataInspectorPresented = true @State private var splitViewVisibility: NavigationSplitViewVisibility = .all @State private var externalDriveNotice: String? + @State private var searchText = "" + @State private var selectedFileType = "" + @State private var selectedTag = "" @AppStorage("lastSelectedLibraryFolderID") private var lastSelectedLibraryFolderID = "" @StateObject private var libraryStorage = LibraryStorage.shared @StateObject private var contactSheetStorage = ContactSheetStorage.shared @StateObject private var userPreferences = UserPreferences.shared + @StateObject private var metadataStore = ImageMetadataStore.shared let supportedExtensions = ["jpg", "jpeg", "png", "pdf", "svg", "gif", "tiff"] @@ -110,7 +116,6 @@ struct ContentView: View { externalVolumes: libraryStorage.rememberedExternalVolumes, contactSheets: contactSheetStorage.contactSheets, selection: selection, - onWidthChange: handleSidebarWidthChange, onLinkFolder: linkFolder, onSelect: { newSelection in selection = newSelection @@ -140,10 +145,14 @@ struct ContentView: View { max: SidebarLayout.maximumWidth ) } detail: { - ZStack { + NavigationStack { MainContentView( - imageFiles: imageFiles, + imageFiles: displayedImageFiles, unavailableLocation: unavailableSelectedFolder, + isFiltering: hasActiveFilter, + onRetryUnavailableLocation: { + libraryStorage.refreshLocations(saveAfterRefresh: true) + }, showsToolbar: detailViewFile == nil, viewMode: $viewMode, gridThumbnailSize: displayedGridThumbnailSize, @@ -157,20 +166,14 @@ struct ContentView: View { contactSheets: contactSheetStorage.contactSheets, onAddToContactSheet: handleAddToContactSheet ) - .opacity(detailViewFile == nil ? 1 : 0) - .allowsHitTesting(detailViewFile == nil) - .accessibilityHidden(detailViewFile != nil) - - if let detailFile = detailViewFile { + .navigationDestination(item: $detailViewFile) { file in ImageDetailView( - file: detailFile, + file: file, isInspectorPresented: $isMetadataInspectorPresented, onBack: closeImageDetail ) - .transition(.opacity) } } - .animation(MicroficheMotion.transition, value: detailViewFile?.id) .inspector(isPresented: $isMetadataInspectorPresented) { Group { if let focusedImageFile { @@ -185,13 +188,22 @@ struct ContentView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .microficheSidebarChrome() .inspectorColumnWidth(min: 280, ideal: 320, max: 420) } } .navigationTitle("") .toolbar { if detailViewFile == nil { + ToolbarItem { + Button(action: toggleSidebar) { + Image(systemName: "sidebar.left") + } + .help("Toggle Sidebar") + .accessibilityLabel("Toggle sidebar") + .accessibilityIdentifier("sidebar.toggle") + } + .hideSharedBackgroundIfAvailable() + ToolbarItem(placement: .principal) { if viewMode == .grid { HStack(spacing: 6) { @@ -229,10 +241,18 @@ struct ContentView: View { Image(systemName: "sidebar.right") } .help(isMetadataInspectorPresented ? "Hide Info" : "Show Info") + .accessibilityLabel(isMetadataInspectorPresented ? "Hide inspector" : "Show inspector") + .accessibilityIdentifier("inspector.toggle") + } + .hideSharedBackgroundIfAvailable() + + ToolbarItem { + filterMenu } .hideSharedBackgroundIfAvailable() } } + .searchable(text: $searchText, placement: .toolbar, prompt: "Search library") .onChange(of: selection) { _, newValue in switch newValue { case .all: @@ -350,14 +370,79 @@ struct ContentView: View { .animation(MicroficheMotion.snap, value: isQuickPreviewPresented) .animation(MicroficheMotion.transition, value: userPreferences.isPresentingOnboarding) .task { - restoreLibrarySelection() + if !ProcessInfo.processInfo.arguments.contains("--ui-testing") { + restoreLibrarySelection() + } userPreferences.evaluateLaunchPresentation() } } private var focusedImageFile: ImageFile? { guard let focusedImageFileID else { return nil } - return imageFiles.first { $0.id == focusedImageFileID } + return displayedImageFiles.first { $0.id == focusedImageFileID } + ?? imageFiles.first { $0.id == focusedImageFileID } + } + + private var hasActiveFilter: Bool { + !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !selectedFileType.isEmpty + || !selectedTag.isEmpty + } + + private var displayedImageFiles: [ImageFile] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + return imageFiles.filter { file in + let metadata = metadataStore.metadata(for: file.url) + return LibraryFiltering.matches( + file: file, + metadata: metadata, + query: query, + fileType: selectedFileType, + tag: selectedTag + ) + } + } + + private var availableFileTypes: [String] { + Set(imageFiles.map { $0.url.pathExtension.lowercased() }) + .filter { !$0.isEmpty } + .sorted() + } + + private var availableTags: [String] { + metadataStore.allTags(for: imageFiles.map(\.url)) + } + + private var filterMenu: some View { + Menu { + Picker("File Type", selection: $selectedFileType) { + Text("All File Types").tag("") + ForEach(availableFileTypes, id: \.self) { fileType in + Text(fileType.uppercased()).tag(fileType) + } + } + + Picker("Tag", selection: $selectedTag) { + Text("All Tags").tag("") + ForEach(availableTags, id: \.self) { tag in + Text(tag).tag(tag) + } + } + + Divider() + Button("Clear Filters") { + selectedFileType = "" + selectedTag = "" + searchText = "" + } + .disabled(!hasActiveFilter) + } label: { + Image(systemName: hasActiveFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") + } + .help("Filter Library") + .accessibilityLabel("Filter library") + .accessibilityValue(hasActiveFilter ? "Filters active" : "No filters") + .accessibilityIdentifier("library.filter") } private var unavailableSelectedFolder: LinkedLibraryFolder? { @@ -455,16 +540,6 @@ struct ContentView: View { } } - private func handleSidebarWidthChange(_ width: CGFloat) { - guard splitViewVisibility != .detailOnly else { return } - - if width < SidebarLayout.autoCollapseWidth { - withAnimation(MicroficheMotion.transition) { - splitViewVisibility = .detailOnly - } - } - } - // MARK: - Contact Sheets private func handleDropToContactSheet(sheetID: UUID, urls: [URL]) { @@ -526,14 +601,14 @@ struct ContentView: View { if NSApp.currentEvent?.modifierFlags.contains(.shift) == true, let lastID = focusedImageFileID, - let lastIndex = imageFiles.firstIndex(where: { $0.id == lastID }), - let currentIndex = imageFiles.firstIndex(where: { $0.id == fileID }) { + let lastIndex = displayedImageFiles.firstIndex(where: { $0.id == lastID }), + let currentIndex = displayedImageFiles.firstIndex(where: { $0.id == fileID }) { let range = min(lastIndex, currentIndex)...max(lastIndex, currentIndex) - selectedImageFileIDs = Set(imageFiles[range].map { $0.id }) + selectedImageFileIDs = Set(displayedImageFiles[range].map { $0.id }) } else if NSApp.currentEvent?.modifierFlags.contains(.command) == true { if selectedImageFileIDs.contains(fileID) { selectedImageFileIDs.remove(fileID) - nextFocusedID = imageFiles.first { + nextFocusedID = displayedImageFiles.first { selectedImageFileIDs.contains($0.id) }?.id } else { @@ -544,26 +619,22 @@ struct ContentView: View { } focusedImageFileID = nextFocusedID - if let file = imageFiles.first(where: { $0.id == fileID }) { + if let file = displayedImageFiles.first(where: { $0.id == fileID }) { PreviewImageCache.shared.preloadImage(for: file.url) } } private func handleDoubleClickImage(for fileID: UUID) { - if let file = imageFiles.first(where: { $0.id == fileID }) { + if let file = displayedImageFiles.first(where: { $0.id == fileID }) { isQuickPreviewPresented = false selectedImageFileIDs = [fileID] focusedImageFileID = fileID - withAnimation(MicroficheMotion.transition) { - detailViewFile = file - // Keep the metadata inspector available for editing Finder labels/tags. - isMetadataInspectorPresented = true - } + detailViewFile = file } } private func closeImageDetail() { - withAnimation(MicroficheMotion.transition) { + withAnimation(MicroficheMotion.transition(reducedMotion: reduceMotion)) { detailViewFile = nil } requestScrollToFocusedImage() @@ -669,11 +740,12 @@ struct ContentView: View { // MARK: - Navigation private func handleArrowKey(_ direction: ArrowDirection) { - guard !imageFiles.isEmpty else { return } + let navigableFiles = displayedImageFiles + guard !navigableFiles.isEmpty else { return } guard let currentFocusedID = focusedImageFileID, - let currentIndex = imageFiles.firstIndex(where: { $0.id == currentFocusedID }) else { - if let firstFile = imageFiles.first { + let currentIndex = navigableFiles.firstIndex(where: { $0.id == currentFocusedID }) else { + if let firstFile = navigableFiles.first { selectedImageFileIDs = [firstFile.id] self.focusedImageFileID = firstFile.id scrollToID = firstFile.id @@ -683,7 +755,7 @@ struct ContentView: View { guard let nextIndex = nextImageIndex(from: currentIndex, direction: direction) else { return } - let nextFile = imageFiles[nextIndex] + let nextFile = navigableFiles[nextIndex] if isQuickPreviewPresented || detailViewFile != nil { selectedImageFileIDs = [nextFile.id] } else if NSApp.currentEvent?.modifierFlags.contains(.shift) == true { @@ -702,7 +774,7 @@ struct ContentView: View { private func nextImageIndex(from currentIndex: Int, direction: ArrowDirection) -> Int? { ImageNavigation.nextIndex( from: currentIndex, - itemCount: imageFiles.count, + itemCount: displayedImageFiles.count, direction: direction, viewMode: viewMode, gridColumnCount: gridColumnCount diff --git a/Microfiche/Models/LibraryLocation.swift b/Microfiche/Models/LibraryLocation.swift index 8755738..eba09d2 100644 --- a/Microfiche/Models/LibraryLocation.swift +++ b/Microfiche/Models/LibraryLocation.swift @@ -23,6 +23,12 @@ struct LinkedLibraryFolder: Identifiable, Equatable { fallback: name ) } + + var isICloudDrive: Bool { + LibraryLocationPresentation.isICloudDrivePath( + resolvedURL ?? URL(fileURLWithPath: originalPath) + ) + } } struct RememberedExternalVolume: Identifiable, Codable, Equatable { @@ -67,4 +73,8 @@ enum LibraryLocationPresentation { let relativeComponents = pathComponents.dropFirst(iCloudDriveIndex + 1) return relativeComponents.last ?? "iCloud Drive" } + + static func isICloudDrivePath(_ url: URL) -> Bool { + url.standardizedFileURL.pathComponents.contains(iCloudDriveDirectoryName) + } } diff --git a/Microfiche/Services/ImageMetadataStore.swift b/Microfiche/Services/ImageMetadataStore.swift index 279e791..c8758ba 100644 --- a/Microfiche/Services/ImageMetadataStore.swift +++ b/Microfiche/Services/ImageMetadataStore.swift @@ -102,6 +102,19 @@ final class ImageMetadataStore { } } + func allTags(for urls: [URL]) -> [String] { + var seen = Set() + var tags: [String] = [] + for url in urls { + for tag in metadata(for: url).tags { + guard !seen.contains(tag) else { continue } + seen.insert(tag) + tags.append(tag) + } + } + return tags.sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } + // MARK: - Persistence private func load() { diff --git a/Microfiche/Services/LibraryFiltering.swift b/Microfiche/Services/LibraryFiltering.swift new file mode 100644 index 0000000..21fe9ba --- /dev/null +++ b/Microfiche/Services/LibraryFiltering.swift @@ -0,0 +1,43 @@ +// +// LibraryFiltering.swift +// Microfiche +// + +import Foundation + +enum LibraryFiltering { + static func matches( + file: ImageFile, + metadata: ImageMetadata, + query: String, + fileType: String, + tag: String + ) -> Bool { + if !fileType.isEmpty, + file.url.pathExtension.lowercased() != fileType.lowercased() { + return false + } + + if !tag.isEmpty { + let normalizedTag = tag.lowercased() + let hasTag = metadata.tags.contains { $0.lowercased() == normalizedTag } + if !hasTag { return false } + } + + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedQuery.isEmpty else { return true } + + let normalizedQuery = trimmedQuery.lowercased() + if file.name.localizedStandardContains(normalizedQuery) { return true } + if metadata.comments.localizedStandardContains(normalizedQuery) { return true } + if metadata.whereFrom.localizedStandardContains(normalizedQuery) { return true } + if metadata.tags.contains(where: { $0.localizedStandardContains(normalizedQuery) }) { + return true + } + if metadata.labels.contains(where: { $0.localizedStandardContains(normalizedQuery) }) { + return true + } + + return false + } +} diff --git a/Microfiche/Views/MainContentView.swift b/Microfiche/Views/MainContentView.swift index de01514..9aa701b 100644 --- a/Microfiche/Views/MainContentView.swift +++ b/Microfiche/Views/MainContentView.swift @@ -13,6 +13,8 @@ import UniformTypeIdentifiers struct MainContentView: View { let imageFiles: [ImageFile] let unavailableLocation: LinkedLibraryFolder? + let isFiltering: Bool + let onRetryUnavailableLocation: () -> Void let showsToolbar: Bool @Binding var viewMode: ViewMode let gridThumbnailSize: CGFloat @@ -37,7 +39,11 @@ struct MainContentView: View { VStack { if imageFiles.isEmpty { Spacer(minLength: 24) - EmptyLibraryStateView(unavailableLocation: unavailableLocation) + EmptyLibraryStateView( + unavailableLocation: unavailableLocation, + isFiltering: isFiltering, + onRetryUnavailableLocation: onRetryUnavailableLocation + ) Spacer(minLength: 24) } else { if viewMode == .grid { @@ -118,6 +124,9 @@ private struct FloatingViewModeControl: View { } } .accessibilityValue(selection == mode ? "Selected" : "") + .accessibilityAddTraits(selection == mode ? [.isSelected] : []) + .accessibilityIdentifier("viewMode.\(mode.rawValue.lowercased())") + .keyboardShortcut(mode == .grid ? "1" : "2", modifiers: .command) } } .padding(3) @@ -146,17 +155,19 @@ private extension View { private struct EmptyLibraryStateView: View { let unavailableLocation: LinkedLibraryFolder? + let isFiltering: Bool + let onRetryUnavailableLocation: () -> Void var body: some View { VStack(spacing: 14) { - Image(systemName: unavailableLocation == nil ? "photo.on.rectangle.angled" : "externaldrive.badge.xmark") + Image(systemName: emptyStateIcon) .font(.system(size: 36, weight: .medium)) .foregroundStyle(.secondary) .symbolRenderingMode(.hierarchical) .frame(width: 56, height: 56) VStack(spacing: 6) { - Text(unavailableLocation == nil ? "No images yet" : "Reconnect the drive") + Text(emptyStateTitle) .font(.system(size: 22, weight: .semibold)) Text(emptyStateMessage) @@ -165,17 +176,41 @@ private struct EmptyLibraryStateView: View { .multilineTextAlignment(.center) .frame(maxWidth: 360) } + + if unavailableLocation != nil, !isFiltering { + Button("Try Again", action: onRetryUnavailableLocation) + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } } .padding(.horizontal, 24) } private var emptyStateMessage: String { + if isFiltering { + return "Try a different name, tag, file type, or clear the active filters." + } guard let unavailableLocation else { return "Link a folder or drop images into a contact sheet to start building a library." } + if unavailableLocation.isICloudDrive { + return "Check your network connection and iCloud Drive status, then try again." + } let driveName = unavailableLocation.volumeName ?? unavailableLocation.name - return "Reconnect \(driveName) to restore \(unavailableLocation.name) automatically." + return "Reconnect \(driveName) to restore \(unavailableLocation.displayName) automatically." + } + + private var emptyStateIcon: String { + if isFiltering { return "magnifyingglass" } + guard let unavailableLocation else { return "photo.on.rectangle.angled" } + return unavailableLocation.isICloudDrive ? "icloud.slash" : "externaldrive.badge.xmark" + } + + private var emptyStateTitle: String { + if isFiltering { return "No matches" } + guard let unavailableLocation else { return "No images yet" } + return unavailableLocation.isICloudDrive ? "iCloud Drive unavailable" : "Reconnect the drive" } } @@ -286,6 +321,7 @@ struct ImageGridView: View { } } .animation(isResizing ? nil : MicroficheMotion.snap, value: thumbnailSize) + .accessibilityIdentifier("image.grid") } private func updateColumnCount(for width: CGFloat) { diff --git a/Microfiche/Views/SidebarView.swift b/Microfiche/Views/SidebarView.swift index 1ff417f..ff072df 100644 --- a/Microfiche/Views/SidebarView.swift +++ b/Microfiche/Views/SidebarView.swift @@ -15,7 +15,6 @@ struct SidebarView: View { let externalVolumes: [RememberedExternalVolume] let contactSheets: [ContactSheet] let selection: Selection? - let onWidthChange: (CGFloat) -> Void let onLinkFolder: () -> Void let onSelect: (Selection) -> Void let onRemoveFolder: (UUID) -> Void @@ -115,7 +114,7 @@ struct SidebarView: View { } .scrollIndicators(.hidden) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background(WidthReader(onChange: onWidthChange)) + .accessibilityIdentifier("library.sidebar") } private var folderSectionDetail: String { @@ -135,6 +134,9 @@ struct SidebarView: View { private func folderSubtitle(_ folder: LinkedLibraryFolder) -> String? { if !folder.isAvailable { + if folder.isICloudDrive { + return "iCloud unavailable" + } return folder.isExternal ? "\(folder.volumeName ?? "External drive") • Offline" : "Unavailable" diff --git a/Microfiche/Views/WidthReader.swift b/Microfiche/Views/WidthReader.swift deleted file mode 100644 index 59726d2..0000000 --- a/Microfiche/Views/WidthReader.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// WidthReader.swift -// Microfiche -// -// Created by David Hoang on 6/8/25. -// - -import SwiftUI - -struct WidthReader: View { - let onChange: (CGFloat) -> Void - - var body: some View { - GeometryReader { geo in - Color.clear - .onAppear { onChange(geo.size.width) } - .onChange(of: geo.size.width) { _, newWidth in - onChange(newWidth) - } - } - } -} diff --git a/MicroficheTests/MicroficheTests.swift b/MicroficheTests/MicroficheTests.swift index 0be053f..c8a53cb 100644 --- a/MicroficheTests/MicroficheTests.swift +++ b/MicroficheTests/MicroficheTests.swift @@ -267,6 +267,34 @@ final class MicroficheTests: XCTestCase { ) } + func testLibraryFilteringMatchesNamesTypesAndMetadata() { + let file = ImageFile(url: URL(fileURLWithPath: "/Photos/sunset.JPG")) + let metadata = ImageMetadata( + tags: ["Travel"], + labels: ["Favorite"], + comments: "Golden hour", + whereFrom: "Seattle" + ) + + XCTAssertTrue(LibraryFiltering.matches( + file: file, metadata: metadata, query: "golden", fileType: "jpg", tag: "travel" + )) + XCTAssertFalse(LibraryFiltering.matches( + file: file, metadata: metadata, query: "golden", fileType: "png", tag: "travel" + )) + XCTAssertFalse(LibraryFiltering.matches( + file: file, metadata: metadata, query: "desert", fileType: "jpg", tag: "" + )) + } + + func testLibraryLocationPresentationRecognizesICloudDrivePaths() { + let url = URL(fileURLWithPath: "/Users/test/Library/Mobile Documents/com~apple~CloudDocs/Photos") + XCTAssertTrue(LibraryLocationPresentation.isICloudDrivePath(url)) + XCTAssertFalse(LibraryLocationPresentation.isICloudDrivePath( + URL(fileURLWithPath: "/Users/test/Pictures") + )) + } + func testICloudDriveRootUsesFriendlyDisplayName() { let folder = LinkedLibraryFolder( id: UUID(), diff --git a/MicroficheUITests/MicroficheUITests.swift b/MicroficheUITests/MicroficheUITests.swift index 2b2da3e..52a4955 100644 --- a/MicroficheUITests/MicroficheUITests.swift +++ b/MicroficheUITests/MicroficheUITests.swift @@ -9,34 +9,63 @@ import XCTest final class MicroficheUITests: XCTestCase { - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. + private func makeApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments.append("--ui-testing") + return app + } - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false + private func element(_ identifier: String, in app: XCUIApplication) -> XCUIElement { + app.descendants(matching: .any).matching(identifier: identifier).firstMatch + } - // In UI tests it's important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + override func setUpWithError() throws { + continueAfterFailure = false } override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. } @MainActor - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() + func testPrimaryNavigationControls() throws { + let app = makeApp() + app.launch() + + XCTAssertTrue(element("library.sidebar", in: app).waitForExistence(timeout: 3)) + XCTAssertTrue(element("sidebar.toggle", in: app).waitForExistence(timeout: 3)) + + let gridButton = element("viewMode.grid", in: app) + let listButton = element("viewMode.list", in: app) + XCTAssertTrue(gridButton.waitForExistence(timeout: 3)) + XCTAssertTrue(listButton.exists) + XCTAssertTrue(element("library.filter", in: app).exists) + XCTAssertTrue(element("inspector.toggle", in: app).exists) + + listButton.click() + XCTAssertTrue(listButton.isSelected) + + gridButton.click() + XCTAssertTrue(gridButton.isSelected) + } + + @MainActor + func testInspectorCanBeToggled() throws { + let app = makeApp() app.launch() - // Use XCTAssert and related functions to verify your tests produce the correct results. + let inspectorButton = element("inspector.toggle", in: app) + XCTAssertTrue(inspectorButton.waitForExistence(timeout: 3)) + inspectorButton.click() + XCTAssertTrue(inspectorButton.waitForExistence(timeout: 2)) + inspectorButton.click() + XCTAssertTrue(inspectorButton.exists) } @MainActor func testLaunchPerformance() throws { if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { - // This measures how long it takes to launch your application. measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() + makeApp().launch() } } } diff --git a/MicroficheUITests/MicroficheUITestsLaunchTests.swift b/MicroficheUITests/MicroficheUITestsLaunchTests.swift index cdc455e..08f7504 100644 --- a/MicroficheUITests/MicroficheUITestsLaunchTests.swift +++ b/MicroficheUITests/MicroficheUITestsLaunchTests.swift @@ -20,6 +20,7 @@ final class MicroficheUITestsLaunchTests: XCTestCase { @MainActor func testLaunch() throws { let app = XCUIApplication() + app.launchArguments.append("--ui-testing") app.launch() // Insert steps here to perform after app launch but before taking a screenshot, diff --git a/design.md b/design.md index efc2f13..d923969 100644 --- a/design.md +++ b/design.md @@ -53,9 +53,9 @@ Rules derived from `ContentView`, `SidebarView`, and `ImageDetailView`. 1. **Two-column shell + inspector** — Use `NavigationSplitView` (sidebar + detail). Metadata lives in a detail-attached `.inspector`, not a third permanent split column. 2. **Sidebar owns library location** — Selection is a single enum: All Images, Folder, or Contact Sheet. External drive rows are status only — not navigation targets. 3. **Location change clears browsing state** — On library selection change, clear image selection, focus, quick preview, and detail view. -4. **Detail is a canvas overlay** — Double-click opens `ImageDetailView` over the library detail column (opacity / hit-testing), not a pushed navigation destination or new column. +4. **Detail pushes on the detail stack** — Double-click opens `ImageDetailView` via `NavigationStack` + `navigationDestination`, not an opacity overlay or new split column. 5. **Inspector stays with detail** — Entering detail keeps the metadata inspector available so Finder labels, tags, and comments can be edited while viewing. -6. **Sidebar is collapsible** — Width 240–360 (ideal 280). Auto-collapse to `.detailOnly` below 220 pt measured width. +6. **Sidebar is collapsible** — Width 240–360 (ideal 280). Use the system split-view divider and sidebar toggle; do not programmatically override `columnVisibility` during resize. 7. **Unified chrome** — Window uses unified toolbar with no title. Keep `.navigationTitle("")` unless a mode truly needs a title. 8. **Window scale** — Minimum about 1100×700; default launch size stays large enough for sidebar + grid + inspector. @@ -190,7 +190,7 @@ Disable animations while the grid size slider is dragging. Prefer `MicroficheMot ### Sidebar - **File:** `Microfiche/Views/SidebarView.swift` -- Custom scroll sections; width reported via `WidthReader` +- Custom scroll sections; collapsible via the system split-view divider and sidebar toggle ### Library canvas - **File:** `Microfiche/Views/MainContentView.swift`