diff --git a/MacImageManager/MacImageManager/ContentView.swift b/MacImageManager/MacImageManager/ContentView.swift index ec63d02..b2362c5 100644 --- a/MacImageManager/MacImageManager/ContentView.swift +++ b/MacImageManager/MacImageManager/ContentView.swift @@ -11,12 +11,16 @@ import UniformTypeIdentifiers struct ContentView: View { // Use EnvironmentObject to access the shared model @EnvironmentObject private var browserModel: BrowserModel - @State private var selectedFile: FileItem? + @FocusState private var activePane: ActivePane? + + enum ActivePane { + case browser, viewer + } // View selection based on media type @ViewBuilder private var mediaViewer: some View { - if let file = selectedFile { + if let file = browserModel.selectedFile { switch file.mediaType { case .staticImage, .unknown: PaneImageViewer(selectedImage: file.url) @@ -58,11 +62,13 @@ struct ContentView: View { var body: some View { HSplitView { // Left pane - File browser - PaneFileBrowserView(selectedImage: $selectedFile) + PaneFileBrowserView(selectedImage: $browserModel.selectedFile) .frame(minWidth: 250, maxWidth: 400) - + .focused($activePane, equals: .browser) + // Right pane - Media viewer mediaViewer + .focused($activePane, equals: .viewer) .fileImporter( isPresented: $browserModel.showingFileImporter, allowedContentTypes: [.folder], @@ -94,7 +100,23 @@ struct ContentView: View { } .onChange(of: browserModel.currentDirectory) { _, _ in // Clear the selected image when navigating to a different directory - selectedFile = nil + browserModel.selectedFile = nil + } + .onKeyPress(.space) { + if activePane == .viewer && browserModel.selectedFileIsVideo { + browserModel.toggleVideoPlayback() + return .handled + } + return .ignored + } + .onKeyPress(phases: .down) { keyPress in + if keyPress.characters == "r" && keyPress.modifiers.contains(.command) { + if browserModel.canRenameSelectedFile { + browserModel.startRenamingSelectedFile() + return .handled + } + } + return .ignored } } } diff --git a/MacImageManager/MacImageManager/MacImageManagerApp.swift b/MacImageManager/MacImageManager/MacImageManagerApp.swift index 6055c5d..5848607 100644 --- a/MacImageManager/MacImageManager/MacImageManagerApp.swift +++ b/MacImageManager/MacImageManager/MacImageManagerApp.swift @@ -15,21 +15,92 @@ struct MacImageManagerApp: App { WindowGroup { ContentView() .frame(minWidth: 1200, minHeight: 800) - // 💡 Add the shared model to the environment so child views can access it + // Add the shared model to the environment so child views can access it .environmentObject(browserModel) + .onAppear { + NSWindow.allowsAutomaticWindowTabbing = false + } } .windowResizability(.contentSize) .commands { - // Add keyboard shortcuts + // Hide the default Edit menu + CommandGroup(replacing: .textEditing) {} + + // Hide the default View menu + CommandGroup(replacing: .toolbar) {} + + // Replace the new item commands CommandGroup(replacing: .newItem) {} - + + // Remove the (⌘W) "Close" item from `File` + CommandGroup(replacing: .saveItem) {} + + // File operations CommandGroup(after: .newItem) { Button("Open Folder...") { browserModel.showingFileImporter = true } .keyboardShortcut("o", modifiers: .command) + + Divider() + + Button("Go Up One Level") { + browserModel.navigateUp() + } + .keyboardShortcut(.upArrow, modifiers: .command) + .disabled(!browserModel.canNavigateUp) + + Button("Rename File") { + browserModel.startRenamingSelectedFile() + } + .keyboardShortcut("r", modifiers: .command) + .disabled(!browserModel.canRenameSelectedFile) + + Button("Delete File") { + browserModel.deleteSelectedFile() + } + .keyboardShortcut(.delete) + .disabled(!browserModel.hasSelectedFile) + + Divider() + + Button("Show in Finder") { + browserModel.showSelectedFileInFinder() + } + .keyboardShortcut("r", modifiers: [.command, .shift]) + .disabled(!browserModel.hasSelectedFile) + } + + // Playback menu for video controls (inserted explicitly after our File operations) + CommandMenu("Playback") { + Button("Play/Pause") { + browserModel.toggleVideoPlayback() + } + .keyboardShortcut(.space, modifiers: []) + .disabled(!browserModel.selectedFileIsVideo) + + Divider() + + Button("Jump Backward 10s") { + browserModel.jumpVideoBackward() + } + .keyboardShortcut(.leftArrow, modifiers: .command) + .disabled(!browserModel.selectedFileIsVideo) + + Button("Jump Forward 10s") { + browserModel.jumpVideoForward() + } + .keyboardShortcut(.rightArrow, modifiers: .command) + .disabled(!browserModel.selectedFileIsVideo) + + Divider() + + Button("Restart Video") { + browserModel.restartVideo() + } + .keyboardShortcut("r", modifiers: [.command, .option]) + .disabled(!browserModel.selectedFileIsVideo) } - } } } diff --git a/MacImageManager/MacImageManager/Models/BrowserModel.swift b/MacImageManager/MacImageManager/Models/BrowserModel.swift index 1b40ca7..887999a 100644 --- a/MacImageManager/MacImageManager/Models/BrowserModel.swift +++ b/MacImageManager/MacImageManager/Models/BrowserModel.swift @@ -9,12 +9,21 @@ import Foundation import SwiftUI import UniformTypeIdentifiers import Combine +import AVKit class BrowserModel: ObservableObject { @Published var items: [FileItem] = [] @Published var currentDirectory: URL @Published var canNavigateUp: Bool = false @Published var showingFileImporter: Bool = false + @Published var selectedFile: FileItem? + @Published var isRenamingFile = false + @Published var renamingText = "" + @Published var currentVideoPlayer: AVPlayer? + + enum VideoAction { + case play, pause, toggle, jumpForward, jumpBackward, restart + } // Cache to speed up metadata recomputation in large directories private var fileItemCache: [URL: FileItem] = [:] @@ -42,6 +51,8 @@ class BrowserModel: ObservableObject { updateNavigationState() } + let videoActionPublisher = PassthroughSubject() + var currentDirectoryName: String { currentDirectory.lastPathComponent } @@ -72,8 +83,6 @@ class BrowserModel: ObservableObject { let uti = resourceValues.contentType let isDir = resourceValues.isDirectory ?? false - let isAnimatedGif = uti?.conforms(to: UTType.gif) ?? false - let isVideo = uti?.conforms(to: UTType.movie) ?? false let fileSize = resourceValues.fileSize ?? 0 let modDate = resourceValues.contentModificationDate ?? Date() @@ -198,4 +207,191 @@ class BrowserModel: ObservableObject { return imageItems.last } + + // MARK: - Menu Actions + + /// Computed property to check if a file is selected for menu state + var hasSelectedFile: Bool { + selectedFile != nil + } + + /// Computed property to check if selected file is renameable + var canRenameSelectedFile: Bool { + guard let file = selectedFile else { return false } + return !file.isDirectory // For now, only allow renaming files, not directories + } + + /// Start renaming the currently selected file + func startRenamingSelectedFile() { + guard let file = selectedFile else { return } + renamingText = file.name + isRenamingFile = true + } + + /// Complete the rename operation + func completeRename() { + guard let file = selectedFile, !renamingText.isEmpty else { + cancelRename() + return + } + + // Validate the filename + guard isValidFilename(renamingText) else { + cancelRename() + return + } + + // Check if the name hasn't actually changed + guard renamingText != file.name else { + cancelRename() + return + } + + let newURL = file.url.deletingLastPathComponent().appendingPathComponent(renamingText) + + do { + try FileManager.default.moveItem(at: file.url, to: newURL) + + // Update the file item in our list + if let index = items.firstIndex(where: { $0.url == file.url }) { + Task { + let updatedItem = await FileItem( + url: newURL, + name: renamingText, + isDirectory: file.isDirectory, + fileSize: file.fileSize, + modificationDate: file.modificationDate, + uti: file.uti + ) + await MainActor.run { + items[index] = updatedItem + selectedFile = updatedItem + + // Update cache + fileItemCache.removeValue(forKey: file.url) + fileItemCache[newURL] = updatedItem + + // Cancel rename mode after UI is updated + cancelRename() + } + } + } else { + // If item not found in list, still cancel rename mode + cancelRename() + } + + print("Successfully renamed \(file.name) to \(renamingText)") + } catch { + print("Failed to rename file: \(error.localizedDescription)") + cancelRename() + } + } + + /// Cancel the rename operation + func cancelRename() { + isRenamingFile = false + renamingText = "" + } + + /// Delete the currently selected file + func deleteSelectedFile() { + guard let file = selectedFile else { return } + + do { + try FileManager.default.trashItem(at: file.url, resultingItemURL: nil) + + // Remove from our list + items.removeAll { $0.url == file.url } + fileItemCache.removeValue(forKey: file.url) + selectedFile = nil + + print("Successfully moved \(file.name) to trash") + } catch { + print("Failed to delete file: \(error.localizedDescription)") + } + } + + /// Show selected file in Finder + func showSelectedFileInFinder() { + guard let file = selectedFile else { return } + NSWorkspace.shared.selectFile(file.url.path, inFileViewerRootedAtPath: "") + } + + // MARK: - Video Control Actions + + /// Toggle video playback (play/pause) + func toggleVideoPlayback() { + videoActionPublisher.send(.toggle) + } + + /// Play video + func playVideo() { + videoActionPublisher.send(.play) + } + + /// Pause video + func pauseVideo() { + videoActionPublisher.send(.pause) + } + + /// Jump forward 10 seconds + func jumpVideoForward() { + videoActionPublisher.send(.jumpForward) + } + + /// Jump backward 10 seconds + func jumpVideoBackward() { + videoActionPublisher.send(.jumpBackward) + } + + /// Restart video from beginning + func restartVideo() { + videoActionPublisher.send(.restart) + } + + /// Set the current video player reference + func setVideoPlayer(_ player: AVPlayer?) { + currentVideoPlayer = player + } + + /// Check if we currently have a video playing + var hasVideoPlayer: Bool { + currentVideoPlayer != nil + } + + /// Check if current selection is a video file + var selectedFileIsVideo: Bool { + selectedFile?.mediaType == .video + } + + /// Validate filename for macOS compatibility + private func isValidFilename(_ filename: String) -> Bool { + let trimmedName = filename.trimmingCharacters(in: .whitespacesAndNewlines) + + // Check for empty or whitespace-only names + guard !trimmedName.isEmpty else { return false } + + // Check if it's just an extension (starts with .) + guard !trimmedName.hasPrefix(".") else { return false } + + // Check filename length (macOS limit is 255 bytes, but we'll use a conservative limit) + guard trimmedName.count <= 255 else { return false } + + // Check for invalid characters on macOS + // macOS is more permissive than Windows, but these are still problematic + let invalidCharacters = CharacterSet(charactersIn: ":\0") + guard trimmedName.rangeOfCharacter(from: invalidCharacters) == nil else { return false } + + // Check for names that are just dots + guard trimmedName != "." && trimmedName != ".." else { return false } + + // Check for control characters (0x00-0x1F and 0x7F) + for char in trimmedName.unicodeScalars { + if char.value <= 0x1F || char.value == 0x7F { + return false + } + } + + return true + } } diff --git a/MacImageManager/MacImageManager/ROADMAP.md b/MacImageManager/MacImageManager/ROADMAP.md index d632c51..ed82edd 100644 --- a/MacImageManager/MacImageManager/ROADMAP.md +++ b/MacImageManager/MacImageManager/ROADMAP.md @@ -1,9 +1,16 @@ -# Development Roadmap +# Development Roadmap ## FEATURES + - Add more menu items/keyboard shortcuts like {space} for Play/Pause of gif/video - Add: options: e.g., sort folders first (BrowserModel > loadCurrentDirectory()) - add samples for: `["jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "heic", "heif", "webp", "svg", "ico"]` +- add a new file menu item and functionality to create a new folder ## QA/DEV + - add unit tests using Swift Testing to cover common cases (e.g., .jpg, .heic, .webp, and unknown extensions). + +## FIXME + +- space only kinda works sometimes for play/pause diff --git a/MacImageManager/MacImageManager/Views/FileBrowserRowView.swift b/MacImageManager/MacImageManager/Views/FileBrowserRowView.swift index e5d0cea..51d42d0 100644 --- a/MacImageManager/MacImageManager/Views/FileBrowserRowView.swift +++ b/MacImageManager/MacImageManager/Views/FileBrowserRowView.swift @@ -7,9 +7,11 @@ import SwiftUI import UniformTypeIdentifiers +import AppKit struct FileBrowserRowView: View { @EnvironmentObject var browserModel: BrowserModel + @FocusState private var isTextFieldFocused: Bool let item: FileItem var body: some View { @@ -20,47 +22,69 @@ struct FileBrowserRowView: View { .foregroundStyle(tint(for: item)) .frame(width: 32, height: 32) - VStack(alignment: .leading) { - Text(item.name) - .lineLimit(1) - Text(item.modificationDate.formatted(date: .abbreviated, time: .shortened)) - .lineLimit(1) - .foregroundColor(.gray) - } + if browserModel.isRenamingFile && browserModel.selectedFile?.id == item.id { + // Rename mode: just show centered TextField + RenameTextField( + text: $browserModel.renamingText, + isTextFieldFocused: $isTextFieldFocused, + onSubmit: { browserModel.completeRename() }, + onCancel: { browserModel.cancelRename() } + ) + } else { + // Normal mode: show name, date, and file size + VStack(alignment: .leading) { + Text(item.name) + .lineLimit(1) + + Text(item.modificationDate.formatted(date: .abbreviated, time: .shortened)) + .lineLimit(1) + .foregroundColor(.gray) + } - Spacer() // NOTE: push content left + Spacer() // NOTE: push content left - if !item.isDirectory { - Text(item.formattedFileSize) - .frame(minWidth: 70, alignment: .trailing) - .foregroundColor(.gray) - .font(.system(size: 12)) + if !item.isDirectory { + Text(item.formattedFileSize) + .frame(minWidth: 70, alignment: .trailing) + .foregroundColor(.gray) + .font(.system(size: 12)) + } } } .padding(.vertical, 2) .contextMenu { Button(item.isDirectory ? "Rename Folder" : "Rename File") { - if item.isDirectory { - print("FUTURE: Rename directory functionality") - //browserModel.navigateInto(item: item) - } else { - print("FUTURE: Rename file functionality") - // This could be a new method in your model or a simple action here - } + // Set this item as selected and start renaming + browserModel.selectedFile = item + browserModel.startRenamingSelectedFile() } - // FUTURE: Add other menu items as needed + .keyboardShortcut("r", modifiers: .command) + .disabled(item.isDirectory) // TODO: For now, disable directory renaming + + Button("Delete") { + browserModel.selectedFile = item + browserModel.deleteSelectedFile() + } + .keyboardShortcut(.delete) + Divider() - Button("Rename") { - print("FUTURE: Implement rename functionality") + Button("Show in Finder") { + browserModel.showSelectedFileInFinder() } + .keyboardShortcut("r", modifiers: [.command, .shift]) - Button("Get Info") { - print("FUTURE: Implement get info functionality") + if item.mediaType == .video { + Divider() + + Button("Play/Pause") { + browserModel.selectedFile = item + browserModel.toggleVideoPlayback() + } } } } - + private func tint(for item: FileItem) -> Color { if item.isDirectory { return .blue } guard let type = item.uti else { return .secondary } @@ -83,6 +107,76 @@ struct FileBrowserRowView: View { } } +struct RenameTextField: NSViewRepresentable { + @Binding var text: String + var isTextFieldFocused: FocusState.Binding + let onSubmit: () -> Void + let onCancel: () -> Void + + func makeNSView(context: Context) -> NSTextField { + let textField = NSTextField() + textField.stringValue = text + textField.delegate = context.coordinator + textField.target = context.coordinator + textField.action = #selector(Coordinator.textFieldAction) + + // Auto-select filename without extension + DispatchQueue.main.async { + textField.becomeFirstResponder() + + if let dotIndex = text.lastIndex(of: "."), + dotIndex > text.startIndex { + let filename = String(text[.. Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, NSTextFieldDelegate { + let parent: RenameTextField + + init(_ parent: RenameTextField) { + self.parent = parent + } + + @objc func textFieldAction() { + parent.onSubmit() + } + + func controlTextDidChange(_ obj: Notification) { + if let textField = obj.object as? NSTextField { + parent.text = textField.stringValue + } + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { + parent.onCancel() + return true + } + return false + } + } +} + #Preview { FileBrowserRowView(item: BrowserModel.preview.items.first!) .environmentObject(BrowserModel.preview) diff --git a/MacImageManager/MacImageManager/Views/PaneFileBrowserView.swift b/MacImageManager/MacImageManager/Views/PaneFileBrowserView.swift index b3edf32..802678c 100644 --- a/MacImageManager/MacImageManager/Views/PaneFileBrowserView.swift +++ b/MacImageManager/MacImageManager/Views/PaneFileBrowserView.swift @@ -10,6 +10,7 @@ import SwiftUI struct PaneFileBrowserView: View { @EnvironmentObject var browserModel: BrowserModel @Binding var selectedImage: FileItem? + @FocusState private var isListFocused: Bool var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -41,6 +42,18 @@ struct PaneFileBrowserView: View { } } } + .focused($isListFocused) + .onAppear { + isListFocused = true + } + .onChange(of: browserModel.isRenamingFile) { isRenaming in + // Restore focus to list when exiting rename mode + if !isRenaming { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isListFocused = true + } + } + } .onChange(of: browserModel.currentDirectory) { _ in // Also scroll to top when using the "up" navigation button DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { diff --git a/MacImageManager/MacImageManager/Views/PaneVideoViewer.swift b/MacImageManager/MacImageManager/Views/PaneVideoViewer.swift index 2f56a5c..95fd225 100644 --- a/MacImageManager/MacImageManager/Views/PaneVideoViewer.swift +++ b/MacImageManager/MacImageManager/Views/PaneVideoViewer.swift @@ -7,11 +7,13 @@ import SwiftUI import AVKit +import Combine struct PaneVideoViewer: View { let videoUrl: URL? - + @EnvironmentObject private var browserModel: BrowserModel @State private var player: AVPlayer? + @State private var cancellables = Set() var body: some View { Group { @@ -43,6 +45,7 @@ struct PaneVideoViewer: View { } .onAppear { loadVideo(from: videoUrl) + setupVideoActionSubscription() } .onDisappear { cleanup() @@ -58,11 +61,59 @@ struct PaneVideoViewer: View { let newPlayer = AVPlayer(playerItem: playerItem) self.player = newPlayer + + // Update the browser model with the current player reference + browserModel.setVideoPlayer(newPlayer) } private func cleanup() { player?.pause() player = nil + cancellables.removeAll() + + // Clear the browser model's player reference + browserModel.setVideoPlayer(nil) + } + + private func setupVideoActionSubscription() { + browserModel.videoActionPublisher + .sink { [weak browserModel] action in + guard let player = browserModel?.currentVideoPlayer else { return } + handleVideoAction(action, for: player) + } + .store(in: &cancellables) + } + + private func handleVideoAction(_ action: BrowserModel.VideoAction, for player: AVPlayer) { + switch action { + case .play: + player.play() + + case .pause: + player.pause() + + case .toggle: + if player.timeControlStatus == .playing { + player.pause() + } else { + player.play() + } + + case .jumpForward: + let currentTime = player.currentTime() + let newTime = CMTimeAdd(currentTime, CMTime(seconds: 10, preferredTimescale: 1)) + player.seek(to: newTime) + + case .jumpBackward: + let currentTime = player.currentTime() + let newTime = CMTimeSubtract(currentTime, CMTime(seconds: 10, preferredTimescale: 1)) + let startTime = CMTime.zero + player.seek(to: CMTimeMaximum(newTime, startTime)) + + case .restart: + player.seek(to: CMTime.zero) + player.play() + } }