├── .gitignore ├── .github └── funding.yml ├── .vscode └── settings.json ├── docs ├── preview.jpg ├── preview_1.png └── preview_2.png ├── FlowVision ├── Resources │ ├── icon.png │ └── Assets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ ├── icon.png │ │ └── Contents.json │ │ ├── AccentColor.colorset │ │ └── Contents.json │ │ └── OutlineViewBgColor.colorset │ │ └── Contents.json ├── Sources │ ├── Views │ │ ├── CustomPathControl.swift │ │ ├── CustomEffectView.swift │ │ ├── CustomSplitView.swift │ │ ├── CoreAreaView.swift │ │ ├── DrawingView.swift │ │ ├── CustomCollectionViewManager.swift │ │ ├── CustomCollectionViewItem.xib │ │ ├── CustomImageView.swift │ │ ├── CustomCollectionView.swift │ │ ├── CustomOutlineViewManager.swift │ │ ├── Layout.swift │ │ └── CustomOutlineView.swift │ ├── Common │ │ ├── Enum.swift │ │ ├── RefCode.swift │ │ ├── VideoProcess.swift │ │ ├── Tag.swift │ │ ├── GlobalVariable.swift │ │ ├── Log.swift │ │ └── FFmpegKit.swift │ └── SettingsViews │ │ ├── ActionsSettingsViewController.swift │ │ ├── AdvancedSettingsViewController.swift │ │ ├── CustomSettingsViewController.swift │ │ ├── GeneralSettingsViewController.swift │ │ └── Base.lproj │ │ ├── ActionsSettingsViewController.xib │ │ └── GeneralSettingsViewController.xib ├── FlowVision.entitlements └── Info.plist ├── FlowVision.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ └── FlowVision.xcscheme ├── README_zh.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | xcuserdata/ -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: netdcyn -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "problems.visibility": false 3 | } -------------------------------------------------------------------------------- /docs/preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netdcy/FlowVision/HEAD/docs/preview.jpg -------------------------------------------------------------------------------- /docs/preview_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netdcy/FlowVision/HEAD/docs/preview_1.png -------------------------------------------------------------------------------- /docs/preview_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netdcy/FlowVision/HEAD/docs/preview_2.png -------------------------------------------------------------------------------- /FlowVision/Resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netdcy/FlowVision/HEAD/FlowVision/Resources/icon.png -------------------------------------------------------------------------------- /FlowVision/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /FlowVision/Resources/Assets.xcassets/AppIcon.appiconset/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netdcy/FlowVision/HEAD/FlowVision/Resources/Assets.xcassets/AppIcon.appiconset/icon.png -------------------------------------------------------------------------------- /FlowVision.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /FlowVision/Resources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /FlowVision.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /FlowVision/Sources/Views/CustomPathControl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomPathControl.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/3/17. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | 11 | class CustomPathControl: NSPathControl { 12 | 13 | } 14 | 15 | class CustomPathControlItem: NSPathControlItem { 16 | var myUrl: URL? 17 | } 18 | -------------------------------------------------------------------------------- /FlowVision/FlowVision.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.automation.apple-events 6 | 7 | com.apple.security.app-sandbox 8 | 9 | com.apple.security.files.bookmarks.app-scope 10 | 11 | com.apple.security.files.user-selected.read-write 12 | 13 | com.apple.security.personal-information.photos-library 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /FlowVision/Resources/Assets.xcassets/OutlineViewBgColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xF5", 9 | "green" : "0xF5", 10 | "red" : "0xF4" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x33", 27 | "green" : "0x33", 28 | "red" : "0x33" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /FlowVision/Sources/Common/Enum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Enum.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/7/5. 6 | // 7 | 8 | import Foundation 9 | import Settings 10 | 11 | enum FileType: Int, Codable { 12 | case image,video,other,folder,notSet,all 13 | } 14 | 15 | enum GestureDirection: Int, Codable { 16 | case right, left, up, down, up_right, up_left, down_left, down_right, zero, forward, back 17 | } 18 | 19 | enum LayoutType: Int, Codable { 20 | case justified,waterfall,grid,detail 21 | } 22 | 23 | enum SortType: Int, Codable { 24 | case pathA,pathZ,extA,extZ,sizeA,sizeZ,createDateA,createDateZ,modDateA,modDateZ,addDateA,addDateZ,random,exifDateA,exifDateZ,exifPixelA,exifPixelZ 25 | } 26 | 27 | extension Settings.PaneIdentifier { 28 | static let general = Self("general") 29 | static let custom = Self("custom") 30 | static let actions = Self("actions") 31 | static let advanced = Self("advanced") 32 | } 33 | -------------------------------------------------------------------------------- /FlowVision.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "e833fb3387e3d25a8dd47d041d897793d3acaf462dff351bb613c1059f8b80e8", 3 | "pins" : [ 4 | { 5 | "identity" : "libwebp-xcode", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", 8 | "state" : { 9 | "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", 10 | "version" : "1.5.0" 11 | } 12 | }, 13 | { 14 | "identity" : "sdwebimage", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/SDWebImage/SDWebImage.git", 17 | "state" : { 18 | "revision" : "cac9a55a3ae92478a2c95042dcc8d9695d2129ca", 19 | "version" : "5.21.0" 20 | } 21 | }, 22 | { 23 | "identity" : "sdwebimagewebpcoder", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/SDWebImage/SDWebImageWebPCoder.git", 26 | "state" : { 27 | "revision" : "f534cfe830a7807ecc3d0332127a502426cfa067", 28 | "version" : "0.14.6" 29 | } 30 | } 31 | ], 32 | "version" : 3 33 | } 34 | -------------------------------------------------------------------------------- /FlowVision/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "filename" : "icon.png", 50 | "idiom" : "mac", 51 | "scale" : "2x", 52 | "size" : "512x512" 53 | } 54 | ], 55 | "info" : { 56 | "author" : "xcode", 57 | "version" : 1 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /FlowVision/Sources/SettingsViews/ActionsSettingsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActionsSettingsViewController.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/7/25. 6 | // 7 | 8 | import Settings 9 | import Cocoa 10 | 11 | final class ActionsSettingsViewController: NSViewController, SettingsPane { 12 | let paneIdentifier = Settings.PaneIdentifier.actions 13 | let paneTitle = NSLocalizedString("Actions", comment: "操作(设置里的面板)") 14 | let toolbarItemIcon = NSImage(systemSymbolName: "keyboard.badge.ellipsis", accessibilityDescription: "")! 15 | 16 | override var nibName: NSNib.Name? { "ActionsSettingsViewController" } 17 | 18 | @IBOutlet weak var radioEnterKeyRename: NSButton! 19 | @IBOutlet weak var radioEnterKeyOpen: NSButton! 20 | 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | 24 | radioEnterKeyOpen.state = globalVar.isEnterKeyToOpen ? .on : .off 25 | radioEnterKeyRename.state = globalVar.isEnterKeyToOpen ? .off : .on 26 | 27 | } 28 | 29 | @IBAction func enterKeyToOpenToggled(_ sender: NSButton) { 30 | let tag = sender.tag 31 | if tag == 0 { 32 | globalVar.isEnterKeyToOpen = false 33 | } else if tag == 1 { 34 | globalVar.isEnterKeyToOpen = true 35 | } 36 | UserDefaults.standard.set(globalVar.isEnterKeyToOpen, forKey: "isEnterKeyToOpen") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /FlowVision/Sources/Common/RefCode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RefCode.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/7/5. 6 | // 7 | 8 | import Foundation 9 | 10 | var stream: FSEventStreamRef? 11 | 12 | func startListeningForFileSystemEvents(in directoryPath: String) { 13 | let callback: FSEventStreamCallback = { (streamRef, clientCallBackInfo, numEvents, eventPaths, eventFlags, eventIds) in 14 | let paths = eventPaths 15 | let pathArray = Unmanaged.fromOpaque(paths).takeUnretainedValue() as NSArray as! [String] 16 | for path in pathArray { 17 | log("File system change detected at path: \(path)") 18 | } 19 | } 20 | 21 | var context = FSEventStreamContext(version: 0, info: nil, retain: nil, release: nil, copyDescription: nil) 22 | let pathsToWatch = [directoryPath] as CFArray 23 | stream = FSEventStreamCreate(kCFAllocatorDefault, callback, &context, pathsToWatch, FSEventStreamEventId(kFSEventStreamEventIdSinceNow), 1.0, FSEventStreamCreateFlags(kFSEventStreamCreateFlagUseCFTypes | kFSEventStreamCreateFlagFileEvents)) 24 | 25 | // 使用DispatchQueue替代RunLoop 26 | if let stream = stream { 27 | FSEventStreamSetDispatchQueue(stream, DispatchQueue.global()) 28 | FSEventStreamStart(stream) 29 | } 30 | } 31 | 32 | func stopListeningForFileSystemEvents() { 33 | if let stream = stream { 34 | FSEventStreamStop(stream) 35 | FSEventStreamInvalidate(stream) 36 | FSEventStreamRelease(stream) 37 | } 38 | } 39 | 40 | -------------------------------------------------------------------------------- /FlowVision/Sources/Views/CustomEffectView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomEffectView.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/3/17. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | 11 | class CustomEffectView: NSVisualEffectView { 12 | 13 | override func awakeFromNib() { 14 | super.awakeFromNib() 15 | registerForDraggedTypes([.fileURL]) 16 | } 17 | 18 | override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { 19 | if let viewController = getViewController(self){ 20 | if viewController.publicVar.isInLargeView { 21 | return .link 22 | } 23 | } 24 | return .every 25 | } 26 | 27 | override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { 28 | if let viewController = getViewController(self) { 29 | if viewController.publicVar.isInLargeView { 30 | let pasteboard = sender.draggingPasteboard 31 | if let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] { 32 | getViewController(self)?.handleDraggedFiles(urls) 33 | return true 34 | } 35 | }else{ 36 | if sender.draggingSource is CustomCollectionView { 37 | return false 38 | } 39 | if let curFolderUrl = URL(string: viewController.fileDB.curFolder){ 40 | viewController.handleMove(targetURL: curFolderUrl, pasteboard: sender.draggingPasteboard) 41 | if sender.draggingSource is CustomOutlineView { 42 | viewController.refreshTreeView() 43 | } 44 | return true 45 | } 46 | } 47 | } 48 | return false 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /FlowVision/Sources/Views/CustomSplitView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomSplitView.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/6/12. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | 11 | class CustomSplitView: NSSplitView { 12 | 13 | private var middleMouseInitialLocation: NSPoint? 14 | 15 | override var dividerThickness: CGFloat { 16 | if getViewController(self)!.publicVar.profile.isDirTreeHidden { 17 | return 0 18 | }else{ 19 | if #available(macOS 26.0, *) { 20 | return 0 21 | }else{ 22 | return 1 23 | } 24 | } 25 | } 26 | 27 | override func otherMouseDown(with event: NSEvent) { 28 | if event.buttonNumber == 2 { // 检查是否按下了鼠标中键 29 | middleMouseInitialLocation = event.locationInWindow 30 | } else { 31 | super.otherMouseDown(with: event) 32 | } 33 | } 34 | 35 | override func otherMouseDragged(with event: NSEvent) { 36 | if event.buttonNumber == 2, let middleMouseInitialLocation = middleMouseInitialLocation { 37 | let newLocation = event.locationInWindow 38 | let deltaX = newLocation.x - middleMouseInitialLocation.x 39 | let deltaY = newLocation.y - middleMouseInitialLocation.y 40 | 41 | if let window = self.window { 42 | var frame = window.frame 43 | frame.origin.x += deltaX 44 | frame.origin.y += deltaY 45 | window.setFrame(frame, display: true) 46 | } 47 | globalVar.isInMiddleMouseDrag = true 48 | } else { 49 | super.otherMouseDragged(with: event) 50 | } 51 | } 52 | 53 | override func otherMouseUp(with event: NSEvent) { 54 | if event.buttonNumber == 2 { 55 | middleMouseInitialLocation = nil 56 | globalVar.isInMiddleMouseDrag = false 57 | } else { 58 | super.otherMouseUp(with: event) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /FlowVision/Sources/Common/VideoProcess.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageRelated.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/7/5. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | import AVFoundation 11 | import AVKit 12 | 13 | class NoHitAVPlayerView: AVPlayerView { 14 | override func hitTest(_ point: NSPoint) -> NSView? { 15 | return superview?.hitTest(convert(point, to: superview)) 16 | } 17 | } 18 | 19 | class LargeAVPlayerView: AVPlayerView { 20 | // override func hitTest(_ point: NSPoint) -> NSView? { 21 | // return nil //superview?.hitTest(convert(point, to: superview)) 22 | // } 23 | override func scrollWheel(with event: NSEvent) { 24 | // 不响应滚动事件,直接传递给下一个 25 | self.nextResponder?.scrollWheel(with: event) 26 | } 27 | } 28 | 29 | 30 | func createLoopingComposition(url: URL) -> AVMutableComposition? { 31 | let asset = AVAsset(url: url) 32 | guard let videoTrack = asset.tracks(withMediaType: .video).first, 33 | let audioTrack = asset.tracks(withMediaType: .audio).first else { 34 | return nil 35 | } 36 | 37 | // 打印视频轨道信息 38 | // let asset = AVAsset(url: url) 39 | // for track in asset.tracks { 40 | // print("媒体类型:", track.mediaType) 41 | // print("时长范围:", track.timeRange) 42 | // } 43 | 44 | // 计算音视频轨道的共同时间范围 45 | let timeRange = CMTimeRangeGetIntersection(videoTrack.timeRange, otherRange: audioTrack.timeRange) 46 | 47 | // 创建一个新的可变组合 48 | let composition = AVMutableComposition() 49 | 50 | do { 51 | // 将共同时间范围内的音视频轨道插入到新的组合中 52 | try composition.insertTimeRange(timeRange, of: asset, at: .zero) 53 | } catch { 54 | print("Error inserting time range into composition: \(error)") 55 | return nil 56 | } 57 | 58 | // 保持视频轨道的方向 59 | if let compositionVideoTrack = composition.tracks(withMediaType: .video).first { 60 | compositionVideoTrack.preferredTransform = videoTrack.preferredTransform 61 | } 62 | 63 | return composition 64 | } 65 | 66 | func getCommonTimeRange(url: URL) -> CMTimeRange? { 67 | let asset = AVAsset(url: url) 68 | guard let videoTrack = asset.tracks(withMediaType: .video).first, 69 | let audioTrack = asset.tracks(withMediaType: .audio).first else { 70 | return nil 71 | } 72 | 73 | // 计算音视频轨道的共同时间范围 74 | return CMTimeRangeGetIntersection(videoTrack.timeRange, otherRange: audioTrack.timeRange) 75 | } 76 | 77 | -------------------------------------------------------------------------------- /README_zh.md: -------------------------------------------------------------------------------- 1 |

2 |

FlowVision

3 |

为macOS设计的瀑布流式图片浏览器

4 |

5 | 6 | [![](https://img.shields.io/github/release/netdcy/FlowVision.svg)](https://github.com/netdcy/FlowVision/releases/latest?color=blue "GitHub release") ![GitHub License](https://img.shields.io/github/license/netdcy/FlowVision?color=blue) 7 | 8 | ## 预览 9 | 10 | ### 浅色模式 11 | ![preview](https://netdcy.github.io/FlowVision/docs/preview_2.png) 12 | 13 | ### 黑暗模式 14 | ![preview](https://netdcy.github.io/FlowVision/docs/preview_1.png) 15 | 16 | ## 应用特点: 17 | 18 | - 自适应布局模式、浅色/深色模式 19 | 20 | - 方便的文件管理(操作类似 Finder) 21 | 22 | - 右键手势、快速查找上一个/下一个有图片/视频的文件夹 23 | 24 | - 针对目录下大量图片情况的性能优化 25 | 26 | - 高质量的缩放(减轻摩尔纹等问题) 27 | 28 | - 支持视频播放 29 | 30 | - 支持HDR显示 31 | 32 | - 支持递归模式 33 | 34 | ## 安装使用 35 | 36 | ### 系统需求 37 | 38 | - macOS 11.0+ 39 | 40 | ### 隐私与安全性 41 | 42 | - 开源软件 43 | - 无网络请求 44 | 45 | ### Homebrew 方式安装 46 | 47 | 首次安装 48 | ``` 49 | brew install flowvision 50 | ``` 51 | 版本升级 52 | ``` 53 | brew update 54 | brew upgrade flowvision 55 | ``` 56 | 57 | ## 操作说明 58 | 59 | ### 图片浏览: 60 | - 双击打开/关闭图片 61 | - 按住右键/左键滚动滚轮可以缩放 62 | - 按住中键拖动可以移动窗口 63 | - 长按左键切换 100%缩放 64 | - 长按右键切换缩放到视图 65 | ### 右键手势: 66 | - 向右/左:切换到下一个/上一个有图片/视频的文件夹(逻辑上等同于将整个磁盘中的文件夹排序后的下一个) 67 | - 向上:切换到上级目录 68 | - 向下:返回到上一次的目录 69 | - 向上右:切换到与当前文件夹平级的下一个有图片的文件夹 70 | - 向下右:关闭当前标签页/窗口 71 | ### 键盘按键: 72 | - W:同右键手势 向上 73 | - A/D:同右键手势 向左/右 74 | - S:同右键手势 向下 75 | 76 | ## 编译 77 | 78 | ### 环境 79 | 80 | Xcode 15.2+ 81 | 82 | ### 第三方库 83 | 84 | - https://github.com/arthenica/ffmpeg-kit 85 | - https://github.com/attaswift/BTree 86 | - https://github.com/sindresorhus/Settings 87 | 88 | ### 构建步骤 89 | 90 | 1. 克隆此项目和依赖库的代码。 91 | 2. 对于ffmpeg-kit,需要预先构建二进制文件。如果你想省时间,可以直接下载它已构建好的二进制库,例如 `ffmpeg-kit-full-gpl-6.0-macos-xcframework.zip` (非LTS版本)。 解压后,在终端执行如下命令以移除quarantine属性: 92 | 93 | ``` 94 | sudo xattr -rd com.apple.quarantine ./ffmpeg-kit-full-gpl-6.0-macos-xcframework 95 | ``` 96 | 97 | (由于项目中止和版权原因,预构建的二进制文件已被移除,[这里](https://github.com/netdcy/ffmpeg-kit/releases/download/v6.0/ffmpeg-kit-full-gpl-6.0-macos-xcframework.zip)是原文件的备份。) 98 | 99 | 3. 按如下所示组织目录结构: 100 | 101 | ``` 102 | ├── FlowVision 103 | │ ├── FlowVision.xcodeproj 104 | │ └── FlowVision 105 | │ └── Sources 106 | ├── ffmpeg-kit-build 107 | │ └── bundle-apple-xcframework-macos 108 | │ ├── ffmpegkit.xcframework 109 | │ └── ... 110 | ├── BTree 111 | │ ├── Package.swift 112 | │ └── Sources 113 | └── Settings 114 | ├── Package.swift 115 | └── Sources 116 | ``` 117 | 118 | 4. 用Xcode打开 `FlowVision.xcodeproj` ,在菜单栏中点击 'Product' -> 'Build For' -> 'Profiling' 。 119 | 5. 然后 'Product' -> 'Show Build Folder in Finder',就可以看到构建好的app了 `Products/Release/FlowVision.app` 。 120 | 121 | ## 协议 122 | 123 | 本项目使用GPL许可证。完整的许可证文本请参见 [LICENSE](https://github.com/netdcy/FlowVision/blob/main/LICENSE) 文件。 -------------------------------------------------------------------------------- /FlowVision/Sources/Views/CoreAreaView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreAreaView.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/7/5. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | 11 | class CoreAreaView: NSView { 12 | 13 | var infoView: InfoView! 14 | var cannotBeCleard: Bool = true 15 | 16 | override init(frame frameRect: NSRect) { 17 | super.init(frame: frameRect) 18 | commonInit() 19 | } 20 | 21 | required init?(coder: NSCoder) { 22 | super.init(coder: coder) 23 | commonInit() 24 | } 25 | 26 | private func commonInit() { 27 | infoView = InfoView(frame: .zero) 28 | infoView.setupView(fontSize: 20, fontWeight: .light, cornerRadius: 6.0, edge: (18,8)) 29 | infoView.translatesAutoresizingMaskIntoConstraints = false 30 | addSubview(infoView) 31 | NSLayoutConstraint.activate([ 32 | infoView.centerXAnchor.constraint(equalTo: self.centerXAnchor), 33 | infoView.centerYAnchor.constraint(equalTo: self.centerYAnchor) 34 | ]) 35 | } 36 | 37 | func showInfo(_ info: String, timeOut: Double = 1.0, duration: Double = INFO_VIEW_DURATION, cannotBeCleard: Bool = true) { 38 | infoView.showInfo(text: info, timeOut: timeOut, duration: duration) 39 | self.cannotBeCleard = cannotBeCleard 40 | } 41 | 42 | func hideInfo(force: Bool = false, duration: Double = INFO_VIEW_DURATION) { 43 | if !self.cannotBeCleard || force { 44 | infoView.hide(duration: duration) 45 | } 46 | } 47 | 48 | override func awakeFromNib() { 49 | super.awakeFromNib() 50 | registerForDraggedTypes([.fileURL]) 51 | } 52 | 53 | override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { 54 | if let viewController = getViewController(self){ 55 | if viewController.publicVar.isInLargeView { 56 | return .link 57 | } 58 | } 59 | return .every 60 | } 61 | 62 | override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { 63 | if let viewController = getViewController(self) { 64 | if viewController.publicVar.isInLargeView { 65 | let pasteboard = sender.draggingPasteboard 66 | if let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] { 67 | getViewController(self)?.handleDraggedFiles(urls) 68 | return true 69 | } 70 | }else{ 71 | if let source = sender.draggingSource { 72 | if source is CustomCollectionView && (source as? NSView)?.window == self.window { 73 | return false 74 | } 75 | } 76 | if let curFolderUrl = URL(string: viewController.fileDB.curFolder){ 77 | viewController.handleMove(targetURL: curFolderUrl, pasteboard: sender.draggingPasteboard) 78 | if sender.draggingSource is CustomOutlineView { 79 | viewController.refreshTreeView() 80 | } 81 | return true 82 | } 83 | } 84 | } 85 | return false 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /FlowVision.xcodeproj/xcshareddata/xcschemes/FlowVision.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 53 | 57 | 58 | 62 | 63 | 64 | 65 | 71 | 73 | 79 | 80 | 81 | 82 | 84 | 85 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 |

FlowVision

4 |

Waterfall-style Image Viewer for macOS

[中文说明]

5 |

6 | 7 | [![](https://img.shields.io/github/release/netdcy/FlowVision.svg)](https://github.com/netdcy/FlowVision/releases/latest?color=blue "GitHub release") ![GitHub License](https://img.shields.io/github/license/netdcy/FlowVision?color=blue) 8 | 9 | ## Screenshots 10 | 11 | ### Light Mode 12 | ![preview](https://netdcy.github.io/FlowVision/docs/preview_2.png) 13 | 14 | ### Dark Mode 15 | ![preview](https://netdcy.github.io/FlowVision/docs/preview_1.png) 16 | 17 | ## Features: 18 | - Adaptive layout mode, light/dark mode 19 | - Convenient file management (similar to Finder) 20 | - Right-click gestures, quickly find the previous/next folder with images/videos 21 | - Performance optimizations for directories with a large number of images 22 | - High-quality scaling (reduces moiré and other issues) 23 | - Support for video playback 24 | - Support for HDR display 25 | - Recursive mode 26 | 27 | ## Installation and Usage 28 | 29 | ### System Requirements 30 | 31 | - macOS 11.0 or Later 32 | 33 | ### Privacy and Security 34 | 35 | - Open source 36 | - No Internet connection 37 | 38 | ### Homebrew Install 39 | 40 | Initial Installation 41 | ``` 42 | brew install flowvision 43 | ``` 44 | Upgrade 45 | ``` 46 | brew update 47 | brew upgrade flowvision 48 | ``` 49 | 50 | ## Instructions: 51 | ### In Image View: 52 | - Double-click to open/close the image 53 | - Hold down the right/left mouse button and scroll the wheel to zoom 54 | - Hold down the middle mouse button and drag to move the window 55 | - Long press the left mouse button to switch to 100% zoom 56 | - Long press the right mouse button to fit the image to the view 57 | ### Right-Click Gestures: 58 | - Right/Left: Switch to the next/previous folder with images/videos (logically equivalent to the next folder when sorting all folders on the disk) 59 | - Up: Switch to the parent directory 60 | - Down: Return to the previous directory 61 | - Up-Right: Switch to the next folder with images at the same level as the current folder 62 | - Down-Right: Close the tab/window 63 | ### Keyboard Shortcuts: 64 | - W: Same as the right-click gesture Up 65 | - A/D: Same as the right-click gesture Left/Right 66 | - S: Same as the right-click gesture Down 67 | 68 | ## Build 69 | 70 | ### Environment 71 | 72 | Xcode 15.2+ 73 | 74 | ### Libraries 75 | 76 | - https://github.com/arthenica/ffmpeg-kit 77 | - https://github.com/attaswift/BTree 78 | - https://github.com/sindresorhus/Settings 79 | 80 | ### Steps 81 | 82 | 1. Clone the source code of the project and libraries. 83 | 2. For ffmpeg-kit, it need to be built to binary first. If you want to save time, you can directly download its pre-built binary, named like `ffmpeg-kit-full-gpl-6.0-macos-xcframework.zip` (not LTS version). Unzip it, then execute this in terminal to remove its quarantine attribute: 84 | 85 | ``` 86 | sudo xattr -rd com.apple.quarantine ./ffmpeg-kit-full-gpl-6.0-macos-xcframework 87 | ``` 88 | 89 | (Due to the project being discontinued and copyright reasons, the prebuilt binaries have been removed. Here is a [backup](https://github.com/netdcy/ffmpeg-kit/releases/download/v6.0/ffmpeg-kit-full-gpl-6.0-macos-xcframework.zip) of original file.) 90 | 91 | 3. Organize the directory structure as shown below: 92 | 93 | ``` 94 | ├── FlowVision 95 | │ ├── FlowVision.xcodeproj 96 | │ └── FlowVision 97 | │ └── Sources 98 | ├── ffmpeg-kit-build 99 | │ └── bundle-apple-xcframework-macos 100 | │ ├── ffmpegkit.xcframework 101 | │ └── ... 102 | ├── BTree 103 | │ ├── Package.swift 104 | │ └── Sources 105 | └── Settings 106 | ├── Package.swift 107 | └── Sources 108 | ``` 109 | 110 | 4. Open `FlowVision.xcodeproj` by Xcode, click 'Product' -> 'Build For' -> 'Profiling' in menu bar. 111 | 5. Then 'Product' -> 'Show Build Folder in Finder', and you will find the app is at `Products/Release/FlowVision.app`. 112 | 113 | ## Donate 114 | 115 | If you found the project is helpful, feel free to buy me a coffee. 116 | 117 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/netdcyn) 118 | 119 | ## License 120 | 121 | This project is licensed under the GPL License. See the [LICENSE](https://github.com/netdcy/FlowVision/blob/main/LICENSE) file for the full license text. 122 | 123 | -------------------------------------------------------------------------------- /FlowVision/Sources/Common/Tag.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tag.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2025/7/9. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | import BTree 11 | 12 | class TaggingSystem { 13 | 14 | static var db = Map>() 15 | static var defaultTag = "⭐" 16 | 17 | // MARK: - 持久化相关 18 | private static var dataFileURL: URL { 19 | let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! 20 | let flowVisionDir = appSupport.appendingPathComponent("FlowVision") 21 | 22 | // 确保目录存在 23 | try? FileManager.default.createDirectory(at: flowVisionDir, withIntermediateDirectories: true) 24 | 25 | return flowVisionDir.appendingPathComponent("tags.json") 26 | } 27 | 28 | // 保存数据到JSON文件 29 | private static func saveToFile() { 30 | do { 31 | // 将Map转换为可序列化的格式 32 | var serializableData: [String: [String]] = [:] 33 | for (tag, urls) in db { 34 | serializableData[tag] = urls.map { $0.absoluteString } 35 | } 36 | 37 | let jsonData = try JSONSerialization.data(withJSONObject: serializableData, options: .prettyPrinted) 38 | try jsonData.write(to: dataFileURL) 39 | } catch { 40 | print("保存标签数据失败: \(error)") 41 | } 42 | } 43 | 44 | // 从JSON文件加载数据 45 | private static func loadFromFile() { 46 | guard FileManager.default.fileExists(atPath: dataFileURL.path) else { return } 47 | 48 | do { 49 | let jsonData = try Data(contentsOf: dataFileURL) 50 | if let serializableData = try JSONSerialization.jsonObject(with: jsonData) as? [String: [String]] { 51 | db.removeAll() 52 | for (tag, urlStrings) in serializableData { 53 | let urls = Set(urlStrings.compactMap { URL(string: $0) }) 54 | db[tag] = urls 55 | } 56 | } 57 | } catch { 58 | print("加载标签数据失败: \(error)") 59 | } 60 | } 61 | 62 | // 添加标签 63 | static func add(tag:String? = nil, url: URL, needSave:Bool = true){ 64 | let tag = tag ?? defaultTag 65 | if db[tag] == nil { 66 | db[tag] = Set() 67 | } 68 | db[tag]?.insert(url) 69 | if needSave { 70 | saveToFile() // 保存更改 71 | } 72 | } 73 | static func add(tag:String? = nil, urls: [URL]){ 74 | for url in urls { 75 | add(tag: tag, url: url, needSave: false) 76 | } 77 | saveToFile() // 保存更改 78 | } 79 | 80 | // 移除标签 81 | static func remove(tag:String? = nil, url: URL, needSave:Bool = true){ 82 | let tag = tag ?? defaultTag 83 | db[tag]?.remove(url) 84 | if db[tag]?.isEmpty == true { 85 | db.removeValue(forKey: tag) 86 | } 87 | if needSave { 88 | saveToFile() // 保存更改 89 | } 90 | } 91 | static func remove(tag:String? = nil, urls: [URL]){ 92 | for url in urls { 93 | remove(tag: tag, url: url, needSave: false) 94 | } 95 | saveToFile() // 保存更改 96 | } 97 | 98 | // 获取某标签的文件列表 99 | static func getList(tag:String? = nil) -> [URL]{ 100 | let tag = tag ?? defaultTag 101 | return Array(db[tag] ?? Set()) 102 | } 103 | 104 | // 判断是否被某标签标记 105 | static func isTagged(tag:String? = nil, url: URL) -> Bool{ 106 | let tag = tag ?? defaultTag 107 | return db[tag]?.contains(url) ?? false 108 | } 109 | 110 | // 判断是否所有文件被某标签标记 111 | static func isAllTagged(tag:String? = nil, urls: [URL]) -> Bool{ 112 | let tag = tag ?? defaultTag 113 | for url in urls { 114 | if !isTagged(tag: tag, url: url) { 115 | return false 116 | } 117 | } 118 | return true 119 | } 120 | 121 | // 获取文件的所有标签 122 | static func getFileTags(url: URL) -> [String] { 123 | var tags: [String] = [] 124 | for (tag, urls) in db { 125 | if urls.contains(url) { 126 | tags.append(tag) 127 | } 128 | } 129 | return tags.sorted() // 对标签列表进行排序 130 | } 131 | 132 | // 获取所有标签 133 | static func getAllTags() -> [String] { 134 | let tags = Array(db.keys).sorted() // 对标签列表进行排序 135 | return tags 136 | } 137 | 138 | static func getAvailableTags() -> [String] { 139 | let tags = ["⭐", "🔥", "💎", "♥️", "🟢"] 140 | return tags 141 | } 142 | 143 | // 初始化时加载数据 144 | static func initialize() { 145 | loadFromFile() 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /FlowVision/Sources/Views/DrawingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DrawingView.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/7/5. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | 11 | class DrawingView: NSView { 12 | private var path: NSBezierPath? 13 | var lineColor: NSColor = NSColor.controlAccentColor // 默认线条颜色 14 | var lineWidth: CGFloat = 4.0 // 默认线条宽度 15 | var directionLabel: NSTextField! // 方向提示文本字段 16 | var statusLabel: NSTextField! // 状态提示文本字段 17 | var containerView: NSView! // 容器视图 18 | 19 | override init(frame frameRect: NSRect) { 20 | super.init(frame: frameRect) 21 | setupContainerView() 22 | setupLabel() 23 | } 24 | 25 | required init?(coder: NSCoder) { 26 | super.init(coder: coder) 27 | setupContainerView() 28 | setupLabel() 29 | } 30 | 31 | override func layout() { 32 | super.layout() 33 | let labelWidth: CGFloat = 240 34 | let containerWidth: CGFloat = labelWidth 35 | let containerHeight: CGFloat = 70 // 根据两个文本字段的高度调整容器高度 36 | let centerX = (self.bounds.width - containerWidth) / 2 37 | let centerY = (self.bounds.height - containerHeight) / 2 38 | 39 | // 设置容器视图的框架 40 | containerView.frame = CGRect(x: centerX, y: centerY, width: containerWidth, height: containerHeight) 41 | 42 | // 方向提示文本字段居中且位于视图中央 43 | directionLabel.frame = CGRect(x: 0, y: containerHeight - 40, width: labelWidth, height: 37) 44 | // 状态提示文本字段居中且位于方向提示文本字段下方 45 | statusLabel.frame = CGRect(x: 0, y: 0, width: labelWidth, height: 28) 46 | } 47 | 48 | private func setupContainerView() { 49 | containerView = NSView() 50 | containerView.wantsLayer = true 51 | containerView.isHidden = true 52 | containerView.alphaValue = 0 53 | containerView.layer?.backgroundColor = hexToNSColor(hex: "#000000",alpha: 0.45).cgColor 54 | containerView.layer?.cornerRadius = 6.0 55 | containerView.layer?.masksToBounds = true 56 | 57 | addSubview(containerView) 58 | } 59 | 60 | private func setupLabel() { 61 | directionLabel = NSTextField(frame: CGRect(x: 0, y: 0, width: self.bounds.width, height: 30)) 62 | directionLabel.stringValue = "" 63 | directionLabel.backgroundColor = hexToNSColor(alpha: 0.0) 64 | directionLabel.isBordered = false 65 | directionLabel.isEditable = false 66 | directionLabel.alignment = .center 67 | directionLabel.font = NSFont.systemFont(ofSize: 29, weight: .regular) 68 | directionLabel.textColor = hexToNSColor(hex: "#FFFFFF",alpha: 0.9) 69 | //directionLabel.isHidden = true 70 | directionLabel.wantsLayer = true 71 | containerView.addSubview(directionLabel) 72 | 73 | statusLabel = NSTextField(frame: CGRect(x: 0, y: 0, width: self.bounds.width, height: 20)) 74 | statusLabel.stringValue = "" 75 | statusLabel.backgroundColor = hexToNSColor(alpha: 0.0) 76 | statusLabel.isBordered = false 77 | statusLabel.isEditable = false 78 | statusLabel.alignment = .center 79 | statusLabel.font = NSFont.systemFont(ofSize: 18, weight: .regular) 80 | statusLabel.textColor = hexToNSColor(hex: "#FFFFFF",alpha: 0.9) 81 | //statusLabel.isHidden = true 82 | statusLabel.wantsLayer = true 83 | containerView.addSubview(statusLabel) 84 | } 85 | 86 | override func hitTest(_ point: NSPoint) -> NSView? { 87 | let hitView = super.hitTest(point) 88 | if hitView == self { 89 | // 如果点击的是DrawingView,但不需要处理事件,则返回nil,让事件传递到下面的视图 90 | return nil 91 | } 92 | return hitView 93 | } 94 | 95 | func _rightMouseDown(with event: NSEvent) { 96 | path = NSBezierPath() // 开始一个新的绘图路径 97 | path?.lineWidth = lineWidth 98 | let location = convert(event.locationInWindow, from: nil) 99 | path?.move(to: location) 100 | super.rightMouseDown(with: event) // 继续传递事件 101 | } 102 | 103 | func _rightMouseDragged(with event: NSEvent) { 104 | guard let path = path else { return } 105 | let location = convert(event.locationInWindow, from: nil) 106 | path.line(to: location) 107 | needsDisplay = true 108 | super.rightMouseDragged(with: event) // 继续传递事件 109 | } 110 | 111 | func _rightMouseUp(with event: NSEvent) { 112 | path = nil // 清除路径 113 | needsDisplay = true // 需要重新绘制,以清除视图 114 | super.rightMouseUp(with: event) // 继续传递事件 115 | } 116 | 117 | override func draw(_ dirtyRect: NSRect) { 118 | super.draw(dirtyRect) 119 | 120 | lineColor.setStroke() 121 | path?.stroke() // 只绘制当前的路径 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /FlowVision/Sources/Views/CustomCollectionViewManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageCollectionViewManager.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/3/24. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | 11 | class CustomCollectionViewManager: NSObject, NSCollectionViewDataSource, NSCollectionViewDelegate, NSCollectionViewDelegateFlowLayout { 12 | 13 | var fileDB: DatabaseModel 14 | var lastSelectedIndexPath: IndexPath? 15 | 16 | init(fileDB: DatabaseModel) { 17 | self.fileDB = fileDB 18 | } 19 | 20 | func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int { 21 | fileDB.lock() 22 | defer{fileDB.unlock()} 23 | if let db=fileDB.db[SortKeyDir(fileDB.curFolder)] { 24 | return min(db.layoutCalcPos,db.files.count) 25 | } 26 | return 0 27 | } 28 | 29 | func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem { 30 | let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "CustomCollectionViewItem"), for: indexPath) as! CustomCollectionViewItem 31 | 32 | fileDB.lock() 33 | if let file=fileDB.db[SortKeyDir(fileDB.curFolder)]?.files.elementSafe(atOffset: indexPath.item)?.1{ 34 | item.configureWithImage(file) 35 | } 36 | fileDB.unlock() 37 | 38 | return item 39 | } 40 | 41 | func collectionView(_ collectionView: NSCollectionView, didEndDisplaying item: NSCollectionViewItem, forRepresentedObjectAt indexPath: IndexPath) { 42 | // (item as! ImageCollectionViewItem).imageViewObj?.image?.recache() 43 | // (item as! ImageCollectionViewItem).imageViewObj?.image=nil 44 | } 45 | 46 | func collectionView(_ collectionView: NSCollectionView, didSelectItemsAt indexPaths: Set) { 47 | for indexPath in indexPaths{ 48 | //注意:下面这句当item不在视野内时为nil 49 | //let item = collectionView.item(at: indexPath) as? ImageCollectionViewItem 50 | // fileDB.lock() 51 | // if let file=fileDB.db[SortKeyDir(fileDB.curFolder)]?.files.elementSafe(atOffset: indexPath.item)?.1{ 52 | // log("Select:",String(indexPath.item),file.path) 53 | // getViewController(collectionView)!.publicVar.selectedUrls2.append(URL(string: file.path)!) 54 | // } 55 | // fileDB.unlock() 56 | } 57 | //log("Selected numbers:"+String(indexPaths.count)) 58 | } 59 | 60 | func collectionView(_ collectionView: NSCollectionView, didDeselectItemsAt indexPaths: Set) { 61 | for indexPath in indexPaths { 62 | //注意:下面这句当item不在视野内时为nil 63 | //let item = collectionView.item(at: indexPath) as? ImageCollectionViewItem 64 | // fileDB.lock() 65 | // if let file=fileDB.db[SortKeyDir(fileDB.curFolder)]?.files.elementSafe(atOffset: indexPath.item)?.1{ 66 | // log("Deselect:",String(indexPath.item),file.path) 67 | // if let index=getViewController(collectionView)!.publicVar.selectedUrls2.firstIndex(of: URL(string: file.path)!){ 68 | // getViewController(collectionView)!.publicVar.selectedUrls2.remove(at: index) 69 | // } 70 | // } 71 | // fileDB.unlock() 72 | } 73 | //log("Deselected numbers:"+String(indexPaths.count)) 74 | } 75 | func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> NSSize { 76 | fileDB.lock() 77 | defer{fileDB.unlock()} 78 | if let thumbSize=fileDB.db[SortKeyDir(fileDB.curFolder)]?.files.elementSafe(atOffset: indexPath.item)?.1.thumbSize{ 79 | return thumbSize 80 | } 81 | return DEFAULT_SIZE 82 | } 83 | 84 | func collectionView(_ collectionView: NSCollectionView, pasteboardWriterForItemAt indexPath: IndexPath) -> NSPasteboardWriting? { 85 | fileDB.lock() 86 | defer{fileDB.unlock()} 87 | let pasteboardItem = NSPasteboardItem() 88 | if let path = fileDB.db[SortKeyDir(fileDB.curFolder)]?.files.elementSafe(atOffset: indexPath.item)?.1.path, 89 | let url = URL(string: path){ 90 | pasteboardItem.setString(url.absoluteString, forType: .fileURL) 91 | } 92 | return pasteboardItem 93 | } 94 | 95 | func collectionView(_ collectionView: NSCollectionView, shouldSelectItemsAt indexPaths: Set) -> Set { 96 | guard let indexPath = indexPaths.first else { return [] } 97 | 98 | // Check if the Shift key is pressed or no selection 99 | if NSEvent.modifierFlags.contains(.shift), let lastIndexPath = lastSelectedIndexPath, collectionView.selectionIndexPaths.count >= 1 { 100 | // Calculate the range of items to select 101 | let startIndex = min(lastIndexPath.item, indexPath.item) 102 | let endIndex = max(lastIndexPath.item, indexPath.item) 103 | let indexSet = IndexSet(startIndex...endIndex) 104 | 105 | // Create new index paths for the range 106 | let newSelectedIndexPaths = indexSet.map { IndexPath(item: $0, section: indexPath.section) } 107 | return Set(newSelectedIndexPaths) 108 | } else { 109 | // Update the last selected index path for non-shift selection 110 | lastSelectedIndexPath = indexPath 111 | return indexPaths 112 | } 113 | } 114 | 115 | func collectionView(_ collectionView: NSCollectionView, shouldDeselectItemsAt indexPaths: Set) -> Set { 116 | guard let indexPath = indexPaths.first else { return [] } 117 | 118 | // TODO 119 | 120 | return indexPaths 121 | } 122 | 123 | } 124 | 125 | -------------------------------------------------------------------------------- /FlowVision/Sources/Views/CustomCollectionViewItem.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /FlowVision/Sources/Common/GlobalVariable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Global.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/7/5. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | 11 | //let DEFAULT_SIZE = 512 12 | let DEFAULT_SIZE = NSSize(width: 512, height: 512) 13 | let OPEN_LARGEIMAGE_DURATION = 0.1 14 | //let THUMB_SIZES = [192,256,384,512,640,768,896,1024,1536,2048,4096] 15 | var THUMB_SIZES = [Int]() 16 | let PRELOAD_THUMB_RANGE_PRE = 20 17 | let PRELOAD_THUMB_RANGE_NEXT = 40 18 | let RESET_VIEW_FILE_NUM_THRESHOLD = 5000 19 | let INFO_VIEW_DURATION = 0.3 20 | 21 | let OFFICIAL_WEBSITE = "https://flowvision.app" 22 | 23 | let ROOT_NAME = getSystemVolumeName() ?? "Macintosh HD" 24 | 25 | let COLOR_COLLECTIONVIEW_BG_LIGHT = "#FFFFFF" 26 | let COLOR_COLLECTIONVIEW_BG_DARK = "#2D2D2D" 27 | 28 | class GlobalVar{ 29 | var myFavoritesArray = ["/"] 30 | var WINDOW_LIMIT=16 31 | var windowNum=0 32 | var toolbarIndex = 0 33 | var operationLogs: [String] = [] 34 | var closedPaths: [String] = [] 35 | 36 | //TODO: 临时公用状态变量 37 | var isLaunchFromFile = false 38 | var startSpeedUpImageSizeCache: NSSize? = nil 39 | var useCreateWindowShowDelay = false 40 | 41 | //实时状态变量 42 | var isInMiddleMouseDrag = false 43 | 44 | //“设置”中按钮,用于同步状态 45 | weak var useInternalPlayerCheckbox: NSButton? 46 | 47 | //“设置”中的变量 48 | var terminateAfterLastWindowClosed = true 49 | var autoHideToolbar = false 50 | var doNotUseFFmpeg = false 51 | var memUseLimit: Int = 4000 52 | var thumbThreadNum: Int = 8 53 | var folderSearchDepth: Int = 4 54 | var thumbThreadNum_External: Int = 1 55 | var folderSearchDepth_External: Int = 0 56 | var randomFolderThumb = false 57 | var loopBrowsing = false 58 | var blackBgInFullScreen = false 59 | var blackBgInFullScreenForVideo = false 60 | var blackBgAlways = false 61 | var blackBgAlwaysForVideo = true 62 | var thumbnailExcludeList: [String] = [] 63 | var usePinyinSearch = false 64 | var usePinyinInitialSearch = false 65 | var videoPlayRememberPosition = false 66 | var useInternalPlayer = true { 67 | didSet { 68 | useInternalPlayerCheckbox?.state = useInternalPlayer ? .on : .off 69 | } 70 | } 71 | var useQuickSearch = false 72 | var isEnterKeyToOpen = false 73 | var clickEdgeToSwitchImage = false 74 | var scrollMouseWheelToZoom = false 75 | var openLastFolder = true 76 | var homeFolder = "file:///" 77 | 78 | //可记忆设置变量 79 | var isFirstTimeUse = true 80 | var portableMode = false 81 | var portableImageUseActualSize = false 82 | var portableImageWidthRatio = 0.8 83 | var portableImageHeightRatio = 0.95 84 | var portableListWidthRatio = 0.7 85 | var portableListHeightRatio = 0.84 86 | var portableListWidthRatioHH = 0.82 87 | var portableListHeightRatioHH = 0.84 88 | 89 | var HandledImageExtensions: [String] = [] 90 | var HandledRawExtensions: [String] = [] 91 | var HandledImageAndRawExtensions: [String] = [] 92 | var HandledVideoExtensions: [String] = [] 93 | var HandledOtherExtensions: [String] = [] 94 | var HandledNonExternalExtensions: [String] = [] 95 | var HandledNativeSupportedVideoExtensions: [String] = [] 96 | var HandledNotNativeSupportedVideoExtensions: [String] = [] 97 | var HandledFileExtensions: [String] = [] 98 | var HandledSearchExtensions: [String] = [] 99 | var HandledFolderThumbExtensions: [String] = [] 100 | 101 | init(){ 102 | HandledImageExtensions = ["jpg", "jpeg", "jxl", "png", "gif", "bmp", "heif", "heic", "hif", "avif", "tif", "tiff", "webp", "jfif", "jp2", "ai", "psd", "ico", "icns", "svg", "tga"] 103 | HandledRawExtensions = ["crw", "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2", "rw2", "orf", "raf", "pef", "dng", "raw", "rwl", "x3f", "3fr", "fff", "iiq", "mos", "dcr", "erf", "mrw", "gpr", "srw"] 104 | HandledImageAndRawExtensions = HandledImageExtensions + HandledRawExtensions 105 | HandledNativeSupportedVideoExtensions = ["mp4", "mov", "m2ts", "ts", "mpeg", "mpg", "m4v", "vob"] 106 | HandledNotNativeSupportedVideoExtensions = ["mkv", "mts", "avi", "flv", "f4v", "asf", "wmv", "rmvb", "rm", "webm", "divx", "xvid", "3gp", "3g2"] 107 | HandledVideoExtensions = HandledNativeSupportedVideoExtensions + HandledNotNativeSupportedVideoExtensions 108 | HandledOtherExtensions = [] //["pdf"] //不能为"",否则会把目录异常包含进来 109 | HandledNonExternalExtensions = HandledImageAndRawExtensions 110 | HandledFileExtensions = HandledImageAndRawExtensions + HandledVideoExtensions + HandledOtherExtensions //文件列表显示的 111 | HandledSearchExtensions = HandledImageAndRawExtensions + HandledVideoExtensions //作为鼠标手势查找的目标 112 | HandledFolderThumbExtensions = HandledImageAndRawExtensions.filter{$0 != "svg"} + HandledVideoExtensions // + ["pdf"] //目录缩略图 113 | //使用个别特殊svg作为文件夹缩略图绘图元素会导致程序异常 'NSGenericException', reason: 'NaN point value' 114 | } 115 | } 116 | var globalVar = GlobalVar() 117 | 118 | let homeDirectory = NSHomeDirectory() 119 | 120 | func isWindowNumMax() -> Bool{ 121 | return globalVar.windowNum >= globalVar.WINDOW_LIMIT 122 | } 123 | 124 | func getMainViewController() -> ViewController? { 125 | if let viewController = NSApplication.shared.mainWindow?.contentViewController as? ViewController { 126 | return viewController 127 | } 128 | return nil 129 | } 130 | 131 | func getViewController(_ selfView: NSView) -> ViewController? { 132 | var responder: NSResponder? = selfView 133 | while responder != nil { 134 | if let viewController = responder as? ViewController { 135 | return viewController 136 | } 137 | responder = responder?.nextResponder 138 | } 139 | return nil 140 | } 141 | 142 | func getSystemVolumeName() -> String? { 143 | let fileManager = FileManager.default 144 | 145 | // 获取根目录的URL 146 | let rootURL = URL(fileURLWithPath: "/") 147 | 148 | do { 149 | // 获取根目录的资源值,特别是卷名 150 | let resourceValues = try rootURL.resourceValues(forKeys: [.volumeNameKey]) 151 | return resourceValues.volumeName 152 | } catch { 153 | print("Error retrieving volume name: \(error)") 154 | return nil 155 | } 156 | } 157 | 158 | -------------------------------------------------------------------------------- /FlowVision/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDocumentTypes 6 | 7 | 8 | CFBundleTypeName 9 | JPEG image 10 | CFBundleTypeRole 11 | Viewer 12 | LSHandlerRank 13 | Default 14 | LSItemContentTypes 15 | 16 | public.jpeg 17 | 18 | 19 | 20 | CFBundleTypeName 21 | PNG image 22 | CFBundleTypeRole 23 | Viewer 24 | LSHandlerRank 25 | Default 26 | LSItemContentTypes 27 | 28 | public.png 29 | 30 | 31 | 32 | CFBundleTypeName 33 | GIF image 34 | CFBundleTypeRole 35 | Viewer 36 | LSHandlerRank 37 | Default 38 | LSItemContentTypes 39 | 40 | public.gif 41 | 42 | 43 | 44 | CFBundleTypeName 45 | BMP image 46 | CFBundleTypeRole 47 | Viewer 48 | LSHandlerRank 49 | Default 50 | LSItemContentTypes 51 | 52 | com.microsoft.bmp 53 | 54 | 55 | 56 | CFBundleTypeName 57 | TIFF image 58 | CFBundleTypeRole 59 | Viewer 60 | LSHandlerRank 61 | Default 62 | LSItemContentTypes 63 | 64 | public.tiff 65 | 66 | 67 | 68 | CFBundleTypeName 69 | HEIF image 70 | CFBundleTypeRole 71 | Viewer 72 | LSHandlerRank 73 | Default 74 | LSItemContentTypes 75 | 76 | public.heif 77 | 78 | 79 | 80 | CFBundleTypeName 81 | WebP image 82 | CFBundleTypeRole 83 | Viewer 84 | LSHandlerRank 85 | Default 86 | LSItemContentTypes 87 | 88 | org.webmproject.webp 89 | 90 | 91 | 92 | CFBundleTypeName 93 | Image 94 | CFBundleTypeRole 95 | Viewer 96 | LSHandlerRank 97 | Default 98 | LSItemContentTypes 99 | 100 | public.image 101 | 102 | 103 | 104 | CFBundleTypeName 105 | HEIC image 106 | CFBundleTypeRole 107 | Viewer 108 | LSHandlerRank 109 | Default 110 | LSItemContentTypes 111 | 112 | public.heic 113 | 114 | 115 | 116 | CFBundleTypeName 117 | Jpeg 2000 Image 118 | CFBundleTypeRole 119 | Viewer 120 | LSHandlerRank 121 | Default 122 | LSItemContentTypes 123 | 124 | public.jpeg-2000 125 | 126 | 127 | 128 | CFBundleTypeName 129 | Folder 130 | CFBundleTypeRole 131 | Viewer 132 | LSHandlerRank 133 | Default 134 | LSItemContentTypes 135 | 136 | public.folder 137 | 138 | 139 | 140 | CFBundleTypeName 141 | JFIF image 142 | CFBundleTypeRole 143 | Viewer 144 | LSHandlerRank 145 | Default 146 | LSItemContentTypes 147 | 148 | netdcy.flowvision.jfif 149 | 150 | 151 | 152 | CFBundleTypeName 153 | MP4 Video 154 | CFBundleTypeRole 155 | Viewer 156 | LSHandlerRank 157 | Default 158 | LSItemContentTypes 159 | 160 | public.mpeg-4 161 | 162 | 163 | 164 | CFBundleTypeName 165 | MOV Video 166 | CFBundleTypeRole 167 | Viewer 168 | LSHandlerRank 169 | Default 170 | LSItemContentTypes 171 | 172 | com.apple.quicktime-movie 173 | 174 | 175 | 176 | CFBundleTypeName 177 | M4V Video 178 | CFBundleTypeRole 179 | Viewer 180 | LSHandlerRank 181 | Default 182 | LSItemContentTypes 183 | 184 | com.apple.m4v-video 185 | 186 | 187 | 188 | CFBundleTypeName 189 | MPEG Video 190 | CFBundleTypeRole 191 | Viewer 192 | LSHandlerRank 193 | Default 194 | LSItemContentTypes 195 | 196 | public.mpeg 197 | 198 | 199 | 200 | CFBundleTypeName 201 | MPEG2-TS Video 202 | CFBundleTypeRole 203 | Viewer 204 | LSHandlerRank 205 | Default 206 | LSItemContentTypes 207 | 208 | public.mpeg-2-transport-stream 209 | 210 | 211 | 212 | CFBundleTypeName 213 | VOB Video 214 | CFBundleTypeRole 215 | Viewer 216 | LSHandlerRank 217 | Default 218 | LSItemContentTypes 219 | 220 | org.videolan.vob 221 | 222 | 223 | 224 | UTImportedTypeDeclarations 225 | 226 | 227 | UTTypeConformsTo 228 | 229 | public.image 230 | 231 | UTTypeDescription 232 | JFIF image 233 | UTTypeIcons 234 | 235 | UTTypeIdentifier 236 | netdcy.flowvision.jfif 237 | UTTypeTagSpecification 238 | 239 | public.filename-extension 240 | 241 | jfif 242 | 243 | 244 | 245 | 246 | 247 | 248 | -------------------------------------------------------------------------------- /FlowVision/Sources/Views/CustomImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomImageView.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/6/4. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | 11 | class CustomImageView: NSImageView { 12 | 13 | var isFolder = false 14 | var url: URL? = nil 15 | 16 | override init(frame frameRect: NSRect) { 17 | super.init(frame: frameRect) 18 | registerForDraggedTypes([NSPasteboard.PasteboardType.fileURL]) 19 | } 20 | 21 | required init?(coder: NSCoder) { 22 | super.init(coder: coder) 23 | registerForDraggedTypes([NSPasteboard.PasteboardType.fileURL]) 24 | } 25 | 26 | override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { 27 | if let viewController = getViewController(self){ 28 | if viewController.publicVar.isInLargeView { 29 | return .link 30 | }else if isFolder{ 31 | return .copy 32 | } 33 | } 34 | return .every 35 | } 36 | 37 | override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { 38 | defer { 39 | sender.draggingPasteboard.clearContents() 40 | } 41 | 42 | if let viewController = getViewController(self){ 43 | if viewController.publicVar.isInLargeView { 44 | let pasteboard = sender.draggingPasteboard 45 | if let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] { 46 | getViewController(self)?.handleDraggedFiles(urls) 47 | return true 48 | } 49 | }else if isFolder{ 50 | getViewController(self)?.handleMove(targetURL: url, pasteboard: sender.draggingPasteboard) 51 | if sender.draggingSource is CustomOutlineView { 52 | viewController.refreshTreeView() 53 | } 54 | if !(sender.draggingSource is CustomCollectionView) { 55 | viewController.refreshAll(needLoadThumbPriority: true) 56 | } 57 | return true 58 | }else{ 59 | if sender.draggingSource is CustomCollectionView { 60 | return false 61 | } 62 | if let curFolderUrl = URL(string: viewController.fileDB.curFolder){ 63 | viewController.handleMove(targetURL: curFolderUrl, pasteboard: sender.draggingPasteboard) 64 | if sender.draggingSource is CustomOutlineView { 65 | viewController.refreshTreeView() 66 | } 67 | return true 68 | } 69 | } 70 | } 71 | return false 72 | } 73 | 74 | var center: CGPoint { 75 | get { 76 | return CGPoint(x: frame.midX, y: frame.midY) 77 | } 78 | set(newCenter) { 79 | var newFrame = frame 80 | newFrame.origin.x = newCenter.x - (newFrame.size.width / 2) 81 | newFrame.origin.y = newCenter.y - (newFrame.size.height / 2) 82 | frame = newFrame 83 | } 84 | } 85 | } 86 | 87 | class BorderedImageView: IntegerImageView { 88 | 89 | var isDrawBorder=false 90 | 91 | //发现会导致加载速度变慢,因此暂时不使用 92 | // override func draw(_ dirtyRect: NSRect) { 93 | // super.draw(dirtyRect) 94 | // 95 | // if isDrawBorder { 96 | // drawBorder(dirtyRect) 97 | // } 98 | // } 99 | 100 | func drawBorder(_ dirtyRect: NSRect) { 101 | // 确保图像存在 102 | guard let image = self.image else { 103 | return 104 | } 105 | 106 | // 设置边框颜色和宽度 107 | let borderColor = NSColor.gray 108 | let borderWidth: CGFloat = 2.0 109 | 110 | // 计算图像在视图中的绘制区域,考虑边框宽度 111 | let imageSize = image.size 112 | let viewSize = self.bounds.size 113 | let imageAspect = imageSize.width / imageSize.height 114 | let viewAspect = viewSize.width / viewSize.height 115 | 116 | var drawRect = NSRect.zero 117 | 118 | if imageAspect > viewAspect { 119 | drawRect.size.width = viewSize.width - 2 * borderWidth 120 | drawRect.size.height = drawRect.size.width / imageAspect 121 | drawRect.origin.y = (viewSize.height - drawRect.size.height) / 2 122 | drawRect.origin.x = borderWidth 123 | } else { 124 | drawRect.size.height = viewSize.height - 2 * borderWidth 125 | drawRect.size.width = drawRect.size.height * imageAspect 126 | drawRect.origin.x = (viewSize.width - drawRect.size.width) / 2 127 | drawRect.origin.y = borderWidth 128 | } 129 | 130 | // 平移绘制区域以确保边框不会被裁剪 131 | drawRect = drawRect.insetBy(dx: -borderWidth / 2, dy: -borderWidth / 2) 132 | 133 | // 绘制图像 134 | //image.draw(in: drawRect) 135 | 136 | // 绘制边框 137 | borderColor.set() 138 | let borderPath = NSBezierPath(rect: drawRect) 139 | borderPath.lineWidth = borderWidth 140 | borderPath.stroke() 141 | } 142 | 143 | } 144 | 145 | class InterpolatedImageView: CustomImageView { 146 | //对于小图此方法可以提高质量 147 | //但只要override,即使不设置插值方法,也会导致巨大图像例如清明上河图100%显示时不够清晰,奇怪 148 | //因此暂时不使用 149 | // override func draw(_ dirtyRect: NSRect) { 150 | // NSGraphicsContext.current!.imageInterpolation = NSImageInterpolation.high 151 | // super.draw(dirtyRect) 152 | // } 153 | } 154 | 155 | class IntegerImageView: CustomImageView { 156 | // Use floating-point numbers to store the precise position and size 157 | private var internalOrigin: CGPoint = .zero 158 | private var internalSize: CGSize = .zero 159 | 160 | func getIntFrame () -> NSRect { 161 | return super.frame 162 | } 163 | 164 | // Override frame property 165 | override var frame: NSRect { 166 | get { 167 | // Return the frame with precise origin and size 168 | return NSRect(origin: internalOrigin, size: internalSize) 169 | } 170 | set { 171 | // Update internal floating-point origin and size 172 | internalOrigin = newValue.origin 173 | internalSize = newValue.size 174 | 175 | // Calculate rounded origin and size 176 | let newRoundedOrigin = CGPoint(x: round(newValue.origin.x), y: round(newValue.origin.y)) 177 | let newRoundedSize = CGSize(width: round(newValue.size.width), height: round(newValue.size.height)) 178 | 179 | // Only update the frame if it actually needs to change 180 | if super.frame.origin != newRoundedOrigin || super.frame.size != newRoundedSize { 181 | super.frame = NSRect(origin: newRoundedOrigin, size: newRoundedSize) 182 | } 183 | } 184 | } 185 | } 186 | 187 | class CustomThumbImageView: BorderedImageView { 188 | 189 | } 190 | 191 | class CustomLargeImageView: IntegerImageView { 192 | 193 | } 194 | -------------------------------------------------------------------------------- /FlowVision/Sources/Common/Log.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Log.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/7/8. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | 11 | func log(_ items: Any..., separator: String = " ", terminator: String = "\n", level: LogLevel = .info, function: String = #function) { 12 | let message = items.map { "\($0)" }.joined(separator: separator) 13 | Logger.shared.log(message, level: level, function: function) 14 | } 15 | 16 | class LogViewController: NSViewController { 17 | 18 | @IBOutlet weak var logTextView: NSTextView! 19 | @IBOutlet weak var debugCheckBox: NSButton! 20 | @IBOutlet weak var infoCheckBox: NSButton! 21 | @IBOutlet weak var warnCheckBox: NSButton! 22 | @IBOutlet weak var errorCheckBox: NSButton! 23 | 24 | var logMessages: [(String, LogLevel)] = [] 25 | 26 | override func viewDidLoad() { 27 | super.viewDidLoad() 28 | // Additional setup if needed 29 | } 30 | 31 | func addLogMessage(_ message: String, level: LogLevel) { 32 | if logMessages.count > 1000 { 33 | logMessages.removeFirst() 34 | } 35 | logMessages.append((message, level)) 36 | if let window = self.view.window, window.isVisible { 37 | refreshLogView() 38 | } 39 | } 40 | 41 | func refreshLogView() { 42 | var filteredMessages = logMessages 43 | 44 | if debugCheckBox.state == .off { 45 | filteredMessages = filteredMessages.filter { $0.1 != .debug } 46 | } 47 | if infoCheckBox.state == .off { 48 | filteredMessages = filteredMessages.filter { $0.1 != .info } 49 | } 50 | if warnCheckBox.state == .off { 51 | filteredMessages = filteredMessages.filter { $0.1 != .warn } 52 | } 53 | if errorCheckBox.state == .off { 54 | filteredMessages = filteredMessages.filter { $0.1 != .error } 55 | } 56 | 57 | logTextView.string = filteredMessages.map { $0.0 }.joined(separator: "\n") 58 | 59 | // Scroll to bottom 60 | let range = NSRange(location: logTextView.string.count, length: 0) 61 | logTextView.scrollRangeToVisible(range) 62 | } 63 | 64 | @IBAction func checkBoxChanged(_ sender: NSButton) { 65 | refreshLogView() 66 | } 67 | } 68 | 69 | class LogWindowController: NSWindowController, NSWindowDelegate { 70 | 71 | var logViewController: LogViewController? { 72 | return contentViewController as? LogViewController 73 | } 74 | 75 | override var windowNibName: NSNib.Name? { 76 | return NSNib.Name("LogWindowController") 77 | } 78 | 79 | override func windowDidLoad() { 80 | super.windowDidLoad() 81 | window?.delegate = self 82 | } 83 | 84 | func addLogMessage(_ message: String, level: LogLevel) { 85 | logViewController?.addLogMessage(message, level: level) 86 | } 87 | } 88 | 89 | enum LogLevel: Int { 90 | case debug = 0 91 | case info 92 | case warn 93 | case error 94 | 95 | var description: String { 96 | switch self { 97 | case .debug: return "DEBUG" 98 | case .info: return "INFO" 99 | case .warn: return "WARN" 100 | case .error: return "ERROR" 101 | } 102 | } 103 | } 104 | 105 | class Logger { 106 | static let shared = Logger() 107 | 108 | private var logFileURL: URL? 109 | private var logWindowController: LogWindowController? 110 | private let logQueue = DispatchQueue(label: "com.example.LoggerQueue") 111 | var logLevel: LogLevel = .debug 112 | var isFileLoggingEnabled = false 113 | 114 | private init() { 115 | setupLogFile() 116 | setupLogWindow() 117 | } 118 | 119 | private func setupLogFile() { 120 | let fileManager = FileManager.default 121 | do { 122 | let appSupportDirectory = try fileManager.url( 123 | for: .applicationSupportDirectory, 124 | in: .userDomainMask, 125 | appropriateFor: nil, 126 | create: true 127 | ) 128 | 129 | let appDirectory = appSupportDirectory.appendingPathComponent("FlowVision", isDirectory: true) 130 | if !fileManager.fileExists(atPath: appDirectory.path) { 131 | try fileManager.createDirectory(at: appDirectory, withIntermediateDirectories: true, attributes: nil) 132 | } 133 | 134 | let logFileName = "FlowVision.log" 135 | logFileURL = appDirectory.appendingPathComponent(logFileName) 136 | 137 | } catch { 138 | let logFileName = ".FlowVision.log" 139 | let userDirectory = fileManager.homeDirectoryForCurrentUser 140 | logFileURL = userDirectory.appendingPathComponent(logFileName) 141 | } 142 | } 143 | 144 | private func setupLogWindow() { 145 | let storyboard = NSStoryboard(name: "Main", bundle: nil) 146 | logWindowController = storyboard.instantiateController(withIdentifier: "LogWindowController") as? LogWindowController 147 | } 148 | 149 | func showLogWindow() { 150 | logWindowController?.logViewController?.refreshLogView() 151 | logWindowController?.showWindow(nil) 152 | } 153 | 154 | func log(_ message: String, level: LogLevel = .info, function: String = #function) { 155 | guard level.rawValue >= logLevel.rawValue else { return } 156 | 157 | let timestamp = Logger.timestamp() 158 | let logMessage = "\(timestamp) [\(level.description)] [\(function)] \(message)" 159 | 160 | logQueue.async(flags: .barrier) { // Use barrier to ensure thread safety 161 | DispatchQueue.main.async { 162 | self.logWindowController?.addLogMessage(logMessage, level: level) 163 | } 164 | 165 | if self.isFileLoggingEnabled, let logFileURL = self.logFileURL { 166 | do { 167 | let fileHandle = try FileHandle(forWritingTo: logFileURL) 168 | fileHandle.seekToEndOfFile() 169 | if let data = (logMessage + "\n").data(using: .utf8) { 170 | fileHandle.write(data) 171 | fileHandle.closeFile() 172 | } 173 | } catch { 174 | try? logMessage.write(to: logFileURL, atomically: true, encoding: .utf8) 175 | } 176 | } 177 | } 178 | } 179 | 180 | func clearLogFile() { 181 | logQueue.sync(flags: .barrier) { // Use barrier to ensure thread safety 182 | if let logFileURL = self.logFileURL { 183 | do { 184 | let fileHandle = try FileHandle(forWritingTo: logFileURL) 185 | fileHandle.truncateFile(atOffset: 0) // Clear the file 186 | fileHandle.closeFile() 187 | } catch { 188 | print("Failed to clear log file: \(error)") 189 | } 190 | } 191 | } 192 | } 193 | 194 | private static func timestamp() -> String { 195 | let dateFormatter = DateFormatter() 196 | dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss,SSS" 197 | return dateFormatter.string(from: Date()) 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /FlowVision/Sources/SettingsViews/AdvancedSettingsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneralSettingsViewController.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/7/22. 6 | // 7 | 8 | import Cocoa 9 | import Settings 10 | 11 | final class AdvancedSettingsViewController: NSViewController, SettingsPane { 12 | let paneIdentifier = Settings.PaneIdentifier.advanced 13 | let paneTitle = NSLocalizedString("Advanced", comment: "高级") 14 | let toolbarItemIcon = NSImage(systemSymbolName: "gearshape.2", accessibilityDescription: "")! 15 | 16 | override var nibName: NSNib.Name? { "AdvancedSettingsViewController" } 17 | 18 | @IBOutlet weak var memUseLimitSlider: NSSlider! 19 | @IBOutlet weak var memUseLimitLabel: NSTextField! 20 | 21 | @IBOutlet weak var thumbThreadNumStepper: NSStepper! 22 | @IBOutlet weak var thumbThreadNumLabel: NSTextField! 23 | 24 | @IBOutlet weak var folderSearchDepthStepper: NSStepper! 25 | @IBOutlet weak var folderSearchDepthLabel: NSTextField! 26 | 27 | @IBOutlet weak var thumbThreadNumStepper_External: NSStepper! 28 | @IBOutlet weak var thumbThreadNumLabel_External: NSTextField! 29 | 30 | @IBOutlet weak var folderSearchDepthStepper_External: NSStepper! 31 | @IBOutlet weak var folderSearchDepthLabel_External: NSTextField! 32 | 33 | @IBOutlet weak var useFFmpegRadioButton: NSButton! 34 | @IBOutlet weak var doNotUseFFmpegRadioButton: NSButton! 35 | 36 | @IBOutlet weak var searchDepthWarningText: NSTextField! 37 | @IBOutlet weak var searchDepthWarningText_External: NSTextField! 38 | 39 | override func viewDidLoad() { 40 | super.viewDidLoad() 41 | 42 | // 初始化 slider、stepper 和标签 43 | memUseLimitSlider.integerValue = globalVar.memUseLimit 44 | updateMemUseLimitLabel(value: Double(globalVar.memUseLimit)) 45 | 46 | thumbThreadNumStepper.integerValue = globalVar.thumbThreadNum 47 | updateThumbThreadNumLabel(value: globalVar.thumbThreadNum) 48 | 49 | folderSearchDepthStepper.integerValue = globalVar.folderSearchDepth 50 | updateFolderSearchDepthLabel(value: globalVar.folderSearchDepth) 51 | 52 | thumbThreadNumStepper_External.integerValue = globalVar.thumbThreadNum_External 53 | updateThumbThreadNumLabel_External(value: globalVar.thumbThreadNum_External) 54 | 55 | folderSearchDepthStepper_External.integerValue = globalVar.folderSearchDepth_External 56 | updateFolderSearchDepthLabel_External(value: globalVar.folderSearchDepth_External) 57 | 58 | if folderSearchDepthStepper.integerValue == 0 { 59 | searchDepthWarningText.textColor = .systemRed 60 | } else { 61 | searchDepthWarningText.textColor = .systemGray 62 | } 63 | 64 | if folderSearchDepthStepper_External.integerValue == 0 { 65 | searchDepthWarningText_External.textColor = .systemRed 66 | } else { 67 | searchDepthWarningText_External.textColor = .systemGray 68 | } 69 | 70 | // 初始化 Radio Buttons 71 | updateFFmpegRadioButtons() 72 | } 73 | 74 | @IBAction func memUseLimitSliderChanged(_ sender: NSSlider) { 75 | let newValue = sender.integerValue 76 | globalVar.memUseLimit = newValue 77 | UserDefaults.standard.set(newValue, forKey: "memUseLimit") 78 | updateMemUseLimitLabel(value: Double(newValue)) 79 | } 80 | 81 | private func updateMemUseLimitLabel(value: Double) { 82 | // 将 slider 的值转换为合适的显示内容 83 | let formattedValue: String 84 | if value < 1000 { 85 | formattedValue = "\(Int(value)) MB" 86 | } else { 87 | formattedValue = String(format: "%.0f GB", value / 1000.0) 88 | } 89 | memUseLimitLabel.stringValue = formattedValue 90 | } 91 | 92 | @IBAction func thumbThreadNumStepperChanged(_ sender: NSStepper) { 93 | let newThumbThreadNum = sender.integerValue 94 | globalVar.thumbThreadNum = newThumbThreadNum 95 | UserDefaults.standard.set(newThumbThreadNum, forKey: "thumbThreadNum") 96 | updateThumbThreadNumLabel(value: newThumbThreadNum) 97 | } 98 | 99 | private func updateThumbThreadNumLabel(value: Int) { 100 | // 更新 thumbThreadNumLabel 的显示内容 101 | thumbThreadNumLabel.stringValue = "\(value)" 102 | } 103 | 104 | @IBAction func folderSearchDepthStepperChanged(_ sender: NSStepper) { 105 | let newFolderSearchDepth = sender.integerValue 106 | globalVar.folderSearchDepth = newFolderSearchDepth 107 | UserDefaults.standard.set(newFolderSearchDepth, forKey: "folderSearchDepth") 108 | updateFolderSearchDepthLabel(value: newFolderSearchDepth) 109 | 110 | if folderSearchDepthStepper.integerValue == 0 { 111 | searchDepthWarningText.textColor = .systemRed 112 | } else { 113 | searchDepthWarningText.textColor = .systemGray 114 | } 115 | } 116 | 117 | private func updateFolderSearchDepthLabel(value: Int) { 118 | // 更新 folderSearchDepthLabel 的显示内容 119 | folderSearchDepthLabel.stringValue = "\(value)" 120 | } 121 | 122 | @IBAction func thumbThreadNumStepperChanged_External(_ sender: NSStepper) { 123 | let newThumbThreadNum_External = sender.integerValue 124 | globalVar.thumbThreadNum_External = newThumbThreadNum_External 125 | UserDefaults.standard.set(newThumbThreadNum_External, forKey: "thumbThreadNum_External") 126 | updateThumbThreadNumLabel_External(value: newThumbThreadNum_External) 127 | } 128 | 129 | private func updateThumbThreadNumLabel_External(value: Int) { 130 | // 更新 thumbThreadNumLabel 的显示内容 131 | thumbThreadNumLabel_External.stringValue = "\(value)" 132 | } 133 | 134 | @IBAction func folderSearchDepthStepperChanged_External(_ sender: NSStepper) { 135 | let newFolderSearchDepth_External = sender.integerValue 136 | globalVar.folderSearchDepth_External = newFolderSearchDepth_External 137 | UserDefaults.standard.set(newFolderSearchDepth_External, forKey: "folderSearchDepth_External") 138 | updateFolderSearchDepthLabel_External(value: newFolderSearchDepth_External) 139 | 140 | if folderSearchDepthStepper_External.integerValue == 0 { 141 | searchDepthWarningText_External.textColor = .systemRed 142 | } else { 143 | searchDepthWarningText_External.textColor = .systemGray 144 | } 145 | } 146 | 147 | private func updateFolderSearchDepthLabel_External(value: Int) { 148 | // 更新 folderSearchDepthLabel 的显示内容 149 | folderSearchDepthLabel_External.stringValue = "\(value)" 150 | } 151 | 152 | @IBAction func ffmpegRadioButtonChanged(_ sender: NSButton) { 153 | if sender == useFFmpegRadioButton { 154 | globalVar.doNotUseFFmpeg = false 155 | } else if sender == doNotUseFFmpegRadioButton { 156 | globalVar.doNotUseFFmpeg = true 157 | } 158 | UserDefaults.standard.set(globalVar.doNotUseFFmpeg, forKey: "doNotUseFFmpeg") 159 | updateFFmpegRadioButtons() 160 | } 161 | private func updateFFmpegRadioButtons() { 162 | // 根据全局变量设置 Radio Buttons 的状态 163 | useFFmpegRadioButton.state = globalVar.doNotUseFFmpeg ? .off : .on 164 | doNotUseFFmpegRadioButton.state = globalVar.doNotUseFFmpeg ? .on : .off 165 | } 166 | 167 | } 168 | -------------------------------------------------------------------------------- /FlowVision/Sources/Common/FFmpegKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FFmpegKit.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/7/5. 6 | // 7 | 8 | import Foundation 9 | 10 | class FFmpegKitWrapper { 11 | static let shared = FFmpegKitWrapper() 12 | 13 | private var isFFmpegKitLoaded = false 14 | private var handle: UnsafeMutableRawPointer? 15 | //private let syncQueue = DispatchQueue(label: "com.ffmpegkit.wrapper.syncQueue") 16 | private let lock = NSLock() 17 | var countBeforeLoaded = 0 18 | 19 | private let LOAD_WHEN_USE=true // 是否使用时立即加载 20 | 21 | private init() {} 22 | 23 | func getIfLoaded() -> Bool { 24 | 25 | if globalVar.doNotUseFFmpeg { 26 | return false 27 | } 28 | 29 | if LOAD_WHEN_USE{ 30 | loadFFmpegKitIfNeeded() 31 | } 32 | 33 | if !isFFmpegKitLoaded{ 34 | countBeforeLoaded += 1 35 | } 36 | return isFFmpegKitLoaded 37 | } 38 | 39 | func loadFFmpegKitIfNeeded() { 40 | lock.lock() 41 | defer {lock.unlock()} 42 | if !isFFmpegKitLoaded { 43 | // loadFFmpegKit("libavcodec.framework/Versions/A/libavcodec") 44 | // loadFFmpegKit("libavdevice.framework/Versions/A/libavdevice") 45 | // loadFFmpegKit("libavfilter.framework/Versions/A/libavfilter") 46 | // loadFFmpegKit("libavformat.framework/Versions/A/libavformat") 47 | // loadFFmpegKit("libavutil.framework/Versions/A/libavutil") 48 | // loadFFmpegKit("libswresample.framework/Versions/A/libswresample") 49 | // loadFFmpegKit("libswscale.framework/Versions/A/libswscale") 50 | loadFFmpegKit("ffmpegkit.framework/Versions/A/ffmpegkit") 51 | isFFmpegKitLoaded = true 52 | } 53 | } 54 | 55 | private func loadFFmpegKit(_ path: String) { 56 | let ffmpegKitPath = path 57 | handle = dlopen(ffmpegKitPath, RTLD_NOW) 58 | if handle == nil { 59 | let error = String(cString: dlerror()) 60 | log("Error loading FFmpegKit: \(error)") 61 | } else { 62 | log("FFmpegKit loaded successfully") 63 | } 64 | } 65 | 66 | func executeFFmpegCommand(_ command: [String]) -> Any? { 67 | loadFFmpegKitIfNeeded() 68 | lock.lock() 69 | 70 | let className = "FFmpegKit" 71 | let selectorName = "executeWithArguments:" 72 | guard let ffmpegKitClass = objc_getClass(className) as? AnyClass else { 73 | log("Could not find class \(className)") 74 | return nil 75 | } 76 | 77 | let selector = sel_registerName(selectorName) 78 | guard ffmpegKitClass.responds(to: selector) else { 79 | log("Could not find selector \(selectorName)") 80 | return nil 81 | } 82 | 83 | let methodIMP = ffmpegKitClass.method(for: selector) 84 | typealias ExecuteFunctionType = @convention(c) (AnyClass, Selector, NSArray) -> Any 85 | let executeFunction = unsafeBitCast(methodIMP, to: ExecuteFunctionType.self) 86 | 87 | let args = NSArray(array: command) 88 | 89 | lock.unlock() 90 | return executeFunction(ffmpegKitClass, selector, args) 91 | } 92 | 93 | func executeFFprobeCommand(_ command: [String]) -> Any? { 94 | loadFFmpegKitIfNeeded() 95 | lock.lock() 96 | 97 | let className = "FFprobeKit" 98 | let selectorName = "executeWithArguments:" 99 | guard let ffprobeKitClass = objc_getClass(className) as? AnyClass else { 100 | log("Could not find class \(className)") 101 | return nil 102 | } 103 | 104 | let selector = sel_registerName(selectorName) 105 | guard ffprobeKitClass.responds(to: selector) else { 106 | log("Could not find selector \(selectorName)") 107 | return nil 108 | } 109 | 110 | let methodIMP = ffprobeKitClass.method(for: selector) 111 | typealias ExecuteFunctionType = @convention(c) (AnyClass, Selector, NSArray) -> Any 112 | let executeFunction = unsafeBitCast(methodIMP, to: ExecuteFunctionType.self) 113 | 114 | let args = NSArray(array: command) 115 | 116 | lock.unlock() 117 | return executeFunction(ffprobeKitClass, selector, args) 118 | } 119 | 120 | func getReturnCode(from session: Any) -> Any? { 121 | loadFFmpegKitIfNeeded() 122 | lock.lock() 123 | 124 | let selectorName = "getReturnCode" 125 | let selector = sel_registerName(selectorName) 126 | guard let sessionClass = object_getClass(session) else { 127 | log("Could not get class of session object") 128 | return nil 129 | } 130 | 131 | guard sessionClass.instancesRespond(to: selector) else { 132 | log("Session class does not respond to selector \(selectorName)") 133 | return nil 134 | } 135 | 136 | let methodIMP = sessionClass.instanceMethod(for: selector) 137 | typealias GetReturnCodeFunctionType = @convention(c) (AnyObject, Selector) -> Any? 138 | let getReturnCodeFunction = unsafeBitCast(methodIMP, to: GetReturnCodeFunctionType.self) 139 | 140 | lock.unlock() 141 | return getReturnCodeFunction(session as AnyObject, selector) 142 | } 143 | 144 | func getOutput(from session: Any) -> String? { 145 | loadFFmpegKitIfNeeded() 146 | lock.lock() 147 | 148 | let selectorName = "getOutput" 149 | let selector = sel_registerName(selectorName) 150 | guard let sessionClass = object_getClass(session) else { 151 | log("Could not get class of session object") 152 | return nil 153 | } 154 | 155 | guard sessionClass.instancesRespond(to: selector) else { 156 | log("Session class does not respond to selector \(selectorName)") 157 | return nil 158 | } 159 | 160 | let methodIMP = sessionClass.instanceMethod(for: selector) 161 | typealias GetOutputFunctionType = @convention(c) (AnyObject, Selector) -> String? 162 | let getOutputFunction = unsafeBitCast(methodIMP, to: GetOutputFunctionType.self) 163 | 164 | lock.unlock() 165 | return getOutputFunction(session as AnyObject, selector) 166 | } 167 | 168 | func isSuccess(_ returnCode: Any?) -> Bool { 169 | loadFFmpegKitIfNeeded() 170 | lock.lock() 171 | 172 | let className = "ReturnCode" 173 | let selectorName = "isSuccess:" 174 | guard let returnCodeClass = objc_getClass(className) as? AnyClass else { 175 | log("Could not find class \(className)") 176 | return false 177 | } 178 | 179 | let selector = sel_registerName(selectorName) 180 | guard returnCodeClass.responds(to: selector) else { 181 | log("Could not find selector \(selectorName)") 182 | return false 183 | } 184 | 185 | let methodIMP = returnCodeClass.method(for: selector) 186 | typealias IsSuccessFunctionType = @convention(c) (AnyClass, Selector, Any) -> Bool 187 | let isSuccessFunction = unsafeBitCast(methodIMP, to: IsSuccessFunctionType.self) 188 | 189 | lock.unlock() 190 | return isSuccessFunction(returnCodeClass, selector, returnCode as Any) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /FlowVision/Sources/Views/CustomCollectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomCollectionView.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/7/5. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | 11 | class CustomCollectionView: NSCollectionView { 12 | 13 | private var mouseDownLocation: NSPoint? = nil 14 | 15 | override func becomeFirstResponder() -> Bool { 16 | let result = super.becomeFirstResponder() 17 | log("CustomCollectionView becomeFirstResponder") 18 | 19 | getViewController(self)!.publicVar.isCollectionViewFirstResponder=true 20 | 21 | let selectedIndexPaths = self.selectionIndexPaths 22 | for indexPath in selectedIndexPaths{ 23 | if let item = self.item(at: indexPath) as? CustomCollectionViewItem{ 24 | item.selectedColor() 25 | } 26 | } 27 | 28 | return result 29 | } 30 | 31 | override func resignFirstResponder() -> Bool { 32 | let result = super.resignFirstResponder() 33 | log("CustomCollectionView resignFirstResponder") 34 | 35 | getViewController(self)!.publicVar.isCollectionViewFirstResponder=false 36 | 37 | let selectedIndexPaths = self.selectionIndexPaths 38 | for indexPath in selectedIndexPaths{ 39 | if let item = self.item(at: indexPath) as? CustomCollectionViewItem{ 40 | item.selectedColor() 41 | } 42 | } 43 | 44 | return result 45 | } 46 | 47 | override func keyDown(with event: NSEvent) { 48 | // 不执行任何操作,从而忽略按键 49 | //super.keyDown(with: event) 50 | return 51 | } 52 | 53 | override func rightMouseDown(with event: NSEvent) { 54 | getViewController(self)!.publicVar.isColllectionViewItemRightClicked=false 55 | self.window?.makeFirstResponder(self) 56 | self.mouseDownLocation = event.locationInWindow 57 | super.rightMouseDown(with: event) 58 | } 59 | 60 | override func rightMouseUp(with event: NSEvent) { 61 | let mouseUpLocation = event.locationInWindow 62 | 63 | if let mouseDownLocation = self.mouseDownLocation { 64 | 65 | if !getViewController(self)!.publicVar.isColllectionViewItemRightClicked { 66 | let maxDistance: CGFloat = 5.0 // 允许的最大移动距离 67 | let distance = hypot(mouseUpLocation.x - mouseDownLocation.x, mouseUpLocation.y - mouseDownLocation.y) 68 | 69 | // 鼠标移动距离在允许范围内,弹出菜单 70 | if distance <= maxDistance { 71 | 72 | deselectAll(nil) 73 | 74 | var canPasteOrMove=true 75 | let pasteboard = NSPasteboard.general 76 | let types = pasteboard.types ?? [] 77 | if !types.contains(.fileURL) { 78 | canPasteOrMove=false 79 | } 80 | 81 | //弹出菜单 82 | let menu = NSMenu(title: "Custom Menu") 83 | menu.autoenablesItems = false 84 | 85 | menu.addItem(withTitle: NSLocalizedString("Open in Finder", comment: "在Finder中打开"), action: #selector(actOpenInFinder), keyEquivalent: "") 86 | 87 | menu.addItem(NSMenuItem.separator()) 88 | 89 | let actionItemPaste = menu.addItem(withTitle: NSLocalizedString("Paste", comment: "粘贴"), action: #selector(actPaste), keyEquivalent: "v") 90 | actionItemPaste.isEnabled = canPasteOrMove 91 | 92 | let actionItemMove = menu.addItem(withTitle: NSLocalizedString("Move Here", comment: "移动到此"), action: #selector(actMove), keyEquivalent: "v") 93 | actionItemMove.keyEquivalentModifierMask = [.command,.option] 94 | actionItemMove.isEnabled = canPasteOrMove 95 | 96 | menu.addItem(NSMenuItem.separator()) 97 | 98 | //let actionItemCopyPath = menu.addItem(withTitle: NSLocalizedString("Copy Path", comment: "复制路径"), action: #selector(actCopyPath), keyEquivalent: "") 99 | 100 | let actionItemOpenInTerminal = menu.addItem(withTitle: NSLocalizedString("Open in Terminal", comment: "在终端中打开"), action: #selector(actOpenInTerminal), keyEquivalent: "") 101 | 102 | menu.addItem(NSMenuItem.separator()) 103 | 104 | // 创建"新建"子菜单 105 | let newMenu = NSMenu() 106 | let newMenuItem = NSMenuItem(title: NSLocalizedString("New", comment: "新建"), action: nil, keyEquivalent: "") 107 | newMenuItem.submenu = newMenu 108 | 109 | // 添加新建文件夹选项 110 | let newFolderItem = newMenu.addItem(withTitle: NSLocalizedString("Folder", comment: "文件夹"), 111 | action: #selector(actNewFolder), 112 | keyEquivalent: "n") 113 | newFolderItem.keyEquivalentModifierMask = [.command, .shift] 114 | 115 | newMenu.addItem(NSMenuItem.separator()) 116 | 117 | // 添加新建文本文件选项 118 | let newTextFileItem = newMenu.addItem(withTitle: NSLocalizedString("Text File", comment: "文本文件"), 119 | action: #selector(actNewTextFile), 120 | keyEquivalent: "") 121 | 122 | menu.addItem(newMenuItem) 123 | 124 | menu.addItem(NSMenuItem.separator()) 125 | 126 | let actionItemRefresh = menu.addItem(withTitle: NSLocalizedString("Refresh", comment: "刷新"), action: #selector(actRefresh), keyEquivalent: "r") 127 | actionItemRefresh.keyEquivalentModifierMask = [.command] 128 | 129 | menu.items.forEach { $0.target = self } 130 | NSMenu.popUpContextMenu(menu, with: event, for: self) 131 | } 132 | } 133 | } 134 | self.mouseDownLocation = nil // 重置按下位置 135 | super.rightMouseUp(with: event) 136 | } 137 | 138 | @objc func actOpenInFinder() { 139 | if let folderURL=getViewController(self)?.fileDB.curFolder { 140 | NSWorkspace.shared.open(URL(string: folderURL)!) 141 | } 142 | } 143 | 144 | @objc func actNewFolder() { 145 | getViewController(self)?.handleNewFolder() 146 | } 147 | 148 | @objc func actPaste() { 149 | getViewController(self)?.handlePaste() 150 | } 151 | 152 | @objc func actMove() { 153 | getViewController(self)?.handleMove() 154 | } 155 | 156 | @objc func actRefresh() { 157 | getViewController(self)?.handleUserRefresh() 158 | } 159 | 160 | @objc func actCopyPath() { 161 | guard let folderURL=getViewController(self)?.fileDB.curFolder else{return} 162 | guard let url=URL(string: folderURL) else{return} 163 | let pasteboard = NSPasteboard.general 164 | pasteboard.clearContents() 165 | pasteboard.setString(url.path, forType: .string) 166 | } 167 | 168 | @objc func actOpenInTerminal() { 169 | guard let folderURL=getViewController(self)?.fileDB.curFolder else{return} 170 | guard let url=URL(string: folderURL) else{return} 171 | let task = Process() 172 | task.launchPath = "/usr/bin/open" 173 | task.arguments = ["-a", "Terminal", url.path] 174 | task.launch() 175 | } 176 | 177 | // 添加新建文本文件的动作处理方法 178 | @objc func actNewTextFile() { 179 | getViewController(self)?.handleNewTextFile() 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /FlowVision/Sources/SettingsViews/CustomSettingsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneralSettingsViewController.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/7/22. 6 | // 7 | 8 | import Cocoa 9 | import Settings 10 | 11 | final class CustomSettingsViewController: NSViewController, SettingsPane { 12 | let paneIdentifier = Settings.PaneIdentifier.custom 13 | let paneTitle = NSLocalizedString("View", comment: "查看") 14 | let toolbarItemIcon = NSImage(systemSymbolName: "gear", accessibilityDescription: "")! 15 | 16 | override var nibName: NSNib.Name? { "CustomSettingsViewController" } 17 | 18 | @IBOutlet weak var randomFolderThumbCheckbox: NSButton! 19 | @IBOutlet weak var loopBrowsingCheckbox: NSButton! 20 | @IBOutlet weak var clickEdgeToSwitchImageCheckbox: NSButton! 21 | @IBOutlet weak var scrollMouseWheelToZoomCheckbox: NSButton! 22 | @IBOutlet weak var useInternalPlayerCheckbox: NSButton! 23 | @IBOutlet weak var usePinyinSearchCheckbox: NSButton! 24 | @IBOutlet weak var usePinyinInitialSearchCheckbox: NSButton! 25 | @IBOutlet weak var excludeListView: NSOutlineView! 26 | @IBOutlet weak var excludeContainerView: NSView! 27 | @IBOutlet weak var refViewForExcludeListView: NSView! 28 | @IBOutlet weak var excludeListEditControl: NSSegmentedControl! 29 | 30 | @IBOutlet weak var radioGlass: NSButton! 31 | @IBOutlet weak var radioBlack: NSButton! 32 | @IBOutlet weak var radioFullscreen: NSButton! 33 | @IBOutlet weak var radioGlassForVideo: NSButton! 34 | @IBOutlet weak var radioBlackForVideo: NSButton! 35 | @IBOutlet weak var radioFullscreenForVideo: NSButton! 36 | 37 | override func viewDidLoad() { 38 | super.viewDidLoad() 39 | 40 | randomFolderThumbCheckbox.state = globalVar.randomFolderThumb ? .on : .off 41 | loopBrowsingCheckbox.state = globalVar.loopBrowsing ? .on : .off 42 | clickEdgeToSwitchImageCheckbox.state = globalVar.clickEdgeToSwitchImage ? .on : .off 43 | scrollMouseWheelToZoomCheckbox.state = globalVar.scrollMouseWheelToZoom ? .on : .off 44 | useInternalPlayerCheckbox.state = globalVar.useInternalPlayer ? .on : .off 45 | usePinyinSearchCheckbox.state = globalVar.usePinyinSearch ? .on : .off 46 | usePinyinInitialSearchCheckbox.state = globalVar.usePinyinInitialSearch ? .on : .off 47 | 48 | globalVar.useInternalPlayerCheckbox = self.useInternalPlayerCheckbox 49 | 50 | radioGlass.state = (!globalVar.blackBgAlways && !globalVar.blackBgInFullScreen) ? .on : .off 51 | radioBlack.state = globalVar.blackBgAlways ? .on : .off 52 | radioFullscreen.state = (!globalVar.blackBgAlways && globalVar.blackBgInFullScreen) ? .on : .off 53 | radioGlassForVideo.state = (!globalVar.blackBgAlwaysForVideo && !globalVar.blackBgInFullScreenForVideo) ? .on : .off 54 | radioBlackForVideo.state = globalVar.blackBgAlwaysForVideo ? .on : .off 55 | radioFullscreenForVideo.state = (!globalVar.blackBgAlwaysForVideo && globalVar.blackBgInFullScreenForVideo) ? .on : .off 56 | 57 | // 设置 OutlineView 58 | excludeListView.dataSource = self 59 | excludeListView.delegate = self 60 | 61 | // 根据refViewForExcludeListView的x、y设置excludeListView的x、y 62 | DispatchQueue.main.async { [weak self] in 63 | guard let self = self else { return } 64 | let refFrameInWindow = refViewForExcludeListView.convert(refViewForExcludeListView.bounds, to: nil) 65 | let newY = refFrameInWindow.origin.y - excludeContainerView.frame.height + refViewForExcludeListView.frame.height 66 | excludeContainerView.frame = NSRect(x: refFrameInWindow.origin.x, y: newY, width: 300, height: 125) 67 | } 68 | 69 | // 设置增减图标 70 | if let plusImage = NSImage(systemSymbolName: "plus", accessibilityDescription: "Add Item") { 71 | excludeListEditControl.setImage(plusImage, forSegment: 0) 72 | } 73 | if let minusImage = NSImage(systemSymbolName: "minus", accessibilityDescription: "Remove Item") { 74 | excludeListEditControl.setImage(minusImage, forSegment: 1) 75 | } 76 | 77 | // 已在AppDelegate中加载数据 78 | excludeListView.reloadData() 79 | } 80 | 81 | @IBAction func randomFolderThumbToggled(_ sender: NSButton) { 82 | globalVar.randomFolderThumb = (sender.state == .on) 83 | UserDefaults.standard.set(globalVar.randomFolderThumb, forKey: "randomFolderThumb") 84 | } 85 | 86 | @IBAction func loopBrowsingToggled(_ sender: NSButton) { 87 | globalVar.loopBrowsing = (sender.state == .on) 88 | UserDefaults.standard.set(globalVar.loopBrowsing, forKey: "loopBrowsing") 89 | } 90 | 91 | @IBAction func clickEdgeToSwitchImageToggled(_ sender: NSButton) { 92 | globalVar.clickEdgeToSwitchImage = (sender.state == .on) 93 | UserDefaults.standard.set(globalVar.clickEdgeToSwitchImage, forKey: "clickEdgeToSwitchImage") 94 | } 95 | 96 | @IBAction func scrollMouseWheelToZoomToggled(_ sender: NSButton) { 97 | globalVar.scrollMouseWheelToZoom = (sender.state == .on) 98 | UserDefaults.standard.set(globalVar.scrollMouseWheelToZoom, forKey: "scrollMouseWheelToZoom") 99 | } 100 | 101 | @IBAction func useInternalPlayerToggled(_ sender: NSButton) { 102 | globalVar.useInternalPlayer = (sender.state == .on) 103 | UserDefaults.standard.set(globalVar.useInternalPlayer, forKey: "useInternalPlayer") 104 | } 105 | 106 | @IBAction func bgSettingToggled(_ sender: NSButton) { 107 | let tag = sender.tag 108 | if tag == 0 { 109 | globalVar.blackBgAlways = false 110 | globalVar.blackBgInFullScreen = false 111 | } else if tag == 1 { 112 | globalVar.blackBgAlways = true 113 | globalVar.blackBgInFullScreen = false 114 | } else if tag == 2 { 115 | globalVar.blackBgAlways = false 116 | globalVar.blackBgInFullScreen = true 117 | } 118 | UserDefaults.standard.set(globalVar.blackBgAlways, forKey: "blackBgAlways") 119 | UserDefaults.standard.set(globalVar.blackBgInFullScreen, forKey: "blackBgInFullScreen") 120 | if let appDelegate=NSApplication.shared.delegate as? AppDelegate { 121 | for windowController in appDelegate.windowControllers { 122 | if let viewController = windowController.contentViewController as? ViewController { 123 | viewController.largeImageView.determineBlackBg() 124 | } 125 | } 126 | } 127 | } 128 | 129 | @IBAction func bgSettingForVideoToggled(_ sender: NSButton) { 130 | let tag = sender.tag 131 | if tag == 0 { 132 | globalVar.blackBgAlwaysForVideo = false 133 | globalVar.blackBgInFullScreenForVideo = false 134 | } else if tag == 1 { 135 | globalVar.blackBgAlwaysForVideo = true 136 | globalVar.blackBgInFullScreenForVideo = false 137 | } else if tag == 2 { 138 | globalVar.blackBgAlwaysForVideo = false 139 | globalVar.blackBgInFullScreenForVideo = true 140 | } 141 | UserDefaults.standard.set(globalVar.blackBgAlwaysForVideo, forKey: "blackBgAlwaysForVideo") 142 | UserDefaults.standard.set(globalVar.blackBgInFullScreenForVideo, forKey: "blackBgInFullScreenForVideo") 143 | if let appDelegate=NSApplication.shared.delegate as? AppDelegate { 144 | for windowController in appDelegate.windowControllers { 145 | if let viewController = windowController.contentViewController as? ViewController { 146 | viewController.largeImageView.determineBlackBg() 147 | } 148 | } 149 | } 150 | } 151 | 152 | @IBAction func usePinyinSearchToggled(_ sender: NSButton) { 153 | globalVar.usePinyinSearch = (sender.state == .on) 154 | UserDefaults.standard.set(globalVar.usePinyinSearch, forKey: "usePinyinSearch") 155 | } 156 | 157 | @IBAction func usePinyinInitialSearchToggled(_ sender: NSButton) { 158 | globalVar.usePinyinInitialSearch = (sender.state == .on) 159 | UserDefaults.standard.set(globalVar.usePinyinInitialSearch, forKey: "usePinyinInitialSearch") 160 | } 161 | 162 | @IBAction func segmentedControlValueChanged(_ sender: NSSegmentedControl) { 163 | switch sender.selectedSegment { 164 | case 0: // 增加 165 | let openPanel = NSOpenPanel() 166 | openPanel.canChooseDirectories = true 167 | openPanel.canChooseFiles = false 168 | openPanel.allowsMultipleSelection = false 169 | 170 | openPanel.beginSheetModal(for: self.view.window!) { [weak self] response in 171 | guard let self = self else { return } 172 | if response == .OK, let url = openPanel.url { 173 | if !globalVar.thumbnailExcludeList.contains(url.path) { 174 | globalVar.thumbnailExcludeList.append(url.path) 175 | } 176 | excludeListView.reloadData() 177 | UserDefaults.standard.set(globalVar.thumbnailExcludeList, forKey: "thumbnailExcludeList") 178 | } 179 | } 180 | case 1: // 删除 181 | let selectedRow = excludeListView.selectedRow 182 | if selectedRow >= 0 && selectedRow < globalVar.thumbnailExcludeList.count { 183 | globalVar.thumbnailExcludeList.remove(at: selectedRow) 184 | excludeListView.reloadData() 185 | UserDefaults.standard.set(globalVar.thumbnailExcludeList, forKey: "thumbnailExcludeList") 186 | } 187 | default: 188 | break 189 | } 190 | } 191 | } 192 | 193 | // MARK: - NSOutlineViewDataSource 194 | extension CustomSettingsViewController: NSOutlineViewDataSource { 195 | func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { 196 | return item == nil ? globalVar.thumbnailExcludeList.count : 0 197 | } 198 | 199 | func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { 200 | return globalVar.thumbnailExcludeList[index] 201 | } 202 | 203 | func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { 204 | return false 205 | } 206 | } 207 | 208 | // MARK: - NSOutlineViewDelegate 209 | extension CustomSettingsViewController: NSOutlineViewDelegate { 210 | func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { 211 | let cellIdentifier = NSUserInterfaceItemIdentifier("PathCell") 212 | 213 | guard let cell = outlineView.makeView(withIdentifier: cellIdentifier, owner: self) as? NSTableCellView else { 214 | let cell = NSTableCellView() 215 | cell.identifier = cellIdentifier 216 | let textField = NSTextField(labelWithString: "") 217 | cell.addSubview(textField) 218 | cell.textField = textField 219 | return cell 220 | } 221 | 222 | if let path = item as? String { 223 | cell.textField?.stringValue = path 224 | } 225 | 226 | return cell 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /FlowVision/Sources/SettingsViews/GeneralSettingsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneralSettingsViewController.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/7/22. 6 | // 7 | 8 | import Cocoa 9 | import Settings 10 | 11 | final class GeneralSettingsViewController: NSViewController, SettingsPane { 12 | let paneIdentifier = Settings.PaneIdentifier.general 13 | let paneTitle = NSLocalizedString("General", comment: "通用") 14 | let toolbarItemIcon = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "")! 15 | 16 | override var nibName: NSNib.Name? { "GeneralSettingsViewController" } 17 | 18 | @IBOutlet weak var terminateAfterLastWindowClosedCheckbox: NSButton! 19 | @IBOutlet weak var autoHideToolbarCheckbox: NSButton! 20 | @IBOutlet weak var languagePopUpButton: NSPopUpButton! 21 | 22 | @IBOutlet weak var radioHomeFolder: NSButton! 23 | @IBOutlet weak var radioLastFolder: NSButton! 24 | 25 | @IBOutlet weak var labelHomeFolder: NSTextField! 26 | @IBOutlet weak var buttonSelectHomeFolder: NSButton! 27 | 28 | override func viewDidLoad() { 29 | super.viewDidLoad() 30 | 31 | //self.preferredContentSize = NSSize(width: 600, height: 400) 32 | 33 | terminateAfterLastWindowClosedCheckbox.state = globalVar.terminateAfterLastWindowClosed ? .on : .off 34 | autoHideToolbarCheckbox.state = globalVar.autoHideToolbar ? .on : .off 35 | 36 | // 初始化 NSPopUpButton 的选项 37 | let autoTitle = NSLocalizedString("Auto", comment: "自动") 38 | languagePopUpButton.removeAllItems() 39 | languagePopUpButton.addItems(withTitles: [autoTitle, "Arabic(العربية)", "Chinese Simplified(简体中文)", "Chinese Traditional(繁體中文)", "Dutch(Nederlands)", "English(English)", "French(Français)", "German(Deutsch)", "Italian(Italiano)", "Japanese(日本語)", "Korean(한국어)", "Portuguese Brazil(Português)", "Portuguese Portugal(Português)", "Russian(Русский)", "Spanish(Español)", "Swedish(Svenska)"]) 40 | // 设置初始选择 41 | if let languageCodes = UserDefaults.standard.array(forKey: "AppleLanguages") as? [String], let firstLanguage = languageCodes.first { 42 | switch firstLanguage { 43 | case let lang where lang.hasPrefix("en"): 44 | languagePopUpButton.selectItem(withTitle: "English(English)") 45 | case let lang where lang.hasPrefix("zh-Hans"): 46 | languagePopUpButton.selectItem(withTitle: "Chinese Simplified(简体中文)") 47 | case let lang where lang.hasPrefix("zh-Hant"): 48 | languagePopUpButton.selectItem(withTitle: "Chinese Traditional(繁體中文)") 49 | case let lang where lang.hasPrefix("es"): 50 | languagePopUpButton.selectItem(withTitle: "Spanish(Español)") 51 | case let lang where lang.hasPrefix("fr"): 52 | languagePopUpButton.selectItem(withTitle: "French(Français)") 53 | case let lang where lang.hasPrefix("de"): 54 | languagePopUpButton.selectItem(withTitle: "German(Deutsch)") 55 | case let lang where lang.hasPrefix("ja"): 56 | languagePopUpButton.selectItem(withTitle: "Japanese(日本語)") 57 | case let lang where lang.hasPrefix("pt-BR"): 58 | languagePopUpButton.selectItem(withTitle: "Portuguese Brazil(Português)") 59 | case let lang where lang.hasPrefix("pt-PT"): 60 | languagePopUpButton.selectItem(withTitle: "Portuguese Portugal(Português)") 61 | case let lang where lang.hasPrefix("ru"): 62 | languagePopUpButton.selectItem(withTitle: "Russian(Русский)") 63 | case let lang where lang.hasPrefix("ko"): 64 | languagePopUpButton.selectItem(withTitle: "Korean(한국어)") 65 | case let lang where lang.hasPrefix("it"): 66 | languagePopUpButton.selectItem(withTitle: "Italian(Italiano)") 67 | case let lang where lang.hasPrefix("ar"): 68 | languagePopUpButton.selectItem(withTitle: "Arabic(العربية)") 69 | case let lang where lang.hasPrefix("nl"): 70 | languagePopUpButton.selectItem(withTitle: "Dutch(Nederlands)") 71 | case let lang where lang.hasPrefix("sv"): 72 | languagePopUpButton.selectItem(withTitle: "Swedish(Svenska)") 73 | default: 74 | languagePopUpButton.selectItem(withTitle: autoTitle) 75 | } 76 | } else { 77 | languagePopUpButton.selectItem(withTitle: autoTitle) 78 | } 79 | 80 | radioLastFolder.state = globalVar.openLastFolder ? .on : .off 81 | radioHomeFolder.state = !globalVar.openLastFolder ? .on : .off 82 | labelHomeFolder.stringValue = globalVar.homeFolder.removingPercentEncoding!.replacingOccurrences(of: "file://", with: "") 83 | labelHomeFolder.textColor = globalVar.openLastFolder ? .disabledControlTextColor : .controlTextColor 84 | buttonSelectHomeFolder.isEnabled = !globalVar.openLastFolder 85 | } 86 | 87 | @IBAction func languageSelectionChanged(_ sender: NSPopUpButton) { 88 | let selectedTitle = sender.selectedItem?.title 89 | let autoTitle = NSLocalizedString("Auto", comment: "自动") 90 | 91 | switch selectedTitle { 92 | case "English(English)": 93 | UserDefaults.standard.set(["en"], forKey: "AppleLanguages") 94 | case "Chinese Simplified(简体中文)": 95 | UserDefaults.standard.set(["zh-Hans"], forKey: "AppleLanguages") 96 | case "Chinese Traditional(繁體中文)": 97 | UserDefaults.standard.set(["zh-Hant"], forKey: "AppleLanguages") 98 | case "Spanish(Español)": 99 | UserDefaults.standard.set(["es"], forKey: "AppleLanguages") 100 | case "French(Français)": 101 | UserDefaults.standard.set(["fr"], forKey: "AppleLanguages") 102 | case "German(Deutsch)": 103 | UserDefaults.standard.set(["de"], forKey: "AppleLanguages") 104 | case "Japanese(日本語)": 105 | UserDefaults.standard.set(["ja"], forKey: "AppleLanguages") 106 | case "Portuguese Brazil(Português)": 107 | UserDefaults.standard.set(["pt-BR"], forKey: "AppleLanguages") 108 | case "Portuguese Portugal(Português)": 109 | UserDefaults.standard.set(["pt-PT"], forKey: "AppleLanguages") 110 | case "Russian(Русский)": 111 | UserDefaults.standard.set(["ru"], forKey: "AppleLanguages") 112 | case "Korean(한국어)": 113 | UserDefaults.standard.set(["ko"], forKey: "AppleLanguages") 114 | case "Italian(Italiano)": 115 | UserDefaults.standard.set(["it"], forKey: "AppleLanguages") 116 | case "Arabic(العربية)": 117 | UserDefaults.standard.set(["ar"], forKey: "AppleLanguages") 118 | case "Dutch(Nederlands)": 119 | UserDefaults.standard.set(["nl"], forKey: "AppleLanguages") 120 | case "Swedish(Svenska)": 121 | UserDefaults.standard.set(["sv"], forKey: "AppleLanguages") 122 | case autoTitle: 123 | UserDefaults.standard.removeObject(forKey: "AppleLanguages") 124 | default: 125 | break 126 | } 127 | } 128 | 129 | @IBAction func terminateAfterLastWindowClosedToggled(_ sender: NSButton) { 130 | globalVar.terminateAfterLastWindowClosed = (sender.state == .on) 131 | UserDefaults.standard.set(globalVar.terminateAfterLastWindowClosed, forKey: "terminateAfterLastWindowClosed") 132 | } 133 | 134 | @IBAction func autoHideToolbarToggled(_ sender: NSButton) { 135 | UserDefaults.standard.set(sender.state, forKey: "autoHideToolbar") 136 | } 137 | 138 | @IBAction func openSystemPreferences(_ sender: Any) { 139 | _ = requestAppleEventsPermission() 140 | let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy")! 141 | NSWorkspace.shared.open(url) 142 | } 143 | 144 | @IBAction func setAsDefaultApp(_ sender: Any) { 145 | // let fileTypes = ["public.jpeg", "public.png", "public.gif", "com.microsoft.bmp", "public.tiff", "public.heif", "org.webmproject.webp", "public.image", "public.heic"] 146 | guard let fileTypes = getSupportedFileTypes() else { 147 | log("获取支持文件类型失败", level: .error) 148 | return 149 | } 150 | let appBundleID = Bundle.main.bundleIdentifier! 151 | 152 | var allSuccess = true 153 | var errorMessages = [String]() 154 | 155 | let roleMask: LSRolesMask = [.all] 156 | 157 | for fileType in fileTypes { 158 | if fileType == "public.folder" {continue} 159 | let status = LSSetDefaultRoleHandlerForContentType(fileType as CFString, roleMask, appBundleID as CFString) 160 | 161 | if status != noErr { 162 | allSuccess = false 163 | errorMessages.append("Failed for \(fileType): Error code: \(status)") 164 | } 165 | } 166 | 167 | let alert = NSAlert() 168 | if allSuccess { 169 | alert.messageText = NSLocalizedString("Success", comment: "成功") 170 | alert.informativeText = NSLocalizedString("This app is now the default for all specified file types.", comment: "此应用现在是所有指定文件类型的默认应用程序。") 171 | alert.alertStyle = .informational 172 | } else { 173 | alert.messageText = NSLocalizedString("Error", comment: "错误") 174 | alert.informativeText = NSLocalizedString("Failed to set this app as the default for some file types:\n", comment: "未能将此应用设置为某些文件类型的默认应用程序:\n") + errorMessages.joined(separator: "\n") 175 | alert.alertStyle = .critical 176 | } 177 | alert.runModal() 178 | } 179 | 180 | private func getSupportedFileTypes() -> [String]? { 181 | guard let infoPlist = Bundle.main.infoDictionary, 182 | let documentTypes = infoPlist["CFBundleDocumentTypes"] as? [[String: Any]] else { 183 | return nil 184 | } 185 | 186 | var fileTypes = [String]() 187 | for documentType in documentTypes { 188 | if let contentTypes = documentType["LSItemContentTypes"] as? [String] { 189 | fileTypes.append(contentsOf: contentTypes) 190 | } 191 | } 192 | return fileTypes 193 | } 194 | 195 | @IBAction func openBehaviorToggled(_ sender: NSButton) { 196 | let tag = sender.tag 197 | if tag == 0 { 198 | globalVar.openLastFolder = false 199 | } else if tag == 1 { 200 | globalVar.openLastFolder = true 201 | } 202 | UserDefaults.standard.set(globalVar.openLastFolder, forKey: "openLastFolder") 203 | if globalVar.openLastFolder { 204 | labelHomeFolder.textColor = .disabledControlTextColor 205 | buttonSelectHomeFolder.isEnabled = false 206 | } else { 207 | labelHomeFolder.textColor = .controlTextColor 208 | buttonSelectHomeFolder.isEnabled = true 209 | } 210 | } 211 | 212 | @IBAction func selectHomeFolder(_ sender: Any) { 213 | let panel = NSOpenPanel() 214 | panel.canChooseDirectories = true 215 | panel.canChooseFiles = false 216 | panel.allowsMultipleSelection = false 217 | panel.directoryURL = URL(string: globalVar.homeFolder) 218 | panel.runModal() 219 | if let url = panel.url { 220 | globalVar.homeFolder = url.absoluteString 221 | labelHomeFolder.stringValue = globalVar.homeFolder.removingPercentEncoding!.replacingOccurrences(of: "file://", with: "") 222 | UserDefaults.standard.set(globalVar.homeFolder, forKey: "homeFolder") 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /FlowVision/Sources/Views/CustomOutlineViewManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TreeOutlineViewManager.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/4/21. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | 11 | class CustomOutlineViewManager: NSObject { 12 | var fileDB: DatabaseModel 13 | var treeViewData: TreeViewModel 14 | var ifActWhenSelected = true 15 | weak var outlineView: NSOutlineView? 16 | 17 | init(fileDB: DatabaseModel, treeViewData: TreeViewModel, outlineView: NSOutlineView) { 18 | self.fileDB = fileDB 19 | self.treeViewData = treeViewData 20 | self.outlineView = outlineView 21 | } 22 | } 23 | 24 | extension CustomOutlineViewManager: NSOutlineViewDataSource { 25 | func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { 26 | guard let treeNode = item as? TreeNode else { 27 | return treeViewData.root?.children?.count ?? 0 28 | } 29 | return treeNode.children?.count ?? 0 30 | } 31 | 32 | func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { 33 | guard let treeNode = item as? TreeNode else { 34 | return treeViewData.root?.children?[index] ?? "" 35 | } 36 | return treeNode.children?[index] ?? "" 37 | } 38 | 39 | func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { 40 | guard let treeNode = item as? TreeNode else { 41 | return false 42 | } 43 | if (treeNode.children?.count ?? 0) > 0 { 44 | return true 45 | } 46 | if treeNode.hasChild { 47 | return true 48 | } 49 | return false 50 | } 51 | func outlineViewItemWillExpand(_ notification: Notification) { 52 | if let item = notification.userInfo?["NSObject"] as? TreeNode { 53 | // 在这里执行你的代码 54 | log("TreeData expand: \(item.fullPath)") 55 | treeViewData.expand(node: item, isLookSub: true) 56 | } 57 | } 58 | 59 | } 60 | extension CustomOutlineViewManager: NSOutlineViewDelegate { 61 | 62 | func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { 63 | guard let treeNode = item as? TreeNode else { return nil } 64 | let view = outlineView.makeView(withIdentifier: NSUserInterfaceItemIdentifier("DataCell"), owner: self) as! CustomTableCellView 65 | view.textField?.stringValue = treeNode.name 66 | 67 | let folderIcon = NSImage(named: NSImage.folderName)?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)) 68 | //folderIcon?.isTemplate = true // 设置为模板 69 | //view.imageView?.contentTintColor = NSColor.red // 将图标颜色设置为红色 70 | view.imageView?.image = folderIcon 71 | //view?.imageView?.image = NSImage(named: NSImage.folderName) 72 | 73 | // let backgroundView = NSView() 74 | // backgroundView.wantsLayer = true 75 | // backgroundView.layer?.backgroundColor = NSColor.lightGray.cgColor 76 | // view.addSubview(backgroundView, positioned: .below, relativeTo: view.textField) 77 | // 78 | // // 确保背景视图填充整个cell 79 | // backgroundView.frame = view.bounds 80 | // backgroundView.autoresizingMask = [.width, .height] 81 | 82 | return view 83 | } 84 | func outlineViewSelectionDidChange(_ notification: Notification) { 85 | guard let outlineView = notification.object as? NSOutlineView else { return } 86 | 87 | let selectedIndex = outlineView.selectedRow 88 | if selectedIndex != -1, let item = outlineView.item(atRow: selectedIndex) as? TreeNode { 89 | // 这里调用你的函数,例如: 90 | itemSelected(item) 91 | } 92 | } 93 | 94 | func itemSelected(_ item: TreeNode) { 95 | if ifActWhenSelected { 96 | //log("Selected item: \(item.name)") 97 | //fileDB.lock() 98 | //let lastFolderPath = fileDB.curFolder 99 | //fileDB.curFolder = item.fullPath 100 | //log(fileDB.curFolder) 101 | //fileDB.unlock() 102 | //getViewController(self)!.publicVar.folderStepStack.insert(lastFolderPath, at: 0) 103 | getViewController(outlineView!)?.switchDirByDirection(direction: .zero, dest: item.fullPath, doCollapse: false, expandLast: false, skip: false, stackDeep: 0) 104 | } 105 | 106 | } 107 | func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? { 108 | return CustomTableRowView() 109 | } 110 | 111 | func outlineViewItemDidExpand(_ notification: Notification) { 112 | adjustColumnWidth() 113 | 114 | // 重新选中之前选中的项 115 | fileDB.lock() 116 | let curFolder = fileDB.curFolder 117 | fileDB.unlock() 118 | if let item = notification.userInfo?["NSObject"] as? TreeNode, 119 | let children = item.children { 120 | for child in children { 121 | if child.fullPath == curFolder { 122 | if let row = outlineView?.row(forItem: child), 123 | row != -1 { 124 | ifActWhenSelected=false 125 | outlineView?.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) 126 | ifActWhenSelected=true 127 | break 128 | } 129 | } 130 | } 131 | } 132 | 133 | } 134 | 135 | func outlineViewItemDidCollapse(_ notification: Notification) { 136 | adjustColumnWidth() 137 | } 138 | 139 | func adjustColumnWidth() { 140 | guard let outlineView = outlineView else { return } 141 | 142 | DispatchQueue.main.async { 143 | 144 | let columnIndex = 0 // 指定你需要调整的列的索引 145 | let column = outlineView.tableColumns[columnIndex] 146 | var maxWidth: CGFloat = 10 147 | 148 | // 遍历所有可见行 149 | for i in 0.. NSDragOperation { 171 | return .move 172 | } 173 | 174 | func outlineView(_ outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: Any?, childIndex index: Int) -> Bool { 175 | guard let outlineItem = item as? TreeNode else { return false } 176 | 177 | if let targetUrl = URL(string: outlineItem.fullPath) { 178 | let pasteboard = info.draggingPasteboard 179 | if let data = pasteboard.data(forType: .fileURL), 180 | let pasteboardUrl = URL(dataRepresentation: data, relativeTo: nil), 181 | pasteboardUrl == targetUrl { 182 | // URLs are identical, do not perform the move 183 | return false 184 | } 185 | 186 | getViewController(outlineView)?.handleMove(targetURL: targetUrl, pasteboard: pasteboard) 187 | getViewController(outlineView)?.refreshTreeView() 188 | return true 189 | } 190 | 191 | return false 192 | } 193 | 194 | func outlineView(_ outlineView: NSOutlineView, pasteboardWriterForItem item: Any) -> NSPasteboardWriting? { 195 | guard let outlineItem = item as? TreeNode else { return nil } 196 | 197 | let pasteboardItem = NSPasteboardItem() 198 | 199 | if let url=URL(string: outlineItem.fullPath) { 200 | pasteboardItem.setString(url.absoluteString, forType: .fileURL) 201 | } 202 | 203 | return pasteboardItem 204 | } 205 | 206 | } 207 | 208 | class CustomTableCellView: NSTableCellView { 209 | 210 | // override var backgroundStyle: NSView.BackgroundStyle { 211 | // didSet { 212 | // imageView?.contentTintColor = backgroundStyle == .emphasized ? NSColor.black : NSColor.green 213 | // // 检查是否处于选中状态 214 | // switch backgroundStyle { 215 | // case .emphasized: // 选中状态 216 | // textField?.textColor = NSColor.green 217 | // // 如果需要,还可以在这里修改imageView的contentTintColor 218 | // default: // 非选中状态 219 | // textField?.textColor = NSColor.red 220 | // // 根据需要恢复imageView的contentTintColor 221 | // } 222 | // } 223 | // } 224 | } 225 | 226 | class CustomTableRowView: NSTableRowView { 227 | 228 | override func drawSelection(in dirtyRect: NSRect) { 229 | if self.selectionHighlightStyle != .none { 230 | let selectionRect = NSInsetRect(self.bounds, 8, 1.5)//边距 231 | let selectionPath = NSBezierPath(roundedRect: selectionRect, xRadius: 4, yRadius: 4)//圆角半径 232 | 233 | // 自定义选中状态下的背景色 234 | let theme=NSApp.effectiveAppearance.name 235 | 236 | // 检查是否是第一响应者 237 | if let window = self.window, let firstResponder = window.firstResponder as? NSView, (firstResponder === self || self.isDescendant(of: firstResponder)) { 238 | if theme == .darkAqua { 239 | // 暗模式下的颜色 240 | NSColor.controlAccentColor.setFill() 241 | } else { 242 | // 光模式下的颜色 243 | NSColor.controlAccentColor.setFill() 244 | } 245 | }else{ 246 | NSColor.systemGray.setFill() 247 | } 248 | 249 | selectionPath.fill() 250 | } 251 | } 252 | 253 | // 为了更好的视觉效果,可能还需要重写背景色绘制方法 254 | override func drawBackground(in dirtyRect: NSRect) { 255 | super.drawBackground(in: dirtyRect) 256 | 257 | // 自定义非选中状态下的背景色 258 | let theme=NSApp.effectiveAppearance.name 259 | 260 | if theme == .darkAqua { 261 | // 暗模式下的颜色 262 | //hexToNSColor(hex: "#333333").setFill() 263 | NSColor(named: NSColor.Name("OutlineViewBgColor"))?.setFill() 264 | }else { 265 | // 光模式下的颜色 266 | //hexToNSColor(hex: "#F4F5F5").setFill() 267 | NSColor(named: NSColor.Name("OutlineViewBgColor"))?.setFill() 268 | } 269 | 270 | __NSRectFillUsingOperation(dirtyRect, .sourceOver) 271 | 272 | let selectionRect = NSInsetRect(self.bounds, 9, 2.5) // 边距 273 | let selectionPath = NSBezierPath(roundedRect: selectionRect, xRadius: 4, yRadius: 4) // 圆角半径 274 | // 获取当前 row 的 index 275 | if let tableView = self.superview as? NSTableView { 276 | let rowIndex = tableView.row(for: self) 277 | if rowIndex == getViewController(self)?.outlineView.curRightClickedIndex { 278 | NSColor.controlAccentColor.setStroke() // 设置边框颜色 279 | selectionPath.lineWidth = 2.0 // 设置边框宽度 280 | selectionPath.stroke() // 绘制边框 281 | } 282 | } 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /FlowVision/Sources/Views/Layout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Layout.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/7/5. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | 11 | //这个布局有问题,拖动选中触发区域不一致 12 | class LeftAlignedCollectionViewFlowLayout: NSCollectionViewFlowLayout { 13 | override func prepare() { 14 | super.prepare() 15 | 16 | self.sectionInset = NSEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) 17 | self.minimumInteritemSpacing = 10 18 | self.minimumLineSpacing = 10 19 | } 20 | override func layoutAttributesForElements(in rect: NSRect) -> [NSCollectionViewLayoutAttributes] { 21 | let attributes = super.layoutAttributesForElements(in: rect) 22 | 23 | var leftMargin = sectionInset.left 24 | var maxY: CGFloat = -1.0 25 | 26 | for layoutAttribute in attributes { 27 | if layoutAttribute.frame.origin.y >= maxY { 28 | leftMargin = sectionInset.left 29 | } 30 | 31 | layoutAttribute.frame.origin.x = leftMargin 32 | 33 | leftMargin += layoutAttribute.frame.width + minimumInteritemSpacing 34 | maxY = max(layoutAttribute.frame.maxY, maxY) 35 | } 36 | 37 | return attributes 38 | } 39 | } 40 | 41 | let xxxxxxx = 14.0 42 | 43 | class CustomFlowLayout: NSCollectionViewLayout { 44 | private var cache: [NSCollectionViewLayoutAttributes] = [] 45 | private var contentHeight: CGFloat = 0 46 | private var contentWidth: CGFloat { 47 | guard let collectionView = collectionView else { return 0 } 48 | return getViewController(collectionView)?.mainScrollView.bounds.width ?? collectionView.bounds.width 49 | } 50 | 51 | //var cellPadding: CGFloat = 5 52 | var itemSpacing: CGFloat = 0 53 | var lineSpacing: CGFloat = 0 54 | 55 | override func prepare() { 56 | guard let collectionView = collectionView else { return } 57 | guard let delegate = collectionView.delegate as? NSCollectionViewDelegateFlowLayout else { return } 58 | 59 | cache.removeAll() 60 | contentHeight = 0 61 | 62 | let cellPadding = getViewController(collectionView)!.publicVar.profile.ThumbnailCellPadding 63 | let borderThickness = getViewController(collectionView)!.publicVar.profile.ThumbnailBorderThickness 64 | let lineSpaceAdjust = getViewController(collectionView)!.publicVar.profile.ThumbnailLineSpaceAdjust 65 | var xOffset: CGFloat = cellPadding 66 | var yOffset: CGFloat = cellPadding 67 | var rowHeight: CGFloat = 0 68 | 69 | for item in 0 ..< collectionView.numberOfItems(inSection: 0) { 70 | let indexPath = IndexPath(item: item, section: 0) 71 | let itemSize = delegate.collectionView!(collectionView, layout: self, sizeForItemAt: indexPath) 72 | 73 | let width = itemSize.width + 2 * cellPadding 74 | let height = itemSize.height + 2 * cellPadding 75 | 76 | if xOffset + width > contentWidth { 77 | xOffset = cellPadding 78 | yOffset += rowHeight + lineSpacing + lineSpaceAdjust 79 | rowHeight = 0 80 | } 81 | 82 | let frame = CGRect(x: xOffset, y: yOffset, width: width, height: height) 83 | let insetFrame = frame.insetBy(dx: cellPadding, dy: cellPadding) 84 | let attributes = NSCollectionViewLayoutAttributes(forItemWith: indexPath) 85 | attributes.frame = insetFrame 86 | cache.append(attributes) 87 | 88 | contentHeight = max(contentHeight, frame.maxY + cellPadding) 89 | rowHeight = max(rowHeight, height) 90 | xOffset += width + itemSpacing 91 | } 92 | } 93 | 94 | override var collectionViewContentSize: NSSize { 95 | guard let collectionView = collectionView else { return NSSize(width: 100, height: 100)} 96 | let cellPadding = getViewController(collectionView)!.publicVar.profile.ThumbnailCellPadding 97 | return NSSize(width: contentWidth, height: contentHeight) 98 | } 99 | 100 | override func layoutAttributesForElements(in rect: NSRect) -> [NSCollectionViewLayoutAttributes] { 101 | var visibleLayoutAttributes: [NSCollectionViewLayoutAttributes] = [] 102 | 103 | for attributes in cache { 104 | if attributes.frame.intersects(rect) { 105 | visibleLayoutAttributes.append(attributes) 106 | } 107 | } 108 | 109 | return visibleLayoutAttributes 110 | } 111 | 112 | override func layoutAttributesForItem(at indexPath: IndexPath) -> NSCollectionViewLayoutAttributes? { 113 | guard indexPath.item < cache.count else { return nil } 114 | return cache[indexPath.item] 115 | } 116 | } 117 | 118 | class CustomGridLayout: NSCollectionViewLayout { 119 | private var cache: [NSCollectionViewLayoutAttributes] = [] 120 | private var contentHeight: CGFloat = 0 121 | private var contentWidth: CGFloat { 122 | guard let collectionView = collectionView else { return 0 } 123 | return getViewController(collectionView)?.mainScrollView.bounds.width ?? collectionView.bounds.width 124 | } 125 | 126 | //var cellPadding: CGFloat = 5 127 | var itemSpacing: CGFloat = 0 128 | var lineSpacing: CGFloat = 0 129 | 130 | override func prepare() { 131 | guard let collectionView = collectionView else { return } 132 | guard let delegate = collectionView.delegate as? NSCollectionViewDelegateFlowLayout else { return } 133 | 134 | cache.removeAll() 135 | contentHeight = 0 136 | 137 | let filenamePadding = getViewController(collectionView)!.publicVar.profile.ThumbnailFilenamePadding 138 | let cellPadding = getViewController(collectionView)!.publicVar.profile.ThumbnailCellPadding 139 | let borderThickness = getViewController(collectionView)!.publicVar.profile.ThumbnailBorderThickness 140 | let numberOfColumns = Double(getViewController(collectionView)!.publicVar.waterfallLayout.numberOfColumns) 141 | let scrollbarWidth = getViewController(collectionView)!.publicVar.profile.ThumbnailScrollbarWidth 142 | var totalWidth = contentWidth - scrollbarWidth - 2 * cellPadding 143 | 144 | var xOffset: CGFloat = cellPadding 145 | var yOffset: CGFloat = cellPadding 146 | var rowHeight: CGFloat = 0 147 | 148 | for item in 0 ..< collectionView.numberOfItems(inSection: 0) { 149 | let indexPath = IndexPath(item: item, section: 0) 150 | let itemSize = delegate.collectionView!(collectionView, layout: self, sizeForItemAt: indexPath) 151 | 152 | // 使用固定尺寸计算位置 153 | let positionWidth: CGFloat = floor(totalWidth/CGFloat(numberOfColumns+1)) 154 | let positionHeight: CGFloat = positionWidth + filenamePadding 155 | 156 | if xOffset + positionWidth > contentWidth { 157 | xOffset = cellPadding 158 | yOffset += rowHeight + lineSpacing 159 | rowHeight = 0 160 | } 161 | 162 | // 计算网格单元格的宽度和实际内容的宽度差 163 | let itemWidth = itemSize.width 164 | let itemHeight = itemSize.height 165 | let horizontalOffset = (positionWidth - itemWidth) / 2 166 | let verticalOffset = (positionHeight - itemHeight) / 2 167 | 168 | // 创建居中的frame 169 | let frame = CGRect(x: xOffset + horizontalOffset, y: yOffset + verticalOffset, width: itemWidth, height: itemHeight - filenamePadding) 170 | let insetFrame = frame.insetBy(dx: 0, dy: 0) 171 | let attributes = NSCollectionViewLayoutAttributes(forItemWith: indexPath) 172 | attributes.frame = insetFrame 173 | cache.append(attributes) 174 | 175 | contentHeight = max(contentHeight, yOffset + positionHeight + cellPadding) 176 | rowHeight = max(rowHeight, positionHeight) // 使用固定高度计算行高 177 | xOffset += positionWidth + itemSpacing // 使用固定宽度计算下一个位置 178 | } 179 | } 180 | 181 | override var collectionViewContentSize: NSSize { 182 | guard let collectionView = collectionView else { return NSSize(width: 100, height: 100)} 183 | let cellPadding = getViewController(collectionView)!.publicVar.profile.ThumbnailCellPadding 184 | return NSSize(width: contentWidth, height: contentHeight) 185 | } 186 | 187 | override func layoutAttributesForElements(in rect: NSRect) -> [NSCollectionViewLayoutAttributes] { 188 | var visibleLayoutAttributes: [NSCollectionViewLayoutAttributes] = [] 189 | 190 | for attributes in cache { 191 | if attributes.frame.intersects(rect) { 192 | visibleLayoutAttributes.append(attributes) 193 | } 194 | } 195 | 196 | return visibleLayoutAttributes 197 | } 198 | 199 | override func layoutAttributesForItem(at indexPath: IndexPath) -> NSCollectionViewLayoutAttributes? { 200 | guard indexPath.item < cache.count else { return nil } 201 | return cache[indexPath.item] 202 | } 203 | } 204 | 205 | class WaterfallLayout: NSCollectionViewLayout { 206 | private var cache: [NSCollectionViewLayoutAttributes] = [] 207 | private var contentHeight: CGFloat = 0 208 | private var contentWidth: CGFloat { 209 | guard let collectionView = collectionView else { return 0 } 210 | return getViewController(collectionView)?.mainScrollView.bounds.width ?? collectionView.bounds.width 211 | } 212 | 213 | var numberOfColumns = 5 214 | //var cellPadding: CGFloat = 5 215 | 216 | override func prepare() { 217 | guard let collectionView = collectionView else { return } 218 | guard let delegate = collectionView.delegate as? NSCollectionViewDelegateFlowLayout else { return } 219 | 220 | let cellPadding = getViewController(collectionView)!.publicVar.profile.ThumbnailCellPadding 221 | let borderThickness = getViewController(collectionView)!.publicVar.profile.ThumbnailBorderThickness 222 | let lineSpaceAdjust = getViewController(collectionView)!.publicVar.profile.ThumbnailLineSpaceAdjust 223 | let totalWidth = getViewController(collectionView)?.mainScrollView.bounds.width ?? collectionView.bounds.width 224 | let scrollbarWidth = getViewController(collectionView)!.publicVar.profile.ThumbnailScrollbarWidth 225 | let columnWidth = floor((totalWidth - scrollbarWidth - 2*cellPadding) / CGFloat(numberOfColumns)) 226 | var xOffset: [CGFloat] = [] 227 | for column in 0 ..< numberOfColumns { 228 | xOffset.append(cellPadding + CGFloat(column) * columnWidth) 229 | } 230 | var yOffset: [CGFloat] = .init(repeating: cellPadding, count: numberOfColumns) 231 | 232 | cache.removeAll() 233 | contentHeight = 0 234 | 235 | for item in 0 ..< collectionView.numberOfItems(inSection: 0) { 236 | let indexPath = IndexPath(item: item, section: 0) 237 | let itemSize = delegate.collectionView!(collectionView, layout: self, sizeForItemAt: indexPath) 238 | let width = columnWidth - (cellPadding * 2) 239 | let height = round(itemSize.height * (width / itemSize.width) + (cellPadding * 2)) 240 | 241 | // 找到所有列中高度最小的列 242 | let minYOffset = yOffset.min() ?? 0 243 | let column = yOffset.firstIndex(of: minYOffset) ?? 0 244 | 245 | let frame = CGRect(x: xOffset[column], y: yOffset[column], width: columnWidth, height: height) 246 | let insetFrame = frame.insetBy(dx: cellPadding, dy: cellPadding) 247 | let attributes = NSCollectionViewLayoutAttributes(forItemWith: indexPath) 248 | attributes.frame = insetFrame 249 | cache.append(attributes) 250 | 251 | contentHeight = max(contentHeight, frame.maxY + cellPadding) 252 | yOffset[column] = yOffset[column] + height + lineSpaceAdjust 253 | } 254 | } 255 | 256 | override var collectionViewContentSize: NSSize { 257 | guard let collectionView = collectionView else { return NSSize(width: 100, height: 100)} 258 | let cellPadding = getViewController(collectionView)!.publicVar.profile.ThumbnailCellPadding 259 | return NSSize(width: contentWidth, height: contentHeight) 260 | } 261 | 262 | override func layoutAttributesForElements(in rect: NSRect) -> [NSCollectionViewLayoutAttributes] { 263 | var visibleLayoutAttributes: [NSCollectionViewLayoutAttributes] = [] 264 | 265 | for attributes in cache { 266 | if attributes.frame.intersects(rect) { 267 | visibleLayoutAttributes.append(attributes) 268 | } 269 | } 270 | 271 | return visibleLayoutAttributes 272 | } 273 | 274 | override func layoutAttributesForItem(at indexPath: IndexPath) -> NSCollectionViewLayoutAttributes? { 275 | guard indexPath.item < cache.count else { return nil } 276 | return cache[indexPath.item] 277 | } 278 | } 279 | 280 | -------------------------------------------------------------------------------- /FlowVision/Sources/Views/CustomOutlineView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomOutlineView.swift 3 | // FlowVision 4 | // 5 | // Created by netdcy on 2024/7/5. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | 11 | class CustomOutlineView: NSOutlineView, NSMenuDelegate { 12 | 13 | var curRightClickedPath = "" 14 | var curRightClickedIndex = -1 15 | 16 | override func becomeFirstResponder() -> Bool { 17 | let result = super.becomeFirstResponder() 18 | log("CustomOutlineView becomeFirstResponder") 19 | getViewController(self)!.publicVar.isOutlineViewFirstResponder=true 20 | return result 21 | } 22 | 23 | override func resignFirstResponder() -> Bool { 24 | let result = super.resignFirstResponder() 25 | log("CustomOutlineView resignFirstResponder") 26 | getViewController(self)!.publicVar.isOutlineViewFirstResponder=false 27 | return result 28 | } 29 | 30 | override func keyDown(with event: NSEvent) { 31 | // 不执行任何操作,从而忽略按键,避免字母定位与目录切换快捷键同时触发 32 | //super.keyDown(with: event) 33 | return 34 | } 35 | 36 | override func mouseDown(with event: NSEvent) { 37 | let locationInWindow = event.locationInWindow 38 | let locationInOutlineView = convert(locationInWindow, from: nil) 39 | let clickedRow = row(at: locationInOutlineView) 40 | 41 | // 检查是否在有效的区域内点击 42 | // 为了解决点击目录树后,在右边CollectionView中空白处快速右击左击,会出现目录树异常响应的问题 43 | // 且弹出重命名对话框时异常响应的问题 44 | if clickedRow >= 0 && getViewController(self)!.publicVar.isKeyEventEnabled { 45 | super.mouseDown(with: event) 46 | } else { 47 | // 如果点击区域无效,不执行默认的点击处理 48 | nextResponder?.mouseDown(with: event) 49 | } 50 | } 51 | 52 | override func rightMouseDown(with event: NSEvent) { 53 | self.window?.makeFirstResponder(self) 54 | super.rightMouseDown(with: event) 55 | } 56 | 57 | func menuDidClose(_ menu: NSMenu) { 58 | //curRightClickedPath = "" 59 | curRightClickedIndex = -1 60 | 61 | (self.delegate as? CustomOutlineViewManager)?.ifActWhenSelected=false 62 | let selectedRows = self.selectedRowIndexes 63 | self.reloadData() 64 | self.selectRowIndexes(selectedRows, byExtendingSelection: false) 65 | (self.delegate as? CustomOutlineViewManager)?.ifActWhenSelected=true 66 | } 67 | 68 | override func menu(for event: NSEvent) -> NSMenu? { 69 | 70 | let locationInView = self.convert(event.locationInWindow, from: nil) 71 | let clickedRow = self.row(at: locationInView) 72 | 73 | if clickedRow != -1 { 74 | //self.selectRowIndexes(IndexSet(integer: clickedRow), byExtendingSelection: false) 75 | 76 | let item = self.item(atRow: clickedRow) as? TreeNode 77 | curRightClickedPath=item!.fullPath 78 | curRightClickedIndex=clickedRow 79 | 80 | (self.delegate as? CustomOutlineViewManager)?.ifActWhenSelected=false 81 | let selectedRows = self.selectedRowIndexes 82 | self.reloadData() 83 | self.selectRowIndexes(selectedRows, byExtendingSelection: false) 84 | (self.delegate as? CustomOutlineViewManager)?.ifActWhenSelected=true 85 | 86 | var canPasteOrMove=true 87 | let pasteboard = NSPasteboard.general 88 | let types = pasteboard.types ?? [] 89 | if !types.contains(.fileURL) { 90 | canPasteOrMove=false 91 | } 92 | 93 | // 创建菜单 94 | let menu = NSMenu() 95 | menu.autoenablesItems = false 96 | 97 | let actionItemOpenInNewTab = menu.addItem(withTitle: NSLocalizedString("Open in New Tab", comment: "在新标签页中打开"), action: #selector(actOpenInNewTab), keyEquivalent: "") 98 | if isWindowNumMax() { 99 | actionItemOpenInNewTab.isEnabled=false 100 | }else{ 101 | actionItemOpenInNewTab.isEnabled=true 102 | } 103 | 104 | menu.addItem(NSMenuItem.separator()) 105 | 106 | menu.addItem(withTitle: NSLocalizedString("Show in Finder", comment: "在Finder中显示"), action: #selector(actShowInFinder), keyEquivalent: "") 107 | 108 | let actionItemGetInfo = menu.addItem(withTitle: NSLocalizedString("file-rightmenu-get-info", comment: "显示简介"), action: #selector(actGetInfo), keyEquivalent: "i") 109 | actionItemGetInfo.keyEquivalentModifierMask = [] 110 | 111 | menu.addItem(NSMenuItem.separator()) 112 | 113 | let actionItemSort = menu.addItem(withTitle: NSLocalizedString("Sort", comment: "排序"), action: nil, keyEquivalent: "") 114 | actionItemSort.keyEquivalentModifierMask = [] 115 | 116 | let sortSubmenu = NSMenu() 117 | let sortTypes: [(SortType, String)] = [ 118 | (.pathA, NSLocalizedString("sort-pathA", comment: "文件名")), 119 | (.pathZ, NSLocalizedString("sort-pathZ", comment: "文件名(倒序)")), 120 | (.createDateA, NSLocalizedString("sort-createDateA", comment: "创建日期")), 121 | (.createDateZ, NSLocalizedString("sort-createDateZ", comment: "创建日期(倒序)")), 122 | (.modDateA, NSLocalizedString("sort-modDateA", comment: "修改日期")), 123 | (.modDateZ, NSLocalizedString("sort-modDateZ", comment: "修改日期(倒序)")), 124 | (.addDateA, NSLocalizedString("sort-addDateA", comment: "添加日期")), 125 | (.addDateZ, NSLocalizedString("sort-addDateZ", comment: "添加日期(倒序)")) 126 | ] 127 | 128 | let currentDirTreeSortType = SortType(rawValue: Int(getViewController(self)!.publicVar.profile.getValue(forKey: "dirTreeSortType")) ?? 0) 129 | 130 | for (sortType, title) in sortTypes { 131 | let item = sortSubmenu.addItem(withTitle: title, action: #selector(actSortByType(_:)), keyEquivalent: "") 132 | item.representedObject = sortType 133 | if sortType == currentDirTreeSortType { 134 | item.state = .on 135 | } 136 | } 137 | 138 | actionItemSort.submenu = sortSubmenu 139 | 140 | menu.addItem(NSMenuItem.separator()) 141 | 142 | let actionItemDelete = menu.addItem(withTitle: NSLocalizedString("Move to Trash", comment: "移动到废纸篓"), action: #selector(actDelete), keyEquivalent: "\u{8}") 143 | actionItemDelete.keyEquivalentModifierMask = [] 144 | 145 | menu.addItem(NSMenuItem.separator()) 146 | 147 | let actionItemRename = menu.addItem(withTitle: NSLocalizedString("Rename", comment: "重命名"), action: #selector(actRename), keyEquivalent: "r") 148 | actionItemRename.keyEquivalentModifierMask = [] 149 | 150 | let actionItemCopy = menu.addItem(withTitle: NSLocalizedString("Copy", comment: "复制"), action: #selector(actCopy), keyEquivalent: "c") 151 | 152 | let actionItemCopyPath = menu.addItem(withTitle: NSLocalizedString("Copy Path", comment: "复制路径"), action: #selector(actCopyPath), keyEquivalent: "") 153 | 154 | let actionItemPaste = menu.addItem(withTitle: NSLocalizedString("Paste", comment: "粘贴"), action: #selector(actPaste), keyEquivalent: "v") 155 | actionItemPaste.isEnabled = canPasteOrMove 156 | 157 | let actionItemMove = menu.addItem(withTitle: NSLocalizedString("Move Here", comment: "移动到此"), action: #selector(actMove), keyEquivalent: "v") 158 | actionItemMove.keyEquivalentModifierMask = [.command,.option] 159 | actionItemMove.isEnabled = canPasteOrMove 160 | 161 | menu.addItem(NSMenuItem.separator()) 162 | 163 | let actionItemOpenInTerminal = menu.addItem(withTitle: NSLocalizedString("Open in Terminal", comment: "在终端中打开"), action: #selector(actOpenInTerminal), keyEquivalent: "") 164 | 165 | menu.addItem(NSMenuItem.separator()) 166 | 167 | let actionItemNewFolder = menu.addItem(withTitle: NSLocalizedString("New Folder", comment: "新建文件夹"), action: #selector(actNewFolder), keyEquivalent: "n") 168 | actionItemNewFolder.keyEquivalentModifierMask = [.command,.shift] 169 | 170 | menu.addItem(NSMenuItem.separator()) 171 | 172 | let actionItemRefresh = menu.addItem(withTitle: NSLocalizedString("Refresh", comment: "刷新"), action: #selector(refreshAll), keyEquivalent: "r") 173 | actionItemRefresh.keyEquivalentModifierMask = [.command] 174 | 175 | // 可以将点击的对象传递给菜单项动作 176 | menu.items.forEach { item in 177 | item.representedObject = item 178 | } 179 | 180 | menu.delegate = self 181 | 182 | return menu 183 | } 184 | 185 | return nil 186 | } 187 | 188 | func getFirstSelectedUrl() -> URL? { 189 | let selectedIndexes = self.selectedRowIndexes 190 | for index in selectedIndexes { 191 | if let item = self.item(atRow: index) as? TreeNode { 192 | return URL(string: item.fullPath) 193 | } 194 | } 195 | return nil 196 | } 197 | 198 | @objc func refreshTreeView() { 199 | getViewController(self)?.refreshTreeView() 200 | } 201 | 202 | @objc func refreshAll() { 203 | getViewController(self)?.handleUserRefresh() 204 | } 205 | 206 | @objc func actOpenInNewTab() { 207 | guard let url=URL(string: curRightClickedPath) else{return} 208 | if let appDelegate=NSApplication.shared.delegate as? AppDelegate { 209 | _ = appDelegate.createNewWindow(url.absoluteString) 210 | } 211 | } 212 | 213 | @objc func actOpenInFinder() { 214 | guard let url=URL(string: curRightClickedPath) else{return} 215 | NSWorkspace.shared.open(url) 216 | } 217 | 218 | @objc func actShowInFinder() { 219 | guard let file=URL(string: curRightClickedPath) else{return} 220 | // let folderPath = (file.path.replacingOccurrences(of: "file://", with: "").removingPercentEncoding! as NSString).deletingLastPathComponent 221 | // NSWorkspace.shared.selectFile(file.path.replacingOccurrences(of: "file://", with: "").removingPercentEncoding!, inFileViewerRootedAtPath: folderPath) 222 | NSWorkspace.shared.activateFileViewerSelecting([file]) 223 | } 224 | 225 | @objc func actGetInfo(isByKeyboard: Bool = false) { 226 | var url: URL? 227 | if isByKeyboard { 228 | url = getFirstSelectedUrl() 229 | }else{ 230 | url=URL(string: curRightClickedPath) 231 | } 232 | guard let url = url else {return} 233 | 234 | getViewController(self)?.handleGetInfo([url]) 235 | } 236 | 237 | @objc func actRename(isByKeyboard: Bool = false) { 238 | var url: URL? 239 | if isByKeyboard { 240 | url = getFirstSelectedUrl() 241 | }else{ 242 | url=URL(string: curRightClickedPath) 243 | } 244 | guard let url = url else {return} 245 | 246 | renameAlert(urls: [url]) 247 | 248 | if curRightClickedIndex != self.selectedRowIndexes.first { 249 | refreshTreeView() 250 | } 251 | } 252 | 253 | @objc func actNewFolder() { 254 | guard let url=URL(string: curRightClickedPath) else{return} 255 | guard let viewController = getViewController(self) else{return} 256 | 257 | if viewController.handleNewFolder(targetURL: url).0 { 258 | if curRightClickedIndex != self.selectedRowIndexes.first { 259 | refreshTreeView() 260 | } 261 | } 262 | } 263 | 264 | @objc func actCopy(isByKeyboard: Bool = false) { 265 | var url: URL? 266 | if isByKeyboard { 267 | url = getFirstSelectedUrl() 268 | }else{ 269 | url=URL(string: curRightClickedPath) 270 | } 271 | guard let url = url else {return} 272 | 273 | let pasteboard = NSPasteboard.general 274 | pasteboard.clearContents() // 清除剪贴板现有内容 275 | 276 | var urls=[URL]() 277 | urls.append(url) 278 | // 将文件URL添加到剪贴板 279 | pasteboard.writeObjects(urls as [NSPasteboardWriting]) 280 | } 281 | 282 | @objc func actDelete(isByKeyboard: Bool = false, isShowPrompt: Bool = true) { 283 | var url: URL? 284 | if isByKeyboard { 285 | url = getFirstSelectedUrl() 286 | }else{ 287 | url=URL(string: curRightClickedPath) 288 | } 289 | guard let url = url else {return} 290 | 291 | let result = getViewController(self)?.handleDelete(fileUrls: [url], isShowPrompt: isByKeyboard && isShowPrompt) 292 | 293 | if result == true && curRightClickedIndex != self.selectedRowIndexes.first { 294 | refreshTreeView() 295 | } 296 | } 297 | 298 | @objc func actPaste() { 299 | guard let url=URL(string: curRightClickedPath) else{return} 300 | getViewController(self)?.handlePaste(targetURL: url) 301 | 302 | if curRightClickedIndex != self.selectedRowIndexes.first { 303 | refreshTreeView() 304 | } 305 | } 306 | 307 | @objc func actMove() { 308 | guard let url=URL(string: curRightClickedPath) else{return} 309 | getViewController(self)?.handleMove(targetURL: url) 310 | 311 | if curRightClickedIndex != self.selectedRowIndexes.first { 312 | refreshTreeView() 313 | } 314 | } 315 | 316 | @objc func actCopyPath() { 317 | guard let url=URL(string: curRightClickedPath) else{return} 318 | let pasteboard = NSPasteboard.general 319 | pasteboard.clearContents() 320 | pasteboard.setString(url.path, forType: .string) 321 | } 322 | 323 | @objc func actOpenInTerminal() { 324 | guard let url=URL(string: curRightClickedPath) else{return} 325 | let task = Process() 326 | task.launchPath = "/usr/bin/open" 327 | task.arguments = ["-a", "Terminal", url.path] 328 | task.launch() 329 | } 330 | 331 | @objc func actSortByType(_ sender: NSMenuItem) { 332 | guard let sortType = sender.representedObject as? SortType else { return } 333 | getViewController(self)?.changeDirSortType(sortType: sortType) 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /FlowVision/Sources/SettingsViews/Base.lproj/ActionsSettingsViewController.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 70 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | Double-click to open/close the image. 106 | Hold down the right/left mouse button and scroll the wheel to zoom. 107 | Hold down the middle mouse button and drag to move the window. 108 | Long press the left mouse button to switch to 100% zoom. 109 | Long press the right mouse button to fit the image to the view. 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | Right/Left: Switch to the next/previous folder with images/videos (logically equivalent to the next folder when sorting all folders on the disk). 137 | Up: Switch to the parent directory. 138 | Down: Return to the previous directory. 139 | Up-Right: Switch to the next folder with images at the same level as the current folder. 140 | Down-Right: Close the tab/window. 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | In thumbnail view: W/S/A/D/E correspond to right-click gestures up/down/left/right/up-right. 168 | In image view: W/S zoom in/zoom out, Z zoom to 100%, X zoom to fit, A/D previous/next image. 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | Press Q in thumbnail view to activate Quick Search, allowing you to enter search keywords within a short time to locate files. In this mode, number keys, letter keys, and the backspace key are used for input. For a more comprehensive search function, please use Cmd + F. 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | Press Opt + 1~9 to switch to the corresponding profile. 223 | Press Opt + Cmd + 1~9 to save the current layout and style to the corresponding profile. 224 | The configurations involved in switching include: whether to display the sidebar, view type, sorting method, thumbnail size, and custom styles (such as whether to display file names and the width of the thumbnail border). 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | -------------------------------------------------------------------------------- /FlowVision/Sources/SettingsViews/Base.lproj/GeneralSettingsViewController.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | When this option is enabled, the multi-tab bar cannot be used. 119 | Restart the application for changes to take effect. 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 172 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | "Files and Folders" permission is required to open directories and files. 245 | "Automation" permission is required to retain original location information when deleting to the Trash (without this permission, the "Put Back" feature in the Trash will not work). 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | Click the button to set this application as the default program for common image formats. For other image formats such as PSD and RAW, please associate them manually. 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | --------------------------------------------------------------------------------