├── .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://github.com/netdcy/FlowVision/releases/latest?color=blue "GitHub release") 
7 |
8 | ## 预览
9 |
10 | ### 浅色模式
11 | 
12 |
13 | ### 黑暗模式
14 | 
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://github.com/netdcy/FlowVision/releases/latest?color=blue "GitHub release") 
8 |
9 | ## Screenshots
10 |
11 | ### Light Mode
12 | 
13 |
14 | ### Dark Mode
15 | 
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 | [](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 |
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 |
--------------------------------------------------------------------------------