├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── assets ├── assets.py ├── favicon.ico ├── image.rcc ├── visual-file.icns └── visual-file.png ├── camera.py ├── data_struct ├── number_vector.py ├── rectangle.py └── text.py ├── docs ├── cannonwar-project.jpg ├── details.jpg ├── shape.jpg ├── show.png └── show1.png ├── entity ├── entity.py ├── entity_file.py └── entity_folder.py ├── exclude_dialog.py ├── exclude_manager.py ├── file_observer.py ├── file_openner.py ├── main.py ├── paint ├── paint_elements.py ├── paint_utils.py ├── paintables.py └── painters.py ├── pyproject.toml ├── requirements.txt ├── style └── styles.py ├── tests ├── gitignore_test.py └── test.py ├── tools ├── color_utils.py ├── gitignore_parser.py ├── rectangle_packing.py ├── string_tools.py └── threads.py └── visual-file.spec /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | __pycache__ 3 | .idea/ 4 | venv/ 5 | build/ 6 | dist/ 7 | .venv/ -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Paintable", 4 | "paintables" 5 | ] 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2024 LiRenTech 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 项目代码直观查看工具 visual-file 2 | 3 | ## 效果图 4 | 5 | 默认生成效果 6 | 7 | ![默认生成效果](docs/show.png) 8 | 9 | 自定义摆放后 10 | 11 | ![cannonwar-project](docs/cannonwar-project.jpg) 12 | 13 | 放大细节 14 | 15 | ![details](docs/details.jpg) 16 | 17 | ## 特点 18 | 19 | 1. 直观的展示项目文件结构,支持多层级目录结构 20 | 21 | 2. 双击矩形打开文件 22 | 23 | 3. 无限放大缩小、无边界移动、自由拖拽摆放布局并保存 24 | 25 | ## 解决痛点 26 | 27 | 项目太大,文件太多,普通的编辑器左侧列表太长,翻阅文件困难,需要一个工具来快速找到并打开文件。 28 | 29 | 我们是三维空间中的碳基生命,我们的视网膜是二维的,所以二维信息包含了上下左右等相对位置信息。比IDE里的一维缩进列表更加丰富。我们可以利用好二维空间、更有逻辑地来摆放文件,比如摆的越靠上可以代表越接近逻辑顶层,越靠下可以代表越接近工具底层、越靠左代表越接近数据层、越靠右代表越接近UI交互层……,(您可以发挥任意想法)。 30 | 31 | 或者以一定的图案摆放,这样一看到我们自己摆放的图案,想到找某个文件就能立刻根据空间记忆立刻找到,双击即可打开。不需要再在一维的列表中来回上下翻找。 32 | 33 | ![形状自由摆放](docs/shape.jpg) 34 | 35 | 如上所示,可以把这五个 ts 文件根据继承关系摆放成三角形,方便快速找到。 36 | 37 | 类似 SpaceSniffer,屏幕上铺满了大大小小的嵌套的矩形框,每个矩形框代表一个文件夹或者文件,以二维的方式直观的打开项目工程文件,所有代码文件一览无余的,以二维的方式直观的展现在面前。 38 | 39 | 双击某个矩形框打开代码文件的原理: 40 | 41 | python 调用系统默认程序打开文件。直接导致编辑器里实现了打开某个代码的功能。 42 | 需要提前将 ts 文件的默认打开方式设置成对应的编辑器程序,比如 vscode。 43 | 44 | ``` 45 | os.startfile(full_path_file) 46 | ``` 47 | 48 | ## 使用提示 49 | 50 | windows系统不知道为什么,可能会报警病毒,但源代码全部开源,无恶意代码,请放心使用。 51 | 52 | 实际上在我打包的时候就直接报警有病毒强制给删了。所以不得不手动设置信任才能成功打包。 53 | 54 | 默认的全局排除里会排除.git 等文件目录,可以根据自己的需要进行修改。 55 | 56 | ## TODO: 57 | 58 | 为不同的后缀名文件渲染不同的图标。 59 | 60 | 当缩小到一定程度的时候,文件夹里面的内容不显示,只显示一个大的文件夹名称 61 | 62 | 全局排除的正则排除功能 63 | 64 | 65 | 66 | ## 开发相关: 67 | 68 | 此项目为LiRen团队开源项目,贡献代码前建议阅读开发规范文档中的python内容:https://liren.zty012.de/ 69 | 70 | 更新 assets 资源文件指令 71 | 72 | ```commandline 73 | pyrcc5 -o assets/image.rcc -o assets/assets.py 74 | ``` 75 | 76 | 打包指令 77 | 78 | ```commandline 79 | windows: 80 | pyinstaller --onefile --windowed --icon=./assets/favicon.ico main.py -n visual-file 81 | macOS: 82 | pyinstaller --onefile --windowed --icon=./assets/visual-file.icns main.py -n visual-file 83 | ``` 84 | 85 | ## 布局文件格式 86 | 87 | ```js 88 | { 89 | "layout": [ 90 | { 91 | "kind": "directory" | "file", 92 | "name": "abc", // 文件夹名字或者文件名,不需要全路径,只需要一个名字即可 93 | "bodyShape": { 94 | "width": 500, 95 | "height": 100, 96 | "locationLeftTop": [155, 4154] 97 | }, 98 | "children": [ 99 | // ...继续嵌套 100 | ] 101 | } 102 | ] 103 | } 104 | ``` 105 | -------------------------------------------------------------------------------- /assets/assets.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Resource object code 4 | # 5 | # Created by: The Resource Compiler for PyQt5 (Qt v5.15.2) 6 | # 7 | # WARNING! All changes made in this file will be lost! 8 | 9 | from PyQt5 import QtCore 10 | 11 | qt_resource_data = b"\ 12 | \x00\x00\x00\xc2\ 13 | \x00\ 14 | \x00\x42\x3e\x78\x9c\xed\xd4\xbb\x0d\xc2\x30\x14\x40\xd1\x97\x0c\ 15 | \x41\x4d\x41\x91\x31\x22\x26\x63\x04\x66\x63\xa1\x60\x81\x68\x50\ 16 | \x28\x80\xe0\x0f\x3e\x2f\xba\x29\x5c\x24\x3e\x92\xe5\x88\x21\x3d\ 17 | \xf3\x1c\xe9\xbd\x8f\xe9\x18\xb1\x8b\x88\x29\x95\x96\xe2\x14\xf7\ 18 | \xf5\xdb\xcc\xb1\x36\x8b\x24\x49\x92\xf4\x68\x39\xb7\x1d\xff\x36\ 19 | \xfe\xd2\xe7\xb0\xd4\xbe\xf9\xf9\xf9\x57\xd6\x2f\x75\x35\x8e\x7d\ 20 | \xfb\x87\xa1\x8c\xbf\xf8\x39\x7f\xb1\x0f\x7e\x7e\x7e\xfe\xdc\xfe\ 21 | \xc3\xf2\x7d\xfc\xed\xfb\x3f\xf9\x17\x3f\x3f\xff\x7f\xf8\x7b\xbf\ 22 | \xff\x7a\xf5\xe7\x8e\x9f\x9f\xbf\x1e\xff\x16\xf7\xdf\x3b\xf7\x23\ 23 | \x7f\x9d\xfe\x5f\x9c\x75\x7e\x7e\xfe\x36\xfc\xbd\xdf\x7f\xbd\xfa\ 24 | \x73\xc7\xcf\xcf\x5f\xce\x5f\x4b\xfc\x79\xfd\xb5\xc7\xcf\xcf\xbf\ 25 | \xdd\x77\x5a\x8d\x5f\x92\x24\x49\x4f\x99\x8e\xe7\x0a\x68\xe4\xda\ 26 | \x17\ 27 | " 28 | 29 | qt_resource_name = b"\ 30 | \x00\x0b\ 31 | \x0a\xb8\x56\x7f\ 32 | \x00\x66\ 33 | \x00\x61\x00\x76\x00\x69\x00\x63\x00\x6f\x00\x6e\x00\x2e\x00\x69\x00\x63\x00\x6f\ 34 | " 35 | 36 | qt_resource_struct_v1 = b"\ 37 | \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ 38 | \x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\ 39 | " 40 | 41 | qt_resource_struct_v2 = b"\ 42 | \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ 43 | \x00\x00\x00\x00\x00\x00\x00\x00\ 44 | \x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\ 45 | \x00\x00\x01\x90\xe9\xd6\xcf\x0a\ 46 | " 47 | 48 | qt_version = [int(v) for v in QtCore.qVersion().split(".")] 49 | if qt_version < [5, 8, 0]: 50 | rcc_version = 1 51 | qt_resource_struct = qt_resource_struct_v1 52 | else: 53 | rcc_version = 2 54 | qt_resource_struct = qt_resource_struct_v2 55 | 56 | 57 | def qInitResources(): 58 | QtCore.qRegisterResourceData( 59 | rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data 60 | ) 61 | 62 | 63 | def qCleanupResources(): 64 | QtCore.qUnregisterResourceData( 65 | rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data 66 | ) 67 | 68 | 69 | qInitResources() 70 | -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LiRenTech/visual-file-qt/8221e2a893732c5798a7c0ceb51f98f8373a26ae/assets/favicon.ico -------------------------------------------------------------------------------- /assets/image.rcc: -------------------------------------------------------------------------------- 1 | 2 | 3 | favicon.ico 4 | 5 | 6 | -------------------------------------------------------------------------------- /assets/visual-file.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LiRenTech/visual-file-qt/8221e2a893732c5798a7c0ceb51f98f8373a26ae/assets/visual-file.icns -------------------------------------------------------------------------------- /assets/visual-file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LiRenTech/visual-file-qt/8221e2a893732c5798a7c0ceb51f98f8373a26ae/assets/visual-file.png -------------------------------------------------------------------------------- /camera.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | 3 | from PyQt5.QtGui import QTransform 4 | 5 | from data_struct.number_vector import NumberVector 6 | from data_struct.rectangle import Rectangle 7 | 8 | 9 | class Camera: 10 | # 每个方向上的动力矢量大小 11 | moveAmplitude = 2 12 | # 摩擦系数,越大摩擦力越大,摩擦力会使速度减慢 13 | frictionCoefficient = 0.1 14 | 15 | frictionExponent = 1.5 16 | 17 | scaleExponent = 1.1 # 缩放指数,越大缩放速度越快 18 | 19 | SCALE_MAX = 5000 20 | SCALE_MIN = 0.0000001 21 | 22 | """ 23 | 空气摩擦力速度指数 24 | 指数=2,表示 f = -k * v^2 25 | 指数=1,表示 f = -k * v 26 | 指数越大,速度衰减越快 27 | """ 28 | 29 | def __init__(self, location: NumberVector, view_width: float, view_height: float): 30 | # 相机位置,(世界位置) 31 | self.location = location 32 | 33 | # 最终地渲染框大小,这两个是在屏幕上的渲染宽度 34 | self.view_width = view_width 35 | self.view_height = view_height 36 | 37 | # 大于1表示放大,小于1表示缩小 38 | self.current_scale = 1.0 39 | 40 | self.target_scale = 1.0 41 | 42 | self.speed = NumberVector(0, 0) 43 | self.accelerate = NumberVector(0, 0) 44 | # 可以看成一个九宫格,主要用于处理 w s a d 按键移动, 45 | self.accelerateCommander = NumberVector(0, 0) 46 | self.is_scale_animation_open = True 47 | # 当前摄像机的透视能力,0表示连根文件夹都看不透,1表示能看透一层,2表示能看透两层 48 | self.perspective_level = 3 49 | 50 | def add_perspective_level(self): 51 | self.perspective_level += 1 52 | if self.perspective_level > 118: # windows最大文件夹深度就是118 53 | self.perspective_level = 118 54 | 55 | def reduce_perspective_level(self): 56 | self.perspective_level -= 1 57 | if self.perspective_level < 0: 58 | self.perspective_level = 0 59 | 60 | def reset(self): 61 | """外界调用,重置相机状态""" 62 | self.target_scale = 1.0 63 | self.location = NumberVector(0, 0) 64 | 65 | def set_fast_mode(self): 66 | self.scaleExponent = 1.5 67 | 68 | def set_slow_mode(self): 69 | self.scaleExponent = 1.1 70 | 71 | def set_scale_animation(self, is_open: bool): 72 | self.is_scale_animation_open = is_open 73 | 74 | def reset_view_size(self, view_width: float, view_height: float): 75 | """ 76 | 由于外层渲染区域大小发生变化,需要重新设置相机视野大小 77 | :param view_width: 78 | :param view_height: 79 | :return: 80 | """ 81 | self.view_width = view_width 82 | self.view_height = view_height 83 | 84 | def press_move(self, move_vector: NumberVector): 85 | """ 86 | 87 | :param move_vector: 四个方向的 上下左右 单位向量 88 | :return: 89 | """ 90 | self.accelerateCommander += move_vector 91 | self.accelerateCommander = self.accelerateCommander.limit_x(-1, 1) 92 | self.accelerateCommander = self.accelerateCommander.limit_y(-1, 1) 93 | 94 | def release_move(self, move_vector: NumberVector): 95 | self.accelerateCommander -= move_vector 96 | self.accelerateCommander = self.accelerateCommander.limit_x(-1, 1) 97 | self.accelerateCommander = self.accelerateCommander.limit_y(-1, 1) 98 | 99 | def zoom_in(self): 100 | if self.is_scale_animation_open: 101 | self.target_scale *= self.scaleExponent 102 | else: 103 | self.current_scale *= self.scaleExponent 104 | 105 | def zoom_out(self): 106 | if self.is_scale_animation_open: 107 | self.target_scale /= self.scaleExponent 108 | else: 109 | self.current_scale /= self.scaleExponent 110 | 111 | def tick(self): 112 | try: 113 | # 计算摩擦力 114 | friction = NumberVector.zero() 115 | if not self.speed.is_zero(): 116 | speed_size = self.speed.magnitude() 117 | friction = ( 118 | self.speed.normalize() 119 | * -1 120 | * (self.frictionCoefficient * speed_size ** self.frictionExponent) 121 | ) 122 | self.speed += self.accelerateCommander * ( 123 | self.moveAmplitude * (1 / self.current_scale) 124 | ) 125 | self.speed += friction 126 | 127 | self.location += self.speed 128 | 129 | # 让 current_scale 逐渐靠近 target_scale 130 | if self.is_scale_animation_open: 131 | self.current_scale += (self.target_scale - self.current_scale) / 10 132 | 133 | # 彩蛋,《微观尽头》——刘慈欣 134 | 135 | if self.current_scale > self.SCALE_MAX: 136 | self.current_scale = self.SCALE_MIN * 2 137 | self.target_scale = self.SCALE_MIN * 2 138 | elif self.current_scale < self.SCALE_MIN: 139 | self.current_scale = self.SCALE_MAX - 1 140 | self.target_scale = self.SCALE_MAX - 1 141 | except Exception as e: 142 | traceback.print_exc() 143 | print(e) 144 | 145 | @property 146 | def cover_world_rectangle(self) -> Rectangle: 147 | """ 148 | 获取摄像机视野范围内所覆盖住的世界范围矩形 149 | :return: 返回的矩形是世界坐标下的矩形 150 | """ 151 | width = self.view_width / self.current_scale 152 | height = self.view_height / self.current_scale 153 | 154 | return Rectangle( 155 | NumberVector(self.location.x - width / 2, self.location.y - height / 2), 156 | width, 157 | height, 158 | ) 159 | 160 | def location_world2view(self, world_location: NumberVector) -> NumberVector: 161 | """ 162 | 将世界坐标转换成视野渲染坐标 163 | :param world_location: 164 | :return: 165 | """ 166 | diff: NumberVector = NumberVector(self.view_width / 2, self.view_height / 2) 167 | v: NumberVector = (world_location - self.location) * self.current_scale 168 | return v + diff 169 | 170 | def location_view2world(self, view_location: NumberVector) -> NumberVector: 171 | """ 172 | 将视野渲染坐标转换成世界坐标 173 | :param view_location: 174 | :return: 175 | """ 176 | v: NumberVector = ( 177 | view_location - NumberVector(self.view_width / 2, self.view_height / 2) 178 | ) / self.current_scale 179 | return v + self.location 180 | 181 | def get_world2view_transform(self) -> QTransform: 182 | q_translate_center = QTransform().translate(-self.location.x, -self.location.y) 183 | q_sacle = QTransform().scale(self.current_scale, self.current_scale) 184 | q_translate_offset = QTransform().translate( 185 | self.view_width / 2, self.view_height / 2 186 | ) 187 | return q_translate_center * q_sacle * q_translate_offset 188 | -------------------------------------------------------------------------------- /data_struct/number_vector.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | 4 | class NumberVector: 5 | 6 | def __init__(self, x: float, y: float): 7 | self.x: float = x 8 | self.y: float = y 9 | 10 | @staticmethod 11 | def zero(): 12 | return NumberVector(0, 0) 13 | 14 | def is_zero(self): 15 | return self.x == 0 and self.y == 0 16 | 17 | def integer(self): 18 | return NumberVector(round(self.x), round(self.y)) 19 | 20 | def normalize(self): 21 | """将此向量转化为单位向量""" 22 | return self / self.__len__() 23 | 24 | def limit_x(self, min_value: float, max_value: float): 25 | return NumberVector(max(min(self.x, max_value), min_value), self.y) 26 | 27 | def limit_y(self, min_value: float, max_value: float): # 限制y轴的范围 28 | return NumberVector(self.x, max(min(self.y, max_value), min_value)) 29 | 30 | def clone(self): 31 | return NumberVector(self.x, self.y) 32 | 33 | def __str__(self): 34 | return f"({self.x}, {self.y})" 35 | 36 | def __repr__(self): 37 | return f"NumberVector({self.x}, {self.y})" 38 | 39 | def __add__(self, other): 40 | return NumberVector(self.x + other.x, self.y + other.y) 41 | 42 | def __sub__(self, other): 43 | if isinstance(other, NumberVector): 44 | return NumberVector(self.x - other.x, self.y - other.y) 45 | else: 46 | return NumberVector(self.x - other, self.y - other) 47 | 48 | def __mul__(self, other) -> "NumberVector": 49 | if isinstance(other, NumberVector): 50 | return NumberVector(self.x * other.x, self.y * other.y) 51 | else: 52 | return NumberVector(self.x * other, self.y * other) 53 | 54 | def __truediv__(self, other): 55 | if isinstance(other, NumberVector): 56 | return NumberVector(self.x / other.x, self.y / other.y) 57 | else: 58 | return NumberVector(self.x / other, self.y / other) 59 | 60 | def __eq__(self, other): 61 | if isinstance(other, NumberVector): 62 | return self.x == other.x and self.y == other.y 63 | else: 64 | return False 65 | 66 | def __ne__(self, other): 67 | if isinstance(other, NumberVector): 68 | return self.x != other.x or self.y != other.y 69 | else: 70 | return True 71 | 72 | def __neg__(self): 73 | return NumberVector(-self.x, -self.y) 74 | 75 | def __pos__(self): 76 | return NumberVector(+self.x, +self.y) 77 | 78 | def __abs__(self): 79 | return NumberVector(abs(self.x), abs(self.y)) 80 | 81 | def __round__(self, n=None): 82 | return NumberVector(round(self.x, n), round(self.y, n)) 83 | 84 | def __floor__(self): 85 | return NumberVector(math.floor(self.x), math.floor(self.y)) 86 | 87 | def __ceil__(self): 88 | return NumberVector(math.ceil(self.x), math.ceil(self.y)) 89 | 90 | def __trunc__(self): 91 | return NumberVector(math.trunc(self.x), math.trunc(self.y)) 92 | 93 | def __iadd__(self, other): 94 | if isinstance(other, NumberVector): 95 | self.x += other.x 96 | self.y += other.y 97 | else: 98 | self.x += other 99 | self.y += other 100 | return self 101 | 102 | def __isub__(self, other): 103 | if isinstance(other, NumberVector): 104 | self.x -= other.x 105 | self.y -= other.y 106 | else: 107 | self.x -= other 108 | self.y -= other 109 | return self 110 | 111 | def __imul__(self, other): 112 | if isinstance(other, NumberVector): 113 | self.x *= other.x 114 | self.y *= other.y 115 | else: 116 | self.x *= other 117 | self.y *= other 118 | return self 119 | 120 | def __len__(self) -> float: 121 | """返回向量的模长""" 122 | return math.sqrt(self.x ** 2 + self.y ** 2) 123 | 124 | def magnitude(self) -> float: 125 | """返回向量的模长""" 126 | return math.sqrt(self.x ** 2 + self.y ** 2) 127 | -------------------------------------------------------------------------------- /data_struct/rectangle.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from data_struct.number_vector import NumberVector 4 | 5 | 6 | class Rectangle: 7 | def __init__(self, location_left_top: NumberVector, width: float, height: float): 8 | self.location_left_top = location_left_top 9 | self.width: float = width 10 | self.height: float = height 11 | 12 | def output_data(self) -> dict[str, Any]: 13 | return { 14 | "width": self.width, 15 | "height": self.height, 16 | "locationLeftTop": [self.location_left_top.x, self.location_left_top.y], 17 | } 18 | 19 | def read_data(self, data: dict[str, Any]): 20 | if "width" not in data or "height" not in data or "locationLeftTop" not in data: 21 | raise ValueError("bodyShape 更新失败,缺少必要参数") 22 | self.width = data["width"] 23 | self.height = data["height"] 24 | self.location_left_top = NumberVector( 25 | data["locationLeftTop"][0], data["locationLeftTop"][1] 26 | ) 27 | 28 | def __contains__(self, item: NumberVector) -> bool: 29 | return ( 30 | self.location_left_top.x <= item.x <= self.location_left_top.x + self.width 31 | ) and ( 32 | self.location_left_top.y <= item.y <= self.location_left_top.y + self.height 33 | ) 34 | 35 | def clone(self) -> "Rectangle": 36 | return Rectangle(self.location_left_top.clone(), self.width, self.height) 37 | 38 | def right(self): 39 | """返回最右侧的x坐标 40 | 41 | Returns: 42 | float: 最右侧x坐标 43 | """ 44 | return self.location_left_top.x + self.width 45 | 46 | def left(self): 47 | return self.location_left_top.x 48 | 49 | def top(self): 50 | return self.location_left_top.y 51 | 52 | def bottom(self): 53 | return self.location_left_top.y + self.height 54 | 55 | @staticmethod 56 | def from_edges(left: float, top: float, right: float, bottom: float) -> "Rectangle": 57 | """通过四条边来创建矩形""" 58 | return Rectangle(NumberVector(left, top), right - left, bottom - top) 59 | 60 | def get_fore_points(self) -> list[NumberVector]: 61 | return [ 62 | NumberVector(self.location_left_top.x, self.location_left_top.y), 63 | NumberVector( 64 | self.location_left_top.x + self.width, self.location_left_top.y 65 | ), 66 | NumberVector( 67 | self.location_left_top.x + self.width, 68 | self.location_left_top.y + self.height, 69 | ), 70 | NumberVector( 71 | self.location_left_top.x, self.location_left_top.y + self.height 72 | ), 73 | ] 74 | 75 | @property 76 | def center(self) -> NumberVector: 77 | return NumberVector( 78 | self.location_left_top.x + self.width / 2, 79 | self.location_left_top.y + self.height / 2, 80 | ) 81 | 82 | def is_collision(self, rect: "Rectangle", margin: float = 0) -> bool: 83 | """判断self是否与rect之间的最小边距小于margin。 84 | 当margin=0时,此时为判断self与rect是否重叠 85 | 86 | Args: 87 | rect (Rectangle): 待判断的矩形 88 | margin (float, optional): 判断的边距,当边距等于0时,为碰撞检测. Defaults to 0. 89 | 90 | Returns: 91 | bool: self是否与rect之间的最小边距小于margin 92 | """ 93 | collision_x = ( 94 | self.right() - rect.left() > -margin 95 | and rect.right() - self.left() > -margin 96 | ) 97 | collision_y = ( 98 | self.bottom() - rect.top() > -margin 99 | and rect.bottom() - self.top() > -margin 100 | ) 101 | return collision_x and collision_y 102 | 103 | def is_contain(self, rect: "Rectangle") -> bool: 104 | """判断是否包含另一个矩形,另一个矩形是否被套在自己内部""" 105 | return ( 106 | self.left() <= rect.left() 107 | and self.right() >= rect.right() 108 | and self.top() <= rect.top() 109 | and self.bottom() >= rect.bottom() 110 | ) 111 | 112 | def is_contain_point(self, point: NumberVector) -> bool: 113 | """判断是否包含点""" 114 | return ( 115 | self.left() <= point.x <= self.right() 116 | and self.top() <= point.y <= self.bottom() 117 | ) 118 | 119 | def __repr__(self): 120 | return f"Rectangle({self.location_left_top}, {self.width}, {self.height})" 121 | 122 | 123 | # test 124 | if __name__ == "__main__": 125 | r1 = Rectangle(NumberVector(0, 0), 10, 10) 126 | r2 = Rectangle(NumberVector(5, 5), 10, 10) 127 | print(r1.is_collision(r2)) # True 128 | print(r2.is_collision(r1)) # True 129 | print(r1.center) # (5, 5) 130 | -------------------------------------------------------------------------------- /data_struct/text.py: -------------------------------------------------------------------------------- 1 | from data_struct.number_vector import NumberVector 2 | 3 | 4 | class Text: 5 | def __init__(self, left_top: NumberVector, text: str): 6 | self.left_top = left_top 7 | self.text = text 8 | -------------------------------------------------------------------------------- /docs/cannonwar-project.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LiRenTech/visual-file-qt/8221e2a893732c5798a7c0ceb51f98f8373a26ae/docs/cannonwar-project.jpg -------------------------------------------------------------------------------- /docs/details.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LiRenTech/visual-file-qt/8221e2a893732c5798a7c0ceb51f98f8373a26ae/docs/details.jpg -------------------------------------------------------------------------------- /docs/shape.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LiRenTech/visual-file-qt/8221e2a893732c5798a7c0ceb51f98f8373a26ae/docs/shape.jpg -------------------------------------------------------------------------------- /docs/show.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LiRenTech/visual-file-qt/8221e2a893732c5798a7c0ceb51f98f8373a26ae/docs/show.png -------------------------------------------------------------------------------- /docs/show1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LiRenTech/visual-file-qt/8221e2a893732c5798a7c0ceb51f98f8373a26ae/docs/show1.png -------------------------------------------------------------------------------- /entity/entity.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta 2 | from typing import Callable 3 | 4 | from data_struct.number_vector import NumberVector 5 | from data_struct.rectangle import Rectangle 6 | from paint.paintables import Paintable 7 | 8 | 9 | class Entity(Paintable, metaclass=ABCMeta): 10 | """ 11 | 实体类 12 | 场景里参与碰撞检测的都算实体 13 | """ 14 | 15 | def __init__(self, body_shape: Rectangle): 16 | self.body_shape = body_shape 17 | 18 | # 拖拽点相对于原点的偏移,从左上角点指向拖拽点 19 | self.dragging_offset: NumberVector = NumberVector(0, 0) 20 | 21 | def move(self, d_location: NumberVector): 22 | """ 23 | 移动实体 24 | :param d_location: 25 | :return: 26 | """ 27 | self.body_shape.location_left_top += d_location 28 | 29 | def move_to(self, location: NumberVector): 30 | """ 31 | 移动实体到指定位置,让实体的左上角顶点对齐到指定位置 32 | :param location: 33 | :return: 34 | """ 35 | self.body_shape.location_left_top = location 36 | 37 | def collide_with(self, other: "Entity"): 38 | # 如果发生了碰撞,则计算两个矩形的几何中心,被撞的矩形按照几何中心连线,根据这个连线继续判断 39 | self_center_location = self.body_shape.center 40 | entity_center_location = other.body_shape.center 41 | # 让它放在及其贴近自己的边缘的位置上。 42 | 43 | # self_center_location -> entity_center_location 44 | d_distance = entity_center_location - self_center_location 45 | # fmt: off 46 | choice_func: list[list[Callable]] = [ 47 | # x < 0 , x == 0 , x > 0 48 | [self._move_left_up, self._move_up, self._move_right_up], # y < 0 49 | [self._move_left, self._move_down, self._move_right], # y == 0 50 | [self._move_left_down, self._move_down, self._move_right_down], # y > 0 51 | ] 52 | # fmt: on 53 | 54 | def number_to_index(x): 55 | if x < 0: 56 | return 0 57 | elif x == 0: 58 | return 1 59 | else: 60 | return 2 61 | 62 | move_strategy = choice_func[number_to_index(d_distance.y)][ 63 | number_to_index(d_distance.x) 64 | ] 65 | move_strategy(other) 66 | 67 | # 以下是一些操作 68 | # _move_xxx 表示被自己挤压的矩形是自己什么方向的矩形 69 | # 例如 _move_left 表示被挤压的矩形是自己左边的矩形 other表示挤压的矩形 70 | 71 | def _move_right(self, other: "Entity"): 72 | d_x = self.body_shape.right() - other.body_shape.left() 73 | other.move(NumberVector(d_x, 0)) 74 | 75 | def _move_left(self, other: "Entity"): 76 | d_x = self.body_shape.left() - other.body_shape.right() 77 | other.move(NumberVector(d_x, 0)) 78 | 79 | def _move_up(self, other: "Entity"): 80 | d_y = self.body_shape.top() - other.body_shape.bottom() 81 | other.move(NumberVector(0, d_y)) 82 | 83 | def _move_down(self, other: "Entity"): 84 | d_y = self.body_shape.bottom() - other.body_shape.top() 85 | other.move(NumberVector(0, d_y)) 86 | 87 | # 上面四种非常难做到,因为人手动拖拽很难完全对准,并且又是float类型 88 | # 所以基本上是矩形的斜向挤压问题 89 | # 这个时候就要看矩形产生挤压时的重叠部分的 子矩形形状 ,是宽大于高还是高大于宽 90 | # 怎么看重叠部分的矩形形状?就看两个矩形互相进去的两个角的位置就可以了 91 | 92 | def _move_left_up(self, other: "Entity"): 93 | w = abs(self.body_shape.left() - other.body_shape.right()) 94 | h = abs(self.body_shape.top() - other.body_shape.bottom()) 95 | if w > h: 96 | self._move_up(other) 97 | else: 98 | self._move_left(other) 99 | 100 | def _move_right_up(self, other: "Entity"): 101 | w = abs(self.body_shape.right() - other.body_shape.left()) 102 | h = abs(self.body_shape.top() - other.body_shape.bottom()) 103 | if w > h: 104 | self._move_up(other) 105 | else: 106 | self._move_right(other) 107 | 108 | def _move_left_down(self, other: "Entity"): 109 | w = abs(self.body_shape.left() - other.body_shape.right()) 110 | h = abs(self.body_shape.bottom() - other.body_shape.top()) 111 | if w > h: 112 | self._move_down(other) 113 | else: 114 | self._move_left(other) 115 | 116 | def _move_right_down(self, other: "Entity"): 117 | w = abs(self.body_shape.right() - other.body_shape.left()) 118 | h = abs(self.body_shape.bottom() - other.body_shape.top()) 119 | if w > h: 120 | self._move_down(other) 121 | else: 122 | self._move_right(other) 123 | -------------------------------------------------------------------------------- /entity/entity_file.py: -------------------------------------------------------------------------------- 1 | """ 2 | 文件矩形实体 3 | """ 4 | 5 | from typing import Any, List 6 | 7 | from data_struct.number_vector import NumberVector 8 | from data_struct.rectangle import Rectangle 9 | from data_struct.text import Text 10 | from entity.entity import Entity 11 | from paint.paintables import PaintContext, Paintable 12 | from tools.string_tools import get_width_by_file_name 13 | 14 | 15 | class EntityFile(Entity): 16 | """ 17 | 文件矩形 18 | """ 19 | 20 | def __init__( 21 | self, location_left_top: NumberVector, full_path: str, parent: "EntityFolder" # type: ignore 22 | ): 23 | """ 24 | 左上角的位置 25 | :param location_left_top: 26 | """ 27 | full_path = full_path.replace("\\", "/") 28 | 29 | file_name = full_path.split("/")[-1] 30 | 31 | self.full_path = full_path 32 | self.location = location_left_top 33 | self.deep_level = 0 # 相对深度,0表示最外层 34 | self.body_shape = Rectangle( 35 | location_left_top, get_width_by_file_name(file_name), 100 36 | ) 37 | super().__init__(self.body_shape) 38 | # 最终用于显示的名字 39 | self.file_name = file_name 40 | 41 | self.parent = parent # parent是EntityFolder 但会循环引入,这里就没有写类型 42 | pass 43 | 44 | def move(self, d_location: NumberVector): 45 | """ 46 | 文件夹移动 47 | """ 48 | super().move(d_location) 49 | if not self.parent: 50 | return 51 | # 推移其他同层的兄弟矩形框 52 | brother_entities: list[Entity] = self.parent.children 53 | 54 | # d_location 经过测试发现不是0 55 | 56 | for entity in brother_entities: 57 | if entity == self: 58 | continue 59 | 60 | if self.body_shape.is_collision(entity.body_shape): 61 | self.collide_with(entity) 62 | # 还要让父文件夹收缩调整 63 | self.parent.adjust() 64 | 65 | def output_data(self) -> dict[str, Any]: 66 | return { 67 | "kind": "file", 68 | "name": self.file_name, 69 | "bodyShape": self.body_shape.output_data(), 70 | } 71 | 72 | def read_data(self, data: dict[str, Any]): 73 | if data["kind"] != "file": 74 | raise ValueError("kind should be file") 75 | if data["name"] != self.file_name: 76 | raise ValueError("读取的文件名不匹配", data["name"], self.file_name) 77 | 78 | self.body_shape.read_data(data["bodyShape"]) 79 | 80 | def __repr__(self): 81 | return f"({self.file_name})" 82 | 83 | def get_components(self) -> List[Paintable]: 84 | return [] 85 | 86 | def paint(self, context: PaintContext) -> None: 87 | context.painter.paint_rect(self.body_shape) 88 | context.painter.paint_text( 89 | Text(self.body_shape.location_left_top + NumberVector(5, 5), self.file_name) 90 | ) 91 | -------------------------------------------------------------------------------- /entity/entity_folder.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Any 2 | 3 | from data_struct.number_vector import NumberVector 4 | from data_struct.rectangle import Rectangle 5 | from data_struct.text import Text 6 | from entity.entity import Entity 7 | from entity.entity_file import EntityFile 8 | from exclude_manager import EXCLUDE_MANAGER 9 | from paint.paintables import PaintContext, Paintable 10 | from tools.gitignore_parser import parse_gitignore 11 | from tools.rectangle_packing import ( 12 | sort_rectangle_all_files, 13 | sort_rectangle_greedy, 14 | sort_rectangle_many_files_less_folders, 15 | ) 16 | from tools.string_tools import get_width_by_file_name 17 | 18 | # 提高递归深度限制 19 | import sys 20 | 21 | sys.setrecursionlimit(10_0000) 22 | 23 | 24 | class EntityFolder(Entity): 25 | """ 26 | 文件夹矩形 27 | """ 28 | 29 | PADDING = 50 30 | 31 | # 通用排除的文件夹 32 | exclusion_list = [ 33 | ".git", # gitignore文件本身不排除.git文件夹,所以这里强制排除 34 | "__pycache__", # 貌似出了一些bug parse_gitignore 没有排除这个文件夹 35 | ".idea", 36 | ] 37 | 38 | def __init__(self, location_left_top: NumberVector, full_path: str): 39 | 40 | full_path = full_path.replace("\\", "/") 41 | folder_name = full_path.split("/")[-1] 42 | 43 | self.full_path = full_path 44 | self.location = location_left_top 45 | self.folder_name = folder_name 46 | self.deep_level = 0 # 相对深度。最顶层为0 47 | self.body_shape = Rectangle( 48 | location_left_top, get_width_by_file_name(folder_name) * 2, 500 49 | ) 50 | super().__init__(self.body_shape) 51 | # 属性节点关系 52 | self.parent: Optional["EntityFolder"] = None 53 | self.children: "list[EntityFolder | EntityFile]" = [] 54 | 55 | # 是否隐藏内部内容,用于观看宏观的时候防止绘制太多细节而卡顿 56 | self.is_hide_inner = False 57 | 58 | # 这个矩形有点麻烦,它可能应该是一个动态变化的东西,不应该变的是它的左上角位置,变得是他的大小 59 | self.adjust() 60 | 61 | def output_data(self) -> dict[str, Any]: 62 | """ 63 | 递归的输出数据,最终返回一个字典 64 | :return: 65 | """ 66 | return { 67 | "kind": "directory", 68 | "name": self.folder_name, 69 | "bodyShape": self.body_shape.output_data(), 70 | "children": [child.output_data() for child in self.children], 71 | } 72 | 73 | def read_data(self, data: dict[str, Any]): 74 | """ 75 | 读取数据 直接导致文件夹更新布局位置信息 76 | :param data: 77 | :return: 78 | """ 79 | if data["kind"] != "directory": 80 | raise ValueError("kind should be directory") 81 | if data["name"] != self.folder_name: 82 | raise ValueError("读取的文件名不匹配", data["name"], self.folder_name) 83 | # 可以是先序遍历 84 | # 先更新内容 85 | self.body_shape.read_data(data["bodyShape"]) 86 | # === 87 | for child in self.children: 88 | if isinstance(child, EntityFolder): 89 | for data_child in data["children"]: 90 | if child.folder_name == data_child["name"]: 91 | child.read_data(data_child) 92 | break 93 | else: 94 | # 没找到,说明有布局文件缺失,或者文件是新增的。 95 | # 将这个对齐到当前的左上角 96 | child.move_to(self.body_shape.location_left_top) 97 | pass 98 | elif isinstance(child, EntityFile): 99 | for data_child in data["children"]: 100 | if child.file_name == data_child["name"]: 101 | child.read_data(data_child) 102 | break 103 | else: 104 | child.move_to(self.body_shape.location_left_top) 105 | pass 106 | 107 | pass 108 | 109 | def move(self, d_location: NumberVector): 110 | # 不仅,要让文件夹这个框框本身移动 111 | super().move(d_location) 112 | # 还要,移动文件夹内所有实体也移动 113 | for child in self.children: 114 | # 移动自己内部所有实体的时候,也不能用move函数本身,会炸开花。 115 | # child.move(d_location) 116 | child.move_to(child.body_shape.location_left_top + d_location) 117 | 118 | # 推移其他同层的矩形框 119 | if not self.parent: 120 | return 121 | # 让父文件夹收缩调整 122 | self.parent.adjust() 123 | 124 | brother_entities: list[EntityFile | EntityFolder] = self.parent.children 125 | 126 | for entity in brother_entities: 127 | if entity == self: 128 | continue 129 | 130 | if self.body_shape.is_collision(entity.body_shape): 131 | self.collide_with(entity) 132 | 133 | def move_to(self, location_left_top: NumberVector): 134 | """ 135 | 此代码不会被用户直接拖拽调用 136 | :param location_left_top: 137 | :return: 138 | """ 139 | # 移动文件夹内所有实体 140 | for child in self.children: 141 | relative_location = ( 142 | child.body_shape.location_left_top - self.body_shape.location_left_top 143 | ) 144 | # 这本身实际上是一个递归函数了 145 | child.move_to(location_left_top + relative_location) 146 | # 移动文件夹本身 147 | super().move_to(location_left_top) 148 | 149 | def _is_have_child(self, child_name: str): 150 | """ 151 | 判断自身文件夹内部第一层是否含有某个子文件或文件夹 152 | :param child_name: 153 | :return: 154 | """ 155 | for child in self.children: 156 | if isinstance(child, EntityFolder): 157 | if child.folder_name == child_name: 158 | return True 159 | elif isinstance(child, EntityFile): 160 | if child.file_name == child_name: 161 | return True 162 | else: 163 | raise ValueError("子节点类型错误", child) 164 | return False 165 | 166 | def count_deep_level(self) -> int: 167 | """ 168 | 计算文件夹的深度 169 | :return: 170 | """ 171 | if not self.children: 172 | return 1 173 | max_deep_level = 1 174 | for child in self.children: 175 | if isinstance(child, EntityFolder): 176 | deep_level = child.count_deep_level() + 1 177 | max_deep_level = max(max_deep_level, deep_level) 178 | return max_deep_level 179 | 180 | def update_tree_content(self): 181 | """ 182 | 更新文件夹树结构内容,不更新显示位置大小 183 | 但是是递归的 184 | :return: 185 | """ 186 | 187 | import os 188 | 189 | # 如果是一个文件夹,往右放 190 | # 如果是一个文件,往下放 191 | 192 | # 放置点位 193 | put_location = self.body_shape.location_left_top + NumberVector(0, 100) 194 | try: 195 | # 输入一个匹配函数,如果匹配则返回True,否则返回False 196 | matches_function = lambda _: False 197 | 198 | if EXCLUDE_MANAGER.is_local_exclude: 199 | # 在遍历之前先看看是否有.gitignore文件 200 | gitignore_file_path = os.path.join( 201 | self.full_path, ".gitignore" 202 | ).replace("\\", "/") 203 | 204 | if os.path.exists(gitignore_file_path): 205 | try: 206 | matches_function = parse_gitignore(gitignore_file_path) 207 | except UnicodeDecodeError: 208 | # 这个不会再发生了,因为已经将这个第三方库放到本地并修改了代码了 209 | print(f"文件{gitignore_file_path}编码错误,跳过") 210 | 211 | # 遍历文件夹内所有文件 212 | for file_name_sub in os.listdir(self.full_path): 213 | full_path_sub = os.path.join(self.full_path, file_name_sub).replace( 214 | "\\", "/" 215 | ) 216 | # 全局排除 217 | if EXCLUDE_MANAGER.is_file_in_global_exclude(full_path_sub): 218 | continue 219 | # 局部排除 220 | if matches_function(full_path_sub): 221 | continue 222 | 223 | is_have = self._is_have_child(file_name_sub) 224 | 225 | # 开始添加 226 | if os.path.isdir(full_path_sub): 227 | if is_have: 228 | # 还要继续深入检查这个文件夹内部是否有更新 229 | for chile in self.children: 230 | if ( 231 | isinstance(chile, EntityFolder) 232 | and chile.folder_name == file_name_sub 233 | ): 234 | # 找到这个原有的子文件夹并递归下去 235 | chile.update_tree_content() 236 | break 237 | else: 238 | # 新增了一个文件夹 239 | child_folder = EntityFolder(put_location, full_path_sub) 240 | put_location += NumberVector(500, 0) # 往右放 241 | 242 | child_folder.parent = self 243 | child_folder.deep_level = self.deep_level + 1 244 | 245 | self.children.append(child_folder) 246 | child_folder.update_tree_content() # 递归调用 247 | else: 248 | if is_have: 249 | continue 250 | # 是一个文件 251 | child_file = EntityFile(put_location, full_path_sub, self) 252 | put_location = NumberVector(0, 120) # 往下放 253 | 254 | child_file.parent = self 255 | child_file.deep_level = self.deep_level + 1 256 | 257 | self.children.append(child_file) 258 | except PermissionError: 259 | # 权限不足,跳过 260 | # 这里或许未来可以加一种禁止访问的矩形,显示成灰色 261 | pass 262 | pass 263 | 264 | def adjust(self, is_generating=False): 265 | """ 266 | 调整文件夹框框的宽度和长度,扩大或缩进,使得将子一层文件都直观上包含进来 267 | 该调整过程是相对于文件树形结构 自底向上的递归 268 | 269 | is_generating: 是否是最初的布局生成阶段 270 | 在最开始初始化摆放结构的时候,只要保证好自身内部结构完好就可以了 271 | 不要考虑自己文件夹形状膨胀之后对兄弟节点的碰撞,这可能会造成莫名奇妙的无穷递归bug。 272 | :return: 273 | """ 274 | if not self.children: 275 | # 如果没有子节点,不调整,因为可能会导致有无穷大位置的出现 276 | return 277 | 278 | left_bound = float("inf") 279 | right_bound = -float("inf") 280 | top_bound = float("inf") 281 | bottom_bound = -float("inf") 282 | 283 | for child in self.children: 284 | left_bound = min(left_bound, child.body_shape.location_left_top.x) 285 | right_bound = max( 286 | right_bound, 287 | child.body_shape.location_left_top.x + child.body_shape.width, 288 | ) 289 | top_bound = min(top_bound, child.body_shape.location_left_top.y) 290 | bottom_bound = max( 291 | bottom_bound, 292 | child.body_shape.location_left_top.y + child.body_shape.height, 293 | ) 294 | 295 | if ( 296 | self.body_shape.left() == left_bound 297 | and self.body_shape.right() == right_bound 298 | and self.body_shape.top() == top_bound 299 | and self.body_shape.bottom() == bottom_bound 300 | ): 301 | # 如果没有变化,就不用调整了 302 | return 303 | 304 | self.body_shape.location_left_top = NumberVector( 305 | left_bound - self.PADDING, top_bound - self.PADDING 306 | ) 307 | self.body_shape.width = right_bound - left_bound + self.PADDING * 2 308 | self.body_shape.height = bottom_bound - top_bound + self.PADDING * 2 309 | if not self.parent: 310 | return 311 | 312 | if is_generating: 313 | # 这里是生成阶段,不用再向上调整了 314 | return 315 | 316 | # 收缩扩张是否导致碰撞了,检查所有兄弟节点是否有碰撞 317 | for brother_entity in self.parent.children: 318 | if brother_entity == self: 319 | continue 320 | if self.body_shape.is_collision(brother_entity.body_shape): 321 | self.collide_with(brother_entity) 322 | 323 | # 扩张收缩是否导致了父层的变化,向上调用 324 | # 但如果自己本身没有发生变化,就不用再向上调用了,拦截冒泡效应 325 | if self.parent is not None: 326 | self.parent.adjust() 327 | pass 328 | 329 | def adjust_tree_location(self): 330 | """ 331 | 提供给外界调用 332 | :return: 333 | """ 334 | self._adjust_tree_dfs(self) 335 | 336 | def _adjust_tree_dfs(self, folder: "EntityFolder"): 337 | """ 338 | 递归调整文件夹树形结构位置 339 | 应该是一个后根遍历的过程,tips:这种多叉树不存在中序遍历,只有先序和后序。 340 | :param folder: 341 | :return: 342 | """ 343 | 344 | # === 递归部分 345 | for child in folder.children: 346 | if isinstance(child, EntityFolder): 347 | # 是文件夹,继续递归 348 | self._adjust_tree_dfs(child) 349 | # === 350 | 351 | # 调整当前文件夹里的所有实体顺序位置 352 | 353 | rectangle_list = [child.body_shape for child in folder.children] 354 | if len(folder.children) < 100: 355 | sort_strategy_function = sort_rectangle_greedy 356 | else: 357 | if all(isinstance(child, EntityFolder) for child in folder.children) or all( 358 | isinstance(child, EntityFile) for child in folder.children 359 | ): 360 | # 如果全是文件夹或者全是文件,按照正常的矩形排列 361 | sort_strategy_function = sort_rectangle_all_files 362 | else: 363 | sort_strategy_function = sort_rectangle_many_files_less_folders 364 | 365 | sorted_rectangle_list = sort_strategy_function( 366 | [rectangle.clone() for rectangle in rectangle_list], self.PADDING 367 | ) 368 | 369 | # === 370 | # 先检查一下排序策略函数是否顺序正确 371 | if len(sorted_rectangle_list) != len(rectangle_list): 372 | print("排序策略错误,前后数组不相等") 373 | 374 | for i, rect in enumerate(rectangle_list): 375 | if ( 376 | sorted_rectangle_list[i].width != rect.width 377 | or sorted_rectangle_list[i].height != rect.height 378 | ): 379 | print("排序策略错误") 380 | # === 381 | 382 | for i, child in enumerate(folder.children): 383 | child.move_to( 384 | sorted_rectangle_list[i].location_left_top 385 | + self.body_shape.location_left_top 386 | ) 387 | folder.adjust(is_generating=True) 388 | 389 | def __repr__(self): 390 | return f"({self.full_path})" 391 | 392 | def get_components(self) -> List[Paintable]: 393 | return [] 394 | 395 | def paint(self, context: PaintContext) -> None: 396 | context.painter.paint_rect(self.body_shape) 397 | if self.is_hide_inner: 398 | context.painter.paint_text_in_rect( 399 | self.folder_name, self.body_shape 400 | ) 401 | pass 402 | else: 403 | context.painter.paint_text( 404 | Text(self.body_shape.location_left_top, self.folder_name) 405 | ) 406 | -------------------------------------------------------------------------------- /exclude_dialog.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import ( 2 | QDialog, 3 | QVBoxLayout, 4 | QLabel, 5 | QTextEdit, 6 | QPushButton, 7 | QCheckBox, 8 | ) 9 | 10 | from exclude_manager import EXCLUDE_MANAGER 11 | 12 | 13 | class ExcludeDialog(QDialog): 14 | """ 15 | 用于设置排除项的对话框 16 | """ 17 | 18 | def __init__(self, parent=None): 19 | super(ExcludeDialog, self).__init__(parent) 20 | self.setWindowTitle("设置排除") 21 | 22 | layout = QVBoxLayout(self) 23 | 24 | # 添加一些示例控件 25 | # ==== 局部排除 ==== 26 | self.local_exclude_checkbox = QCheckBox( 27 | "开启.gitignore文件识别自动排除功能", self 28 | ) 29 | # 设置此选项的初始状态 30 | self.local_exclude_checkbox.setChecked(EXCLUDE_MANAGER.is_local_exclude) 31 | 32 | self.local_exclude_checkbox.stateChanged.connect( 33 | self.on_local_exclude_state_changed 34 | ) 35 | print(EXCLUDE_MANAGER.is_local_exclude) 36 | layout.addWidget(self.local_exclude_checkbox) 37 | layout.addWidget( 38 | QLabel( 39 | "以上功能开启后,您如果打开了一个项目集文件夹,会自动识别每个项目文件夹中的.gitignore并排除", 40 | self, 41 | ) 42 | ) 43 | # ==== 全局排除 ==== 44 | self.local_exclude_checkbox = QCheckBox("开启全局排除功能", self) 45 | self.local_exclude_checkbox.setChecked(EXCLUDE_MANAGER.is_global_exclude) 46 | self.local_exclude_checkbox.stateChanged.connect( 47 | self.on_global_exclude_state_changed 48 | ) 49 | layout.addWidget(self.local_exclude_checkbox) 50 | layout.addWidget( 51 | QLabel("设置全局排除(以下相当于您在编写gitignore文件时使用的规则):", self) 52 | ) 53 | 54 | self.text_edit = QTextEdit(self) 55 | # 设置初始内容 56 | self.text_edit.setPlainText(EXCLUDE_MANAGER.user_input_content) 57 | layout.addWidget(self.text_edit) 58 | layout.addWidget( 59 | QLabel( 60 | "注意,如果您现在已经打开了文件,保存后需要重新“打开文件夹”才能生效", 61 | self, 62 | ) 63 | ) 64 | save_button = QPushButton("保存", self) 65 | save_button.clicked.connect(self.save_settings) 66 | layout.addWidget(save_button) 67 | 68 | cancel_button = QPushButton("取消", self) 69 | cancel_button.clicked.connect(self.reject) 70 | layout.addWidget(cancel_button) 71 | 72 | self.setLayout(layout) 73 | 74 | def on_local_exclude_state_changed(self, state: int): 75 | if state == 2: # 2 表示选中状态 76 | print("局部排除功能已开启") 77 | EXCLUDE_MANAGER.is_local_exclude = True 78 | else: 79 | print("局部排除功能已关闭") 80 | EXCLUDE_MANAGER.is_local_exclude = False 81 | 82 | def on_global_exclude_state_changed(self, state: int): 83 | if state == 2: # 2 表示选中状态 84 | print("全局排除功能已开启") 85 | EXCLUDE_MANAGER.is_global_exclude = True 86 | else: 87 | print("全局排除功能已关闭") 88 | EXCLUDE_MANAGER.is_global_exclude = False 89 | 90 | def save_settings(self): 91 | # 获取多行文本内容 92 | text = self.text_edit.toPlainText() 93 | # 在这里处理保存逻辑,例如保存到 EXCLUDE_MANAGER 94 | EXCLUDE_MANAGER.update_exclude_content(text) 95 | self.accept() 96 | -------------------------------------------------------------------------------- /exclude_manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | 排除项管理器 3 | 单例模式,全局唯一实例 4 | """ 5 | 6 | _init_content = """.git 7 | __pycache__ 8 | .idea 9 | """ 10 | 11 | 12 | class ExcludeManager: 13 | def __init__(self): 14 | # 用户初始化输入的东西,可以看成是.gitignore文件的内容 15 | self.user_input_content = _init_content 16 | 17 | # 是否开启局部排除 18 | self.is_local_exclude = True 19 | # 是否开启全局排除 20 | self.is_global_exclude = True 21 | 22 | def update_exclude_content(self, content: str): 23 | self.user_input_content = content 24 | 25 | @property 26 | def exclude_list(self): 27 | """ 28 | 排除项列表 29 | """ 30 | result = self.user_input_content.split() 31 | # 去掉空白项 32 | result = [item for item in result if item] 33 | # 可能还有其他的排除 34 | 35 | return result 36 | 37 | def is_file_in_global_exclude(self, file_path: str): 38 | """ 39 | 判断某一个文件是否应该被全局排除 40 | 拿到的路径在上游保证 反斜杠替换为正斜杠 41 | """ 42 | 43 | if not self.is_global_exclude: 44 | return False 45 | 46 | # 这里先写简单一些,暂时不支持正则表达式 47 | file_name = file_path.split("/")[-1] 48 | if file_name in self.exclude_list: 49 | return True 50 | return False 51 | 52 | pass 53 | 54 | 55 | EXCLUDE_MANAGER = ExcludeManager() 56 | del ExcludeManager 57 | -------------------------------------------------------------------------------- /file_observer.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from data_struct.number_vector import NumberVector 3 | from data_struct.rectangle import Rectangle 4 | from entity.entity import Entity 5 | from entity.entity_file import EntityFile 6 | from entity.entity_folder import EntityFolder 7 | 8 | 9 | class InteractiveState(enum.Enum): 10 | SELECT = 0 # 框选状态 11 | DRAG = 1 # 拖拽状态 12 | 13 | 14 | class FileObserver: 15 | 16 | def __init__(self): 17 | """ 18 | 初始化文件观察者 19 | """ 20 | self.folder_full_path: str = "" 21 | self.folder_max_deep_index = 0 + 1 # 当前最深的深度,用于渲染颜色 22 | 23 | self.root_folder: EntityFolder | None = None 24 | 25 | # 选中的矩形 26 | self.select_rect_start_location: NumberVector | None = None 27 | self.select_rect_end_location: NumberVector | None = None 28 | 29 | # 当前正在拖拽的 30 | self.dragging_entity_list: list[Entity] = [] 31 | # 当前选中的实体是否是激活状态 32 | self.dragging_entity_activating: bool = True 33 | # 是否锁定拖拽 34 | self.is_drag_locked: bool = False 35 | # 当前的交互状态 36 | self.interactive_state: InteractiveState = InteractiveState.SELECT 37 | 38 | @property 39 | def select_rectangle(self) -> Rectangle | None: 40 | """ 41 | 根据两个点,生成一个矩形 42 | """ 43 | if ( 44 | self.select_rect_start_location is None 45 | or self.select_rect_end_location is None 46 | ): 47 | return None 48 | left_top = NumberVector( 49 | min(self.select_rect_start_location.x, self.select_rect_end_location.x), 50 | min(self.select_rect_start_location.y, self.select_rect_end_location.y), 51 | ) 52 | width = abs(self.select_rect_start_location.x - self.select_rect_end_location.x) 53 | height = abs( 54 | self.select_rect_start_location.y - self.select_rect_end_location.y 55 | ) 56 | return Rectangle(left_top, width, height) 57 | 58 | def clear_select_rect(self): 59 | """ 60 | 清除选中的矩形 61 | """ 62 | self.select_rect_start_location = None 63 | self.select_rect_end_location = None 64 | 65 | def set_drag_lock(self, is_lock: bool): 66 | self.is_drag_locked = is_lock 67 | if is_lock: 68 | self.dragging_entity_activating = False 69 | 70 | def update_file_path(self, new_path: str): 71 | """ 72 | 更新文件路径,相当于外界用户换了一个想要查看的文件夹 73 | 被外界更换文件夹的地方调用 74 | :param new_path: 75 | :return: 76 | """ 77 | 78 | self.folder_full_path = new_path 79 | self.root_folder = EntityFolder(NumberVector(0, 0), self.folder_full_path) 80 | # 时间花费较少 81 | print("读取文件夹内容中") 82 | self.root_folder.update_tree_content() 83 | print("生成排列结构中") 84 | # 时间花费较大 85 | self.root_folder.adjust_tree_location() 86 | 87 | self.dragging_entity_list = [] 88 | # 还需要将新的文件夹移动到世界坐标的中心。 89 | target_location_left_top = NumberVector(0, 0) - NumberVector( 90 | self.root_folder.body_shape.width / 2, 91 | self.root_folder.body_shape.height / 2, 92 | ) 93 | self.root_folder.move_to(target_location_left_top) 94 | print("计算最大深度中") 95 | self.folder_max_deep_index = self.root_folder.count_deep_level() 96 | 97 | def output_layout_dict(self) -> dict: 98 | """ 99 | 输出当前文件夹的布局文件 100 | :return: 101 | """ 102 | if self.root_folder is None: 103 | return {"layout": []} 104 | return {"layout": [self.root_folder.output_data()]} 105 | 106 | def read_layout_dict(self, layout_file: dict): 107 | """ 108 | 读取布局文件,恢复当前文件夹的布局 109 | :param layout_file: 110 | :return: 111 | """ 112 | if self.root_folder is None: 113 | return 114 | self.root_folder.read_data(layout_file["layout"][0]) 115 | self.dragging_entity_list = [] 116 | 117 | def _entity_files(self, folder: EntityFolder) -> list[EntityFile]: 118 | """ 119 | 获取某个文件夹下所有的文件,包括子文件夹的文件,展平成一个列表 120 | :param folder: 121 | :return: 122 | """ 123 | res = [] 124 | for file in folder.children: 125 | if isinstance(file, EntityFile): 126 | res.append(file) 127 | elif isinstance(file, EntityFolder): 128 | res.extend(self._entity_files(file)) 129 | return res 130 | 131 | def _entity_folders(self, folder: EntityFolder) -> list[EntityFolder]: 132 | """ 133 | 获取某个文件夹下所有的文件夹,包括子文件夹的文件夹,展平成一个列表 134 | :param folder: 135 | :return: 136 | """ 137 | 138 | assert self.root_folder 139 | res = [self.root_folder] 140 | for file in folder.children: 141 | if isinstance(file, EntityFolder): 142 | res.append(file) 143 | res.extend(self._entity_folders(file)) 144 | return res 145 | 146 | def get_entity_by_location( 147 | self, location_world: NumberVector 148 | ) -> EntityFile | EntityFolder | None: 149 | """ 150 | 判断一个点是否击中了某个实体文件 151 | :param location_world: 152 | :return: 153 | """ 154 | if self.root_folder is None: 155 | return None 156 | 157 | return self._get_entity_by_location_dfs(location_world, self.root_folder) 158 | 159 | def get_folder_by_location( 160 | self, location_world: NumberVector 161 | ) -> EntityFolder | None: 162 | """ 163 | 判断一个点是否击中了某个文件夹 164 | :param location_world: 165 | :return: 166 | """ 167 | if self.root_folder is None: 168 | return None 169 | 170 | return self._get_folder_by_location_dfs(location_world, self.root_folder) 171 | 172 | def _get_folder_by_location_dfs( 173 | self, location_world: NumberVector, currentEntity: EntityFolder 174 | ) -> EntityFolder | None: 175 | """递归的实现""" 176 | # 当前没有点到东西 177 | if not currentEntity.body_shape.is_contain_point(location_world): 178 | return None 179 | 180 | if isinstance(currentEntity, EntityFolder): 181 | # 是文件夹 182 | 183 | # 如果当前文件夹内部隐藏了,则直接返回当前文件夹 184 | if currentEntity.is_hide_inner: 185 | return currentEntity 186 | 187 | # 看看是不是击中了内部的东西 188 | for child in currentEntity.children: 189 | if isinstance(child, EntityFolder): 190 | # 是否击中文件夹 191 | res = self._get_folder_by_location_dfs(location_world, child) 192 | if res is not None: 193 | return res 194 | 195 | # 未击中任何内部的东西,则返回当前文件夹 196 | return currentEntity 197 | else: 198 | raise ValueError("Unknown entity type") 199 | 200 | def _get_entity_by_location_dfs( 201 | self, location_world: NumberVector, currentEntity: EntityFile | EntityFolder 202 | ) -> EntityFile | EntityFolder | None: 203 | """递归的实现""" 204 | # 当前没有点到东西 205 | if not currentEntity.body_shape.is_contain_point(location_world): 206 | return None 207 | 208 | if isinstance(currentEntity, EntityFile): 209 | # 是文件 210 | return currentEntity 211 | elif isinstance(currentEntity, EntityFolder): 212 | # 是文件夹 213 | 214 | # 如果当前文件夹内部隐藏了,则直接返回当前文件夹 215 | if currentEntity.is_hide_inner: 216 | return currentEntity 217 | 218 | # 看看是不是击中了内部的东西 219 | for child in currentEntity.children: 220 | if isinstance(child, EntityFile): 221 | # 是否击中文件 222 | if child.body_shape.is_contain_point(location_world): 223 | return child 224 | 225 | elif isinstance(child, EntityFolder): 226 | # 是否击中文件夹 227 | res = self._get_entity_by_location_dfs(location_world, child) 228 | if res is not None: 229 | return res 230 | 231 | # 未击中任何内部的东西,则返回当前文件夹 232 | return currentEntity 233 | else: 234 | raise ValueError("Unknown entity type") 235 | -------------------------------------------------------------------------------- /file_openner.py: -------------------------------------------------------------------------------- 1 | """ 2 | 文件打开者,专门为打开文件 3 | """ 4 | 5 | import os 6 | 7 | 8 | def open_file(full_path_file: str): 9 | """ 10 | 打开文件 11 | """ 12 | if os.path.exists(full_path_file): 13 | # if full_path_file.endswith(".py"): 14 | # # 用pycharm打开python文件 15 | # pycharm_path = r"D:\Program Files\JetBrains\PyCharm Community Edition 2023.3.3\bin" 16 | # subprocess.run([pycharm_path, full_path_file]) 17 | # return 18 | print("当前系统:", os.name) 19 | if os.name == "win32" or os.name == "nt": 20 | os.startfile(full_path_file) 21 | elif os.name == "posix": 22 | # macOS 系统 23 | os.system(f"open {full_path_file}") 24 | else: 25 | # linux 系统 26 | os.system(f"xdg-open {full_path_file}") 27 | else: 28 | print("文件不存在!") 29 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import json 2 | import traceback 3 | 4 | from PyQt5.QtCore import Qt, QTimer, QUrl 5 | from PyQt5.QtGui import ( 6 | QPainter, 7 | QMouseEvent, 8 | QWheelEvent, 9 | QKeyEvent, 10 | QColor, 11 | QIcon, 12 | QPaintEvent, 13 | QDesktopServices, 14 | ) 15 | from PyQt5.QtWidgets import ( 16 | QApplication, 17 | QDesktopWidget, 18 | QAction, 19 | QMainWindow, 20 | QFileDialog, 21 | QMessageBox, 22 | QPushButton, 23 | ) 24 | 25 | from camera import Camera 26 | from data_struct.number_vector import NumberVector 27 | from data_struct.rectangle import Rectangle 28 | from entity.entity import Entity 29 | from entity.entity_file import EntityFile 30 | from entity.entity_folder import EntityFolder 31 | from exclude_dialog import ExcludeDialog 32 | from file_observer import FileObserver, InteractiveState 33 | from file_openner import open_file 34 | from paint.paint_elements import ( 35 | paint_grid, 36 | paint_file_rect, 37 | paint_folder_rect, 38 | paint_details_data, 39 | paint_rect_in_world, 40 | paint_selected_rect, 41 | paint_alert_message, 42 | ) 43 | from paint.paintables import PaintContext 44 | from paint.painters import VisualFilePainter 45 | from style.styles import EntityFolderDefaultStyle 46 | from tools.threads import OpenFolderThread 47 | 48 | from assets import assets 49 | 50 | 51 | # 是为了引入assets文件夹中的资源文件,看似是灰色的没有用,但实际不能删掉 52 | # 只是为了让pyinstaller打包时能打包到exe文件中。 53 | # 需要进入assets文件夹后在命令行输入指令 `pyrcc5 image.rcc -o assets.py` 来更新assets.py文件 54 | 55 | 56 | class Canvas(QMainWindow): 57 | 58 | def __init__(self): 59 | super().__init__() 60 | 61 | self._open_folder_thread = None 62 | # 界面初始化 63 | self.zoom_in_button = QPushButton("透视+", self) 64 | self.zoom_out_button = QPushButton("透视-", self) 65 | self.init_ui() 66 | 67 | # 重要对象绑定 68 | self.camera = Camera(NumberVector.zero(), 1920, 1080) 69 | self.file_observer = FileObserver() 70 | 71 | # 创建一个定时器用于定期更新窗口 72 | self.timer = QTimer(self) 73 | self.timer.timeout.connect(self.tick) 74 | self.timer.setInterval(16) # 1000/60 大约= 16ms 75 | # 启动定时器 76 | self.timer.start() 77 | 78 | # 窗口移动相关 79 | self._last_mouse_move_location = NumberVector.zero() # 注意这是一个世界坐标 80 | # 是否正在更新布局 81 | self._is_updating_layout = False 82 | # 是否正在打开文件夹 83 | self._is_open_folder = False # 没有起到效果 84 | 85 | def init_ui(self): 86 | # 设置窗口标题和尺寸 87 | self.setWindowTitle("VisualFile 大型文件夹直观可视化工具") 88 | 89 | self.setGeometry(0, 0, 1920, 1080) 90 | self.setWindowIcon(QIcon(":/favicon.ico")) 91 | self._move_window_to_center() 92 | 93 | # 在界面右上角增加两个按钮“+”“-” 94 | self.zoom_in_button.setGeometry(140, 60, 100, 50) 95 | self.zoom_in_button.clicked.connect(lambda: self.camera.add_perspective_level()) 96 | self.zoom_in_button.setStyleSheet("background-color: rgb(30, 215, 109);") 97 | self.zoom_out_button.setGeometry(20, 60, 100, 50) 98 | self.zoom_out_button.clicked.connect(lambda: self.camera.reduce_perspective_level()) 99 | self.zoom_out_button.setStyleSheet("background-color: rgb(30, 215, 109);") 100 | 101 | # 创建菜单栏 102 | menubar = self.menuBar() 103 | assert menubar 104 | # 创建 "文件夹" 菜单 105 | folder_menu = menubar.addMenu("文件夹") 106 | assert folder_menu 107 | # 创建 "Open" 菜单项 108 | open_action = QAction("打开文件夹", self) 109 | open_action.setShortcut("Ctrl+O") 110 | folder_menu.addAction(open_action) 111 | open_action.triggered.connect(self.on_open) 112 | 113 | # 创建 "Update" 菜单项 114 | update_action = QAction("更新文件夹", self) 115 | update_action.setShortcut("Ctrl+U") 116 | folder_menu.addAction(update_action) 117 | update_action.triggered.connect(self.on_update) 118 | 119 | # 创建 设置排除 菜单项 120 | exclude_action = QAction("设置排除", self) 121 | exclude_action.setShortcut("Ctrl+E") 122 | folder_menu.addAction(exclude_action) 123 | exclude_action.triggered.connect(self.show_exclude_dialog) 124 | 125 | # “布局”菜单 126 | layout_menu = menubar.addMenu("布局") 127 | assert layout_menu 128 | # 创建 "Save" 菜单项 129 | save_action = QAction("导出布局文件", self) 130 | save_action.setShortcut("Ctrl+S") 131 | layout_menu.addAction(save_action) 132 | save_action.triggered.connect(self.on_save) 133 | # 创建 导入 菜单项 134 | import_action = QAction("导入布局文件并更新布局", self) 135 | import_action.setShortcut("Ctrl+I") 136 | layout_menu.addAction(import_action) 137 | import_action.triggered.connect(self.on_import) 138 | 139 | drag_lock = QAction("锁定拖拽", self) 140 | drag_lock.setShortcut("Ctrl+L") 141 | layout_menu.addAction(drag_lock) 142 | drag_lock.triggered.connect(lambda: self.file_observer.set_drag_lock(True)) 143 | 144 | drag_unlock = QAction("解锁拖拽", self) 145 | drag_unlock.setShortcut("Ctrl+U") 146 | layout_menu.addAction(drag_unlock) 147 | drag_unlock.triggered.connect(lambda: self.file_observer.set_drag_lock(False)) 148 | 149 | # "视野" 菜单 150 | view_menu = menubar.addMenu("视野") 151 | assert view_menu 152 | # 创建 "Reset" 菜单项 153 | reset_action = QAction("重置缩放", self) 154 | reset_action.setShortcut("Ctrl+0") 155 | view_menu.addAction(reset_action) 156 | reset_action.triggered.connect(self.on_reset_zoom) 157 | 158 | # 创建 "快速档缩放" 菜单项 159 | camera_fast_mode = QAction("快速档缩放", self) 160 | camera_fast_mode.setShortcut("Ctrl++") 161 | view_menu.addAction(camera_fast_mode) 162 | camera_fast_mode.triggered.connect(lambda: self.camera.set_fast_mode()) 163 | 164 | camera_slow_mode = QAction("慢速档缩放", self) 165 | camera_slow_mode.setShortcut("Ctrl+-") 166 | view_menu.addAction(camera_slow_mode) 167 | camera_slow_mode.triggered.connect(lambda: self.camera.set_slow_mode()) 168 | 169 | camera_open_animation = QAction("开启动画", self) 170 | camera_open_animation.setShortcut("Ctrl+A") 171 | view_menu.addAction(camera_open_animation) 172 | camera_open_animation.triggered.connect( 173 | lambda: self.camera.set_scale_animation(True) 174 | ) 175 | 176 | camera_close_animation = QAction("关闭动画", self) 177 | camera_close_animation.setShortcut("Ctrl+D") 178 | view_menu.addAction(camera_close_animation) 179 | camera_close_animation.triggered.connect( 180 | lambda: self.camera.set_scale_animation(False) 181 | ) 182 | 183 | # 创建帮助说明菜单项 184 | help_menu = menubar.addMenu("帮助") 185 | assert help_menu 186 | help_action = QAction("帮助说明", self) 187 | help_action.setShortcut("Ctrl+H") 188 | help_menu.addAction(help_action) 189 | help_action.triggered.connect(self.on_help) 190 | 191 | pass 192 | 193 | def _move_window_to_center(self): 194 | # 获取屏幕可用空间(macOS上会有titlebar占据一部分空间) 195 | screen_geometry = QDesktopWidget().availableGeometry() 196 | 197 | # 计算新的宽度和高度(长宽各取屏幕的百分之八十) 198 | new_width = screen_geometry.width() * 0.8 199 | new_height = screen_geometry.height() * 0.8 200 | 201 | # 计算窗口应该移动到的新位置 202 | new_left = (screen_geometry.width() - new_width) / 2 203 | new_top = (screen_geometry.height() - new_height) / 2 + screen_geometry.top() 204 | 205 | # 移动窗口到新位置 206 | self.setGeometry(int(new_left), int(new_top), int(new_width), int(new_height)) 207 | 208 | def show_exclude_dialog(self): 209 | dialog = ExcludeDialog(self) 210 | dialog.exec_() 211 | 212 | @staticmethod 213 | def on_help(): 214 | # 创建一个消息框 215 | msg_box = QMessageBox() 216 | msg_box.setWindowIcon(QIcon("assets/favicon.ico")) 217 | msg_box.setIcon(QMessageBox.Information) 218 | msg_box.setWindowTitle("visual-file 帮助说明") 219 | msg_box.setText( 220 | "\n\n".join( 221 | [ 222 | "框选拖拽:拖拽框选,起始点位置决定了在哪个文件夹内进行框选,框选后的矩形会变成绿色", 223 | "摆放位置:按住绿色的矩形左键拖拽", 224 | "打开文件:左键双击某矩形,以系统默认方式打开对应文件或文件夹", 225 | "移动视野:鼠标中键拖拽 或 右键拖拽 或 WASD键", 226 | "缩放视野:鼠标滚轮", 227 | "重置视野:双击鼠标中键", 228 | "反馈问题:如有问题建议在github上提交issues,或在b站视频下方评论区留言。", 229 | ] 230 | ) 231 | ) 232 | # github按钮 233 | button_github = QPushButton("Github 项目地址") 234 | msg_box.addButton(button_github, QMessageBox.ActionRole) 235 | button_github.clicked.connect(Canvas.__open_github) 236 | msg_box.setStandardButtons(QMessageBox.Ok) 237 | # b站按钮 238 | button_bilibili = QPushButton("bilibili 视频介绍") 239 | msg_box.addButton(button_bilibili, QMessageBox.ActionRole) 240 | button_bilibili.clicked.connect(Canvas.__open_bilibili) 241 | 242 | # 显示消息框 243 | msg_box.exec_() 244 | 245 | @staticmethod 246 | def __open_github(): 247 | QDesktopServices.openUrl(QUrl("https://github.com/LiRenTech/visual-file-qt")) 248 | 249 | @staticmethod 250 | def __open_bilibili(): 251 | QDesktopServices.openUrl(QUrl("https://www.bilibili.com/video/BV1qw4m1k7LD")) 252 | 253 | def on_open_folder_finish_slot(self): 254 | self._is_open_folder = False 255 | self.camera.reset() 256 | self.camera.target_scale = 0.1 257 | 258 | def on_open(self): 259 | # 直接读取文件 260 | directory = QFileDialog.getExistingDirectory(self, "选择要直观化查看的文件夹") 261 | 262 | if directory: 263 | # paint_alert_message(painter, self.camera, "请先打开文件夹") 264 | self._is_open_folder = True 265 | self._open_folder_thread = OpenFolderThread(self.file_observer, directory) 266 | self._open_folder_thread.finished.connect(self.on_open_folder_finish_slot) 267 | self._open_folder_thread.start() 268 | # self.file_observer.update_file_path(directory) 269 | # self._is_open_folder = False 270 | 271 | pass 272 | 273 | def on_save(self): 274 | 275 | file_path, _ = QFileDialog.getSaveFileName( 276 | self, "保存布局文件", "", "JSON Files (*.json);;All Files (*)" 277 | ) 278 | 279 | if file_path: 280 | # 如果用户选择了文件并点击了保存按钮 281 | # 保存布局文件 282 | layout: dict = self.file_observer.output_layout_dict() 283 | 284 | # 确保文件扩展名为 .json 285 | if not file_path.endswith(".json"): 286 | file_path += ".json" 287 | 288 | with open(file_path, "w") as f: 289 | json.dump(layout, f) 290 | else: 291 | # 如果用户取消了保存操作 292 | print("Save operation cancelled.") 293 | 294 | def on_update(self): 295 | if self.file_observer.root_folder is None: 296 | return 297 | # BUG:新增的东西可能会导致父文件夹形状炸开散架 298 | # 更新文件夹内容 299 | self.file_observer.root_folder.update_tree_content() 300 | self.file_observer.folder_max_deep_index = ( 301 | self.file_observer.root_folder.count_deep_level() 302 | ) 303 | pass 304 | 305 | def on_import(self): 306 | # 显示文件打开对话框 307 | file_path, _ = QFileDialog.getOpenFileName( 308 | self, "导入布局文件", "", "JSON Files (*.json);;All Files (*)" 309 | ) 310 | 311 | if file_path: 312 | # 如果用户选择了文件并点击了打开按钮 313 | # 在这里编写导入文件的逻辑 314 | with open(file_path, "r") as f: 315 | layout_dict = json.load(f) 316 | 317 | try: 318 | self._is_updating_layout = True 319 | self.file_observer.read_layout_dict(layout_dict) 320 | self._is_updating_layout = False 321 | except Exception as e: 322 | traceback.print_exc() 323 | print(e) 324 | else: 325 | # 如果用户取消了打开操作 326 | print("Import operation cancelled.") 327 | 328 | def on_reset_zoom(self): 329 | self.camera.reset() 330 | 331 | def tick(self): 332 | self.camera.tick() 333 | # 重绘窗口 334 | self.update() 335 | for entity in self.file_observer.dragging_entity_list: 336 | # 对比当前选中的实体矩形和视野矩形 337 | if self.camera.cover_world_rectangle.is_contain(entity.body_shape): 338 | # 套住了 339 | self.file_observer.dragging_entity_activating = True 340 | else: 341 | # 没套住 342 | self.file_observer.dragging_entity_activating = False 343 | break 344 | 345 | def paintEvent(self, a0: QPaintEvent | None): 346 | assert a0 is not None 347 | painter = QPainter(self) 348 | 349 | # 获取窗口的尺寸 350 | rect = self.rect() 351 | # 更新camera大小,防止放大窗口后缩放中心点还在左上部分 352 | self.camera.reset_view_size(rect.width(), rect.height()) 353 | 354 | # 使用黑色填充整个窗口 355 | painter.fillRect(rect, QColor(0, 0, 0, 255)) 356 | # 画网格 357 | paint_grid(painter, self.camera) 358 | 359 | if self._is_updating_layout: 360 | paint_alert_message(painter, self.camera, "正在更新布局,请稍后...") 361 | return 362 | if self._is_open_folder: 363 | paint_alert_message(painter, self.camera, "正在打开文件夹,请稍后...") 364 | return 365 | # 如果没有文件夹,绘制提示信息 366 | if self.file_observer.root_folder is None: 367 | paint_alert_message(painter, self.camera, "请先打开文件夹") 368 | # 画场景物体 369 | 370 | # 画各种矩形 371 | if self.file_observer.root_folder: 372 | # self.paint_folder_dfs(painter, self.file_observer.root_folder) 373 | folder_style = EntityFolderDefaultStyle( 374 | self.file_observer.root_folder, self.file_observer.folder_max_deep_index 375 | ) 376 | painter.setTransform(self.camera.get_world2view_transform()) 377 | folder_style.paint_objects( 378 | PaintContext(VisualFilePainter(painter), self.camera) 379 | ) 380 | painter.resetTransform() 381 | # 绘制选中的矩形的填充色 382 | for entity in self.file_observer.dragging_entity_list: 383 | paint_selected_rect( 384 | painter, 385 | self.camera, 386 | entity, 387 | self.file_observer.dragging_entity_activating, 388 | ) 389 | # 绘制框选的矩形 390 | if self.file_observer.select_rect_start_location is not None: 391 | folder = self.file_observer.get_folder_by_location( 392 | self.file_observer.select_rect_start_location 393 | ) 394 | user_rect = self.file_observer.select_rectangle 395 | 396 | if folder and user_rect: 397 | # 绘制框选的矩形 与 文件夹取交集 398 | paint_rect_in_world( 399 | painter, 400 | self.camera, 401 | Rectangle.from_edges( 402 | max(user_rect.left(), folder.body_shape.left()), 403 | max(user_rect.top(), folder.body_shape.top()), 404 | min(user_rect.right(), folder.body_shape.right()), 405 | min(user_rect.bottom(), folder.body_shape.bottom()), 406 | ), 407 | QColor(255, 255, 0, 128), 408 | QColor(255, 255, 0, 255), 409 | ) 410 | # 绘制文件夹外框 411 | paint_rect_in_world( 412 | painter, 413 | self.camera, 414 | folder.body_shape, 415 | QColor(0, 0, 0, 0), 416 | QColor(255, 255, 0, 255), 417 | ) 418 | # 绘制细节信息 419 | paint_details_data( 420 | painter, 421 | self.camera, 422 | [ 423 | f"当前缩放: {self.camera.current_scale:.2f} location: {self.camera.location}" 424 | f"当前目录:{self.file_observer.root_folder.full_path if self.file_observer.root_folder else '没有目录'}", 425 | f"拖拽锁定: {self.file_observer.is_drag_locked}", 426 | f"鼠标状态: {self.file_observer.interactive_state.name}", 427 | f"透视等级:{self.camera.perspective_level}", 428 | ], 429 | ) 430 | 431 | def paint_folder_dfs(self, painter: QPainter, folder_entity: EntityFolder): 432 | """ 433 | 递归绘制文件夹,遇到视野之外的直接排除 434 | """ 435 | # 先绘制本体 436 | if folder_entity.body_shape.is_collision(self.camera.cover_world_rectangle): 437 | paint_folder_rect( 438 | painter, 439 | self.camera, 440 | folder_entity, 441 | folder_entity.deep_level / self.file_observer.folder_max_deep_index, 442 | ) 443 | else: 444 | return 445 | # 递归绘制子文件夹 446 | for child in folder_entity.children: 447 | if isinstance(child, EntityFolder): 448 | self.paint_folder_dfs(painter, child) 449 | elif isinstance(child, EntityFile): 450 | if child.body_shape.is_collision(self.camera.cover_world_rectangle): 451 | paint_file_rect( 452 | painter, 453 | self.camera, 454 | child, 455 | child.deep_level / self.file_observer.folder_max_deep_index, 456 | ) 457 | 458 | def mousePressEvent(self, a0: QMouseEvent | None): 459 | assert a0 is not None 460 | point_view_location = NumberVector(a0.pos().x(), a0.pos().y()) 461 | point_world_location = self.camera.location_view2world(point_view_location) 462 | 463 | if a0.button() == Qt.MouseButton.LeftButton: 464 | # 如果当前正按下的位置正好命中了正在选择的任意一个矩形,则开始拖拽 465 | for entity in self.file_observer.dragging_entity_list: 466 | if entity.body_shape.is_contain_point(point_world_location): 467 | self.file_observer.interactive_state = InteractiveState.DRAG 468 | break 469 | else: 470 | # 否则,开始框选 471 | self.file_observer.interactive_state = InteractiveState.SELECT 472 | 473 | # 状态更新完毕 474 | 475 | if self.file_observer.interactive_state == InteractiveState.SELECT: 476 | # 清空上一次选择的内容 477 | self.file_observer.dragging_entity_list = [] 478 | # 更新选择框起始位置 479 | self.file_observer.select_rect_start_location = ( 480 | point_world_location.clone() 481 | ) 482 | elif self.file_observer.interactive_state == InteractiveState.DRAG: 483 | # 开始拖拽,更新每个被拖拽实体的 dragging_offset 484 | for entity in self.file_observer.dragging_entity_list: 485 | entity.dragging_offset = ( 486 | point_world_location - entity.body_shape.location_left_top 487 | ) 488 | elif ( 489 | a0.button() == Qt.MouseButton.MiddleButton 490 | or a0.button() == Qt.MouseButton.RightButton 491 | ): 492 | # 开始准备移动,记录好上一次鼠标位置的相差距离向量 493 | self._last_mouse_move_location = self.camera.location_view2world( 494 | NumberVector(a0.pos().x(), a0.pos().y()) 495 | ) 496 | pass 497 | 498 | def _select_rect_get_entity_list(self, select_rect: Rectangle) -> list[Entity]: 499 | """ 500 | 选择current_folder中的实体 501 | 选择框的起始点位置落在了什么文件夹里就直接决定它只能选中哪一个文件夹里的内容 502 | """ 503 | entity_list = [] 504 | 505 | if self.file_observer.select_rect_start_location is None: 506 | return entity_list 507 | 508 | folder = self.file_observer.get_folder_by_location( 509 | self.file_observer.select_rect_start_location 510 | ) 511 | if folder: 512 | for child in folder.children: 513 | if child.body_shape.is_collision(select_rect): 514 | entity_list.append(child) 515 | 516 | return entity_list 517 | 518 | def mouseMoveEvent(self, a0: QMouseEvent | None): 519 | assert a0 is not None 520 | point_view_location = NumberVector(a0.pos().x(), a0.pos().y()) 521 | point_world_location = self.camera.location_view2world(point_view_location) 522 | 523 | if a0.buttons() == Qt.MouseButton.LeftButton: 524 | if self.file_observer.interactive_state == InteractiveState.SELECT: 525 | # 更新矩形位置大小 526 | self.file_observer.select_rect_end_location = ( 527 | point_world_location.clone() 528 | ) 529 | # 检测矩形是否和其他实体发生碰撞 530 | select_rect = self.file_observer.select_rectangle 531 | if select_rect and self.file_observer.root_folder: 532 | self.file_observer.dragging_entity_list = ( 533 | self._select_rect_get_entity_list(select_rect) 534 | ) 535 | pass 536 | elif self.file_observer.interactive_state == InteractiveState.DRAG: 537 | if self.file_observer.is_drag_locked: 538 | return 539 | # 左键拖拽,但要看看是否是激活状态 540 | try: 541 | if not self.file_observer.dragging_entity_activating: 542 | # 不是一个激活的状态 就不动了 543 | return 544 | for entity in self.file_observer.dragging_entity_list: 545 | # 让它跟随鼠标移动 546 | new_left_top = point_world_location - entity.dragging_offset 547 | d_location = new_left_top - entity.body_shape.location_left_top 548 | entity.move(d_location) 549 | except Exception as e: 550 | print(e) 551 | traceback.print_exc() 552 | pass 553 | if ( 554 | a0.buttons() == Qt.MouseButton.MiddleButton 555 | or a0.buttons() == Qt.MouseButton.RightButton 556 | ): 557 | # 移动的时候,应该记录与上一次鼠标位置的相差距离向量 558 | current_mouse_move_location = self.camera.location_view2world( 559 | NumberVector(a0.pos().x(), a0.pos().y()) 560 | ) 561 | diff_location = current_mouse_move_location - self._last_mouse_move_location 562 | self.camera.location -= diff_location 563 | 564 | def mouseReleaseEvent(self, a0: QMouseEvent | None): 565 | assert a0 is not None 566 | point_view_location = NumberVector(a0.pos().x(), a0.pos().y()) 567 | point_world_location = self.camera.location_view2world(point_view_location) 568 | 569 | if a0.button() == Qt.MouseButton.LeftButton: 570 | if self.file_observer.interactive_state == InteractiveState.SELECT: 571 | # 左键释放,结束框选视觉效果 572 | self.file_observer.clear_select_rect() 573 | # 如果左键释放都没有找到一个实体,则在释放位置选择住一个矩形 574 | if not self.file_observer.dragging_entity_list: 575 | point_entity = self.file_observer.get_entity_by_location( 576 | point_world_location 577 | ) 578 | # 但前提是这个矩形不能是超大矩形,即没有被屏幕完全覆盖住的 579 | if point_entity and self.camera.cover_world_rectangle.is_contain( 580 | point_entity.body_shape 581 | ): 582 | self.file_observer.dragging_entity_list = [point_entity] 583 | 584 | elif self.file_observer.interactive_state == InteractiveState.DRAG: 585 | if self.file_observer.is_drag_locked: 586 | return 587 | self.file_observer.interactive_state = InteractiveState.SELECT 588 | 589 | if ( 590 | a0.button() == Qt.MouseButton.MiddleButton 591 | or a0.button() == Qt.MouseButton.RightButton 592 | ): 593 | if self.file_observer.is_drag_locked: 594 | return 595 | 596 | entity = self.file_observer.get_entity_by_location(point_world_location) 597 | if entity: 598 | pass 599 | else: 600 | # 让它脱离鼠标吸附 601 | self.file_observer.dragging_entity_list = [] 602 | 603 | def mouseDoubleClickEvent(self, a0: QMouseEvent | None): 604 | assert a0 is not None 605 | if a0.button() == Qt.MouseButton.LeftButton: 606 | point_view_location = NumberVector(a0.pos().x(), a0.pos().y()) 607 | point_world_location = self.camera.location_view2world(point_view_location) 608 | entity = self.file_observer.get_entity_by_location(point_world_location) 609 | if entity: 610 | open_file(entity.full_path) 611 | elif a0.button() == Qt.MouseButton.MiddleButton: 612 | # 双击中键返回原位 613 | self.camera.reset() 614 | 615 | def wheelEvent(self, a0: QWheelEvent | None): 616 | assert a0 is not None 617 | # 检查滚轮方向 618 | if a0.angleDelta().y() > 0: 619 | self.camera.zoom_in() 620 | else: 621 | self.camera.zoom_out() 622 | 623 | # 你可以在这里添加更多的逻辑来响应滚轮事件 624 | a0.accept() 625 | 626 | def keyPressEvent(self, a0: QKeyEvent | None): 627 | assert a0 is not None 628 | key = a0.key() 629 | if key == Qt.Key.Key_A: 630 | self.camera.press_move(NumberVector(-1, 0)) 631 | elif key == Qt.Key.Key_S: 632 | self.camera.press_move(NumberVector(0, 1)) 633 | elif key == Qt.Key.Key_D: 634 | self.camera.press_move(NumberVector(1, 0)) 635 | elif key == Qt.Key.Key_W: 636 | self.camera.press_move(NumberVector(0, -1)) 637 | 638 | def keyReleaseEvent(self, a0: QKeyEvent | None): 639 | assert a0 is not None 640 | key = a0.key() 641 | if key == Qt.Key.Key_A: 642 | self.camera.release_move(NumberVector(-1, 0)) 643 | elif key == Qt.Key.Key_S: 644 | self.camera.release_move(NumberVector(0, 1)) 645 | elif key == Qt.Key.Key_D: 646 | self.camera.release_move(NumberVector(1, 0)) 647 | elif key == Qt.Key.Key_W: 648 | self.camera.release_move(NumberVector(0, -1)) 649 | 650 | 651 | def main(): 652 | import sys 653 | import traceback 654 | 655 | try: 656 | sys.excepthook = sys.__excepthook__ 657 | 658 | app = QApplication(sys.argv) 659 | app.setWindowIcon(QIcon("./assets/visual-file.icns")) 660 | 661 | canvas = Canvas() 662 | canvas.show() 663 | 664 | sys.exit(app.exec_()) 665 | except Exception as e: 666 | # 捕捉不到 667 | traceback.print_exc() 668 | print(e) 669 | sys.exit(1) 670 | pass 671 | 672 | 673 | if __name__ == "__main__": 674 | main() 675 | -------------------------------------------------------------------------------- /paint/paint_elements.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtGui import QPainter, QColor 2 | 3 | from camera import Camera 4 | from data_struct.number_vector import NumberVector 5 | from data_struct.rectangle import Rectangle 6 | from entity.entity import Entity 7 | from entity.entity_file import EntityFile 8 | from entity.entity_folder import EntityFolder 9 | from paint.paint_utils import PainterUtils 10 | from tools.color_utils import get_color_by_level 11 | 12 | 13 | def paint_grid(paint: QPainter, camera: Camera): 14 | try: 15 | line_color = QColor(255, 255, 255, 100) 16 | line_color_light = QColor(255, 255, 255, 255) 17 | 18 | for y in range(-1000, 1000, 100): 19 | PainterUtils.paint_solid_line( 20 | paint, 21 | camera.location_world2view(NumberVector(-1000, y)), 22 | camera.location_world2view(NumberVector(1000, y)), 23 | line_color_light if y == 0 else line_color, 24 | 1 * camera.current_scale, 25 | ) 26 | for x in range(-1000, 1000, 100): 27 | PainterUtils.paint_solid_line( 28 | paint, 29 | camera.location_world2view(NumberVector(x, -1000)), 30 | camera.location_world2view(NumberVector(x, 1000)), 31 | line_color_light if x == 0 else line_color, 32 | 1 * camera.current_scale, 33 | ) 34 | except Exception as e: 35 | print(e) 36 | 37 | 38 | def paint_details_data(paint: QPainter, camera: Camera, datas: list[str]): 39 | """ 40 | 左上角绘制细节信息 41 | :param paint: 42 | :param camera: 43 | :param datas: 44 | :return: 45 | """ 46 | start_y = 150 47 | for i, data in enumerate(datas): 48 | PainterUtils.paint_word_from_left_top( 49 | paint, 50 | NumberVector(20, start_y + i * 50), 51 | data, 52 | 12, 53 | QColor(255, 255, 255, 100), 54 | ) 55 | pass 56 | 57 | 58 | def paint_alert_message(paint: QPainter, camera: Camera, message: str): 59 | """ 60 | 屏幕中心绘制警告信息 61 | :param paint: 62 | :param camera: 63 | :param message: 64 | :return: 65 | """ 66 | 67 | PainterUtils.paint_word_from_center( 68 | paint, 69 | NumberVector(camera.view_width / 2, camera.view_height / 2), 70 | message, 71 | 24, 72 | QColor(255, 255, 0, 255), 73 | ) 74 | 75 | 76 | def paint_rect_in_world( 77 | paint: QPainter, 78 | camera: Camera, 79 | rect: Rectangle, 80 | fill_color: QColor, 81 | stroke_color: QColor, 82 | ): 83 | PainterUtils.paint_rect_from_left_top( 84 | paint, 85 | camera.location_world2view(rect.location_left_top), 86 | rect.width * camera.current_scale, 87 | rect.height * camera.current_scale, 88 | fill_color, 89 | stroke_color, 90 | 1, 91 | ) 92 | 93 | 94 | def paint_file_rect( 95 | paint: QPainter, camera: Camera, entity_file: EntityFile, color_rate: float = 0.5 96 | ): 97 | 98 | # 先画一个框 99 | PainterUtils.paint_rect_from_left_top( 100 | paint, 101 | camera.location_world2view(entity_file.body_shape.location_left_top), 102 | entity_file.body_shape.width * camera.current_scale, 103 | entity_file.body_shape.height * camera.current_scale, 104 | QColor(0, 0, 0, 255), 105 | get_color_by_level(color_rate), 106 | 1, 107 | ) 108 | # camera scale < 0.15 的时候不渲染文字了,会导致文字突然变大,重叠一大堆 109 | if camera.current_scale < 0.15: 110 | return 111 | # 再画文字 112 | PainterUtils.paint_word_from_left_top( 113 | paint, 114 | camera.location_world2view( 115 | entity_file.body_shape.location_left_top + NumberVector(5, 5) 116 | ), 117 | entity_file.file_name, 118 | 14 * camera.current_scale, 119 | get_color_by_level(color_rate), 120 | ) 121 | 122 | pass 123 | 124 | 125 | def paint_selected_rect( 126 | paint: QPainter, camera: Camera, selected_entity: Entity, is_active: bool 127 | ): 128 | """ 129 | 绘制选中的区域 130 | :param paint: 131 | :param camera: 132 | :param selected_entity: 133 | :param is_active: 如果是激活状态,绘制填充颜色,否则绘制边框颜色 134 | :return: 135 | """ 136 | PainterUtils.paint_rect_from_left_top( 137 | paint, 138 | camera.location_world2view( 139 | selected_entity.body_shape.location_left_top - NumberVector(5, 5) 140 | ), 141 | (selected_entity.body_shape.width + 10) * camera.current_scale, 142 | (selected_entity.body_shape.height + 10) * camera.current_scale, 143 | QColor(89, 158, 94, 100) if is_active else QColor(0, 0, 0, 0), 144 | QColor(0, 255, 0, 255) if is_active else QColor(255, 0, 0, 255), 145 | 4, 146 | ) 147 | 148 | 149 | def paint_folder_rect( 150 | paint: QPainter, camera: Camera, entity_file: EntityFolder, color_rate: float = 0.5 151 | ): 152 | """ 153 | 154 | :param paint: 155 | :param camera: 156 | :param entity_file: 157 | :param color_rate: 158 | :return: 159 | """ 160 | # 先画一个框 161 | PainterUtils.paint_rect_from_left_top( 162 | paint, 163 | camera.location_world2view(entity_file.body_shape.location_left_top), 164 | entity_file.body_shape.width * camera.current_scale, 165 | entity_file.body_shape.height * camera.current_scale, 166 | QColor(255, 255, 255, 0), 167 | get_color_by_level(color_rate), 168 | 1, 169 | ) 170 | if camera.current_scale < 0.05: 171 | return 172 | # 再画文字 173 | PainterUtils.paint_word_from_left_top( 174 | paint, 175 | camera.location_world2view(entity_file.body_shape.location_left_top), 176 | entity_file.folder_name, 177 | 16 * camera.current_scale, 178 | get_color_by_level(color_rate), 179 | ) 180 | -------------------------------------------------------------------------------- /paint/paint_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | 这个里面绘制的元素都是直接基于渲染坐标来绘制的,不是世界坐标 3 | """ 4 | 5 | import traceback 6 | 7 | from PyQt5.QtCore import QPoint, QPointF, Qt 8 | from PyQt5.QtGui import QPainter, QColor, QPen, QFont, QFontMetrics 9 | 10 | from data_struct.number_vector import NumberVector 11 | 12 | 13 | class PainterUtils: 14 | @staticmethod 15 | def paint_solid_line( 16 | painter: QPainter, 17 | point1: NumberVector, 18 | point2: NumberVector, 19 | color: QColor, 20 | width: float, 21 | ): 22 | """ 23 | 绘制一条实线 24 | :param painter: 25 | :param point1: 26 | :param point2: 27 | :param color: 28 | :param width: 29 | :return: 30 | """ 31 | pen = QPen(color, width) # 创建QPen并设置颜色和宽度 32 | painter.setPen(pen) 33 | painter.setBrush(color) 34 | painter.setRenderHint(QPainter.Antialiasing) 35 | painter.drawLine(int(point1.x), int(point1.y), int(point2.x), int(point2.y)) 36 | painter.setPen(QColor(0, 0, 0, 0)) 37 | painter.setBrush(QColor(0, 0, 0, 0)) 38 | painter.setRenderHint(QPainter.Antialiasing, False) 39 | pass 40 | 41 | @staticmethod 42 | def paint_dashed_line( 43 | painter: QPainter, 44 | point1: NumberVector, 45 | point2: NumberVector, 46 | color: QColor, 47 | width: float, 48 | dash_length: float, 49 | ): 50 | """ 51 | 绘制一条虚线 52 | :param painter: 53 | :param point1: 54 | :param point2: 55 | :param color: 56 | :param width: 57 | :param dash_length: 58 | :return: 59 | """ 60 | pen = QPen(color, width) # 创建QPen并设置颜色和宽度 61 | pen.setStyle(Qt.PenStyle.DashLine) # 设置线型为虚线 62 | pen.setDashPattern([dash_length, dash_length]) # 设置虚线长度 63 | painter.setPen(pen) 64 | painter.setBrush(color) 65 | painter.setRenderHint(QPainter.Antialiasing) 66 | dx = point2.x - point1.x 67 | dy = point2.y - point1.y 68 | length = (dx**2 + dy**2) ** 0.5 69 | num_dashes = int(length / dash_length) 70 | if num_dashes == 0: 71 | num_dashes = 1 72 | dash_pattern = [dash_length] * num_dashes 73 | dash_pattern.append(length - (num_dashes - 1) * dash_length) 74 | painter.setPen(QColor(0, 0, 0, 0)) 75 | painter.setBrush(QColor(0, 0, 0, 0)) 76 | painter.setRenderHint(QPainter.Antialiasing, False) 77 | painter.drawLine(int(point1.x), int(point1.y), int(point2.x), int(point2.y)) 78 | pass 79 | 80 | @staticmethod 81 | def paint_rect_from_left_top( 82 | painter: QPainter, 83 | left_top: NumberVector, 84 | width: float, 85 | height: float, 86 | fill_color: QColor, 87 | stroke_color: QColor, 88 | stroke_width: int, 89 | ): 90 | """ 91 | 绘制一个矩形,左上角坐标为left_top,宽为width,高为height,填充色为fill_color,边框色为stroke_color 92 | :param painter: 93 | :param left_top: 94 | :param width: 95 | :param height: 96 | :param fill_color: 97 | :param stroke_color: 98 | :return: 99 | """ 100 | # 设置边框宽度 101 | pen = QPen(stroke_color, stroke_width) 102 | painter.setPen(pen) 103 | # painter.setPen(stroke_color) 104 | painter.setBrush(fill_color) 105 | painter.setRenderHint(QPainter.Antialiasing) 106 | painter.drawRect(int(left_top.x), int(left_top.y), int(width), int(height)) 107 | # HACK: 这个数字转int有隐患,OverflowError: argument 3 overflowed: value must be in the range -2147483648 to 2147483647 108 | painter.setPen(QColor(0, 0, 0, 0)) 109 | painter.setBrush(QColor(0, 0, 0, 0)) 110 | 111 | painter.setRenderHint(QPainter.Antialiasing, False) 112 | pass 113 | 114 | @staticmethod 115 | def paint_word_from_left_top( 116 | painter: QPainter, 117 | left_top: NumberVector, 118 | text: str, 119 | font_size: float, 120 | color: QColor, 121 | ): 122 | """ 123 | 绘制一个文本,左上角坐标为left_top,文本为text,字体大小为font_size,颜色为color 124 | :param painter: 125 | :param left_top: 126 | :param text: 127 | :param font_size: 128 | :param color: 129 | :return: 130 | """ 131 | # 创建QFont对象并设置字体大小 132 | try: 133 | font = QFont("Consolas") 134 | font.setPointSizeF(font_size) 135 | # 获取字体度量信息 136 | font_metrics = QFontMetrics(font) 137 | # 设置QPainter的字体和颜色 138 | painter.setFont(font) 139 | painter.setPen(color) 140 | 141 | # 计算字体的ascent值,即基线到顶的距离 142 | 143 | # transform = QTransform() 144 | # factor = font_size / 20 145 | factor = 1 146 | ascent = font_metrics.ascent() * factor 147 | # transform.translate(left_top.x, left_top.y + ascent).scale( 148 | # factor, factor 149 | # ) 150 | # painter.setTransform(transform) 151 | 152 | # painter.drawText(QPoint(0, 0), text) 153 | 154 | # painter.resetTransform() 155 | # 转换left_top为整数坐标 156 | left_top = left_top.integer() 157 | left_top = QPointF(left_top.x, left_top.y) 158 | 159 | # 调整y坐标,使文本的左上角对齐 160 | adjusted_y = left_top.y() + ascent 161 | left_top.setY(adjusted_y) 162 | # 绘制文本 163 | painter.drawText(left_top, text) 164 | except Exception as e: 165 | print(f"Exception type: {type(e)}") 166 | print(f"Error message: {str(e)}") 167 | traceback.print_exc() 168 | pass 169 | 170 | @staticmethod 171 | def paint_word_from_center( 172 | painter: QPainter, 173 | center: NumberVector, 174 | text: str, 175 | font_size: float, 176 | color: QColor, 177 | ): 178 | """ 179 | 绘制一个文本,其中心坐标为中心point,文本为text,字体大小为font_size,颜色为color 180 | :param painter: QPainter对象 181 | :param center: 文本的中心点 (NumberVector类型) 182 | :param text: 要绘制的文本 183 | :param font_size: 字体大小 184 | :param color: 文本颜色 185 | :return: None 186 | """ 187 | try: 188 | font = QFont("Consolas") 189 | font.setPointSize(int(font_size)) 190 | font_metrics = QFontMetrics(font) 191 | 192 | # 设置QPainter的字体和颜色 193 | painter.setFont(font) 194 | painter.setPen(color) 195 | 196 | # 转换center为整数坐标 197 | center = center.integer() 198 | center = QPoint(int(center.x), int(center.y)) 199 | 200 | # 获取文本的宽度和高度 201 | text_width = font_metrics.width(text) 202 | # text_height = font_metrics.height() 203 | ascent = font_metrics.ascent() 204 | 205 | # 计算文本中心点相对于左上角的位置 206 | left_top_x = center.x() - text_width // 2 207 | left_top_y = center.y() - ascent 208 | 209 | # 创建新的左上角坐标 210 | left_top = QPoint(left_top_x, left_top_y) 211 | 212 | # 绘制文本 213 | painter.drawText(left_top, text) 214 | except Exception as e: 215 | print(f"Exception type: {type(e)}") 216 | print(f"Error message: {str(e)}") 217 | import traceback 218 | 219 | traceback.print_exc() 220 | -------------------------------------------------------------------------------- /paint/paintables.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from typing import List 3 | 4 | from camera import Camera 5 | from paint.painters import VisualFilePainter 6 | 7 | 8 | class PaintContext: 9 | """绘制上下文,该类是为了便于在绘制时传递一些额外信息。""" 10 | 11 | def __init__(self, painter: VisualFilePainter, camera: Camera): 12 | """ 13 | Args: 14 | painter (QPainter): 已经使用QTransform将世界坐标转化为视野渲染坐标的painter 15 | """ 16 | self.painter = painter 17 | self.camera = camera 18 | 19 | 20 | class Paintable(metaclass=ABCMeta): 21 | """代表了所有能绘制的一个对象""" 22 | 23 | @abstractmethod 24 | def get_components(self) -> List["Paintable"]: 25 | """获取该对象的基本图元,如没有,返回空列表""" 26 | pass 27 | 28 | @abstractmethod 29 | def paint(self, context: PaintContext) -> None: 30 | """使用context绘制本对象, 31 | 32 | Args: 33 | context (PaintContext): 待使用的context 34 | """ 35 | pass 36 | -------------------------------------------------------------------------------- /paint/painters.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtCore import QRectF, QPointF 2 | from PyQt5.QtGui import QPainter 3 | 4 | from data_struct.rectangle import Rectangle 5 | from data_struct.text import Text 6 | 7 | 8 | # 施工中... 9 | class VisualFilePainter: 10 | def __init__(self, painter: QPainter): 11 | self._painter = painter 12 | 13 | def q_painter(self) -> QPainter: 14 | return self._painter 15 | 16 | def paint_rect(self, rect: Rectangle): 17 | self._painter.drawRect( 18 | QRectF( 19 | rect.location_left_top.x, 20 | rect.location_left_top.y, 21 | rect.width, 22 | rect.height, 23 | ) 24 | ) 25 | 26 | def paint_text(self, text: Text): 27 | ascent = self._painter.fontMetrics().ascent() 28 | self._painter.drawText( 29 | QPointF(text.left_top.x, text.left_top.y + ascent), text.text 30 | ) 31 | 32 | def paint_text_in_rect(self, str_text: str, rect: Rectangle): 33 | """ 34 | 绘制文本,使得文本中心居中在矩形框内 35 | :param str_text: 文本内容 36 | :param rect: 矩形框 37 | """ 38 | ascent = self._painter.fontMetrics().ascent() 39 | text_width = self._painter.fontMetrics().width(str_text) 40 | text_height = self._painter.fontMetrics().height() 41 | self._painter.drawText( 42 | QPointF( 43 | rect.location_left_top.x + (rect.width - text_width) / 2, 44 | rect.location_left_top.y + (rect.height - text_height) / 2 + ascent, 45 | ), 46 | str_text 47 | ) 48 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | dependencies = [ 3 | "PyQt5==5.15.10", 4 | "PyQt5_sip==12.13.0", 5 | "gitignore-parser==0.1.11" 6 | ] 7 | 8 | 9 | [tool.black] 10 | line-length = 88 11 | target-version = ['py36', 'py37', 'py38'] 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyQt5==5.15.10 2 | PyQt5_sip==12.13.0 3 | gitignore-parser==0.1.11 -------------------------------------------------------------------------------- /style/styles.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | import math 3 | 4 | from PyQt5.QtGui import QPainter, QColor, QFont, QPen 5 | 6 | from entity.entity_file import EntityFile 7 | from entity.entity_folder import EntityFolder 8 | from paint.paintables import PaintContext 9 | from tools.color_utils import get_color_by_level 10 | 11 | 12 | class Styleable(metaclass=ABCMeta): 13 | """代表了一组Paintable的样式""" 14 | 15 | @abstractmethod 16 | def paint_objects(self, context: PaintContext) -> None: 17 | """使用context绘制本对象,不包括该对象的子对象 18 | 19 | Args: 20 | context (PaintContext): 待使用的context 21 | """ 22 | pass 23 | 24 | 25 | class EntityFolderDefaultStyle(Styleable): 26 | def __init__(self, root_folder: EntityFolder, folder_max_deep_index: int): 27 | """构造方法 28 | 29 | Args: 30 | root_folder (EntityFolder): 要渲染的文件夹树的根 31 | folder_max_deep_index (int): 文件夹最大深度 32 | """ 33 | self.root_folder = root_folder 34 | self.folder_max_deep_index = folder_max_deep_index 35 | 36 | @staticmethod 37 | def calculate_deep(camera_current_scale: float) -> float: 38 | """ 39 | 根据当前缩放比例,计算出文件夹的最大深度 40 | x:当前缩放比例 41 | 放大看细节:>1 42 | 缩小看宏观:<1 43 | y:当前视野能看到的文件夹深度等级,也就是函数线下面的是能看到的,上面的深度是看不到的 44 | # 暂时弃用 45 | """ 46 | if camera_current_scale >= 1: 47 | return float("inf") 48 | else: 49 | return math.tan(camera_current_scale * (math.pi / 2)) * 10 50 | 51 | def _paint_folder_dfs( 52 | self, context: PaintContext, folder: EntityFolder, current_deep_index: int 53 | ) -> None: 54 | """ 55 | 递归绘制文件夹,遇到视野之外的直接排除 56 | current_deep_index 从0开始,表示当前文件夹的深度 57 | """ 58 | # 看看是否因为缩放太小,视野看到的太宏观,就不绘制太细节的东西 59 | exclude_level = context.camera.perspective_level 60 | if current_deep_index != 0 and current_deep_index > exclude_level: 61 | # print("skip folder deep index", current_deep_index) 62 | return 63 | q = context.painter.q_painter() 64 | # 先绘制本体 65 | if folder.body_shape.is_collision(context.camera.cover_world_rectangle): 66 | color_rate = folder.deep_level / self.folder_max_deep_index 67 | q.setPen( 68 | QPen(get_color_by_level(color_rate), 1 / context.camera.current_scale) 69 | ) 70 | if ( 71 | exclude_level < 2147483647 72 | and math.floor(exclude_level) == current_deep_index 73 | ): 74 | # 这时代表文件夹内部已经不显示了,要将文件夹名字居中显示在中央 75 | q.setFont(QFont("Consolas", int(16 / context.camera.current_scale))) 76 | folder.is_hide_inner = True 77 | else: 78 | folder.is_hide_inner = False 79 | if q.font().pointSize != 16: 80 | q.setFont(QFont("Consolas", 16)) 81 | folder.paint(context) 82 | else: 83 | return 84 | # 递归绘制子文件夹 85 | child_deep_index = current_deep_index + 1 86 | for child in folder.children: 87 | if isinstance(child, EntityFolder): 88 | self._paint_folder_dfs(context, child, child_deep_index) 89 | elif isinstance(child, EntityFile): 90 | if child_deep_index > exclude_level: 91 | continue 92 | if child.body_shape.is_collision(context.camera.cover_world_rectangle): 93 | color_rate = child.deep_level / self.folder_max_deep_index 94 | q.setPen( 95 | QPen( 96 | get_color_by_level(color_rate), 97 | 1 / context.camera.current_scale, 98 | ) 99 | ) 100 | if q.font().pointSize != 14: 101 | q.setFont(QFont("Consolas", 14)) 102 | child.paint(context) 103 | 104 | def paint_objects(self, context: PaintContext) -> None: 105 | q = context.painter.q_painter() 106 | q.setBrush(QColor(255, 255, 255, 0)) 107 | q.setRenderHint(QPainter.Antialiasing) 108 | q.setFont(QFont("Consolas", 16)) 109 | self._paint_folder_dfs(context, self.root_folder, 0) 110 | q.setPen(QColor(0, 0, 0, 0)) 111 | q.setBrush(QColor(0, 0, 0, 0)) 112 | q.setRenderHint(QPainter.Antialiasing, False) 113 | -------------------------------------------------------------------------------- /tests/gitignore_test.py: -------------------------------------------------------------------------------- 1 | from gitignore_parser import parse_gitignore 2 | 3 | matches_function = parse_gitignore("D:/Projects/Project-Tools/CodeEmpire/.gitignore") 4 | 5 | print(matches_function("D:/Projects/Project-Tools/CodeEmpire/tools/__pycache__")) 6 | print( 7 | matches_function( 8 | "D:/Projects/Project-Tools/CodeEmpire/tools/__pycache__/string_tools.cpython-311.pyc" 9 | ) 10 | ) 11 | print(matches_function("D:/Projects/Project-Tools/CodeEmpire/requirements.txt")) 12 | -------------------------------------------------------------------------------- /tests/test.py: -------------------------------------------------------------------------------- 1 | class NumberVector: 2 | 3 | def __init__(self, x: float, y: float): 4 | self.x = x 5 | self.y = y 6 | 7 | 8 | class Rectangle: 9 | def __init__(self, location_left_top: NumberVector, width: float, height: float): 10 | self.location_left_top = location_left_top 11 | self.width = width 12 | self.height = height 13 | 14 | 15 | def sort_rectangle(rectangles: list[Rectangle], margin: float) -> list[Rectangle]: 16 | """ 17 | 装箱问题,排序矩形 18 | :param rectangles: N个矩形的大小和位置 19 | :param margin: 矩形之间的间隔(为了美观考虑) 20 | :return: 调整好后的N个矩形的大小和位置,数组内每个矩形一一对应。 21 | 例如: 22 | rectangles = [Rectangle(NumberVector(0, 0), 10, 10), Rectangle(NumberVector(10, 10), 1, 1)] 23 | 这两个矩形对角放,外套矩形空隙面积过大,空间浪费,需要调整位置。 24 | 25 | 调整后返回: 26 | 27 | [Rectangle(NumberVector(0, 0), 10, 10), Rectangle(NumberVector(12, 0), 1, 1)] 28 | 参数 margin = 2 29 | 横向放置,减少了空间浪费。 30 | """ 31 | return rectangles 32 | 33 | 34 | def show_rectangle(rectangles: list[Rectangle]): 35 | """ 36 | 用海龟绘图 显示所有矩形 37 | """ 38 | import turtle 39 | 40 | # 创建一个绘图窗口 41 | screen = turtle.Screen() 42 | screen.title("Rectangle Visualization") 43 | 44 | # 创建一个小海龟绘图器 45 | painter = turtle.Turtle() 46 | painter.speed(0) # 设置绘制速度为最快 47 | 48 | # 遍历所有矩形进行绘制 49 | for rect in rectangles: 50 | # 移动到矩形的左上角位置 51 | painter.penup() # 抬起笔,移动时不绘制 52 | painter.goto(rect.location_left_top.x, -rect.location_left_top.y) 53 | painter.pendown() # 放下笔,开始绘制 54 | 55 | # 绘制矩形 56 | for _ in range(2): 57 | painter.forward(rect.width) # 向右移动 58 | painter.left(90) # 左转90度 59 | painter.forward(rect.height) # 向下移动 60 | painter.left(90) # 左转90度 61 | 62 | # 隐藏海龟 63 | painter.hideturtle() 64 | 65 | # 结束绘制 66 | screen.mainloop() 67 | 68 | 69 | def main(): 70 | rectangles_list = [ 71 | # 斜对角放置两个 72 | sort_rectangle( 73 | [ 74 | Rectangle(NumberVector(0, 0), 10, 10), 75 | Rectangle(NumberVector(10, 10), 1, 1), 76 | ], 77 | 2, 78 | ), 79 | # 五个长条文件 80 | sort_rectangle( 81 | [ 82 | Rectangle(NumberVector(0, 0), 500, 100), 83 | Rectangle(NumberVector(0, 0), 500, 100), 84 | Rectangle(NumberVector(0, 0), 500, 100), 85 | Rectangle(NumberVector(0, 0), 500, 100), 86 | Rectangle(NumberVector(0, 0), 500, 100), 87 | ], 88 | 50, 89 | ), 90 | # 五个参差不齐的长条 91 | sort_rectangle( 92 | [ 93 | Rectangle(NumberVector(0, 0), 500, 100), 94 | Rectangle(NumberVector(0, 0), 600, 100), 95 | Rectangle(NumberVector(0, 0), 750, 100), 96 | Rectangle(NumberVector(0, 0), 400, 100), 97 | Rectangle(NumberVector(0, 0), 200, 100), 98 | ], 99 | 50, 100 | ), 101 | # 五个正方形 102 | sort_rectangle( 103 | [ 104 | Rectangle(NumberVector(0, 0), 100, 100), 105 | Rectangle(NumberVector(0, 0), 100, 100), 106 | Rectangle(NumberVector(0, 0), 100, 100), 107 | Rectangle(NumberVector(0, 0), 100, 100), 108 | Rectangle(NumberVector(0, 0), 100, 100), 109 | ], 110 | 50, 111 | ), 112 | # 四个长条文件和一个大正方形文件夹 113 | sort_rectangle( 114 | [ 115 | Rectangle(NumberVector(0, 0), 500, 100), 116 | Rectangle(NumberVector(0, 0), 520, 100), 117 | Rectangle(NumberVector(0, 0), 540, 100), 118 | Rectangle(NumberVector(0, 0), 456, 100), 119 | Rectangle(NumberVector(0, 0), 600, 1000), 120 | ], 121 | 50, 122 | ), 123 | ] 124 | 125 | show_rectangle(rectangles_list[2]) 126 | 127 | 128 | if __name__ == "__main__": 129 | main() 130 | -------------------------------------------------------------------------------- /tools/color_utils.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | 3 | from PyQt5.QtGui import QColor 4 | 5 | 6 | def mix_colors(color1, color2, rate) -> QColor: 7 | """ 8 | Mixes two colors together based on a ratio. 9 | """ 10 | r1, g1, b1 = color1 11 | r2, g2, b2 = color2 12 | r = (1 - rate) * r1 + rate * r2 13 | g = (1 - rate) * g1 + rate * g2 14 | b = (1 - rate) * b1 + rate * b2 15 | return QColor(int(r), int(g), int(b)) 16 | 17 | 18 | @lru_cache(maxsize=1000) 19 | def get_color_by_level(rate: float) -> QColor: 20 | """ 21 | 根据等级获取颜色 22 | :param rate: 23 | :return: 24 | """ 25 | return mix_colors((35, 170, 242), (76, 236, 45), rate) 26 | -------------------------------------------------------------------------------- /tools/gitignore_parser.py: -------------------------------------------------------------------------------- 1 | """ 2 | 这实际上是一个第三方库,并不是我们自己写的。 3 | https://github.com/mherrmann/gitignore_parser 4 | 但由于报错。需要对文件进行更改。 5 | """ 6 | 7 | import collections 8 | import os 9 | import re 10 | 11 | from os.path import abspath, dirname 12 | from pathlib import Path 13 | from typing import Reversible, Union 14 | 15 | 16 | def handle_negation(file_path, rules: Reversible["IgnoreRule"]): 17 | for rule in reversed(rules): 18 | if rule.match(file_path): 19 | return not rule.negation 20 | return False 21 | 22 | 23 | def parse_gitignore(full_path, base_dir=None): 24 | if base_dir is None: 25 | base_dir = dirname(full_path) 26 | rules = [] 27 | with open(full_path, encoding="utf-8") as ignore_file: # 这里添加了utf-8编码 28 | counter = 0 29 | for line in ignore_file: 30 | counter += 1 31 | line = line.rstrip("\n") 32 | rule = rule_from_pattern( 33 | line, base_path=_normalize_path(base_dir), source=(full_path, counter) 34 | ) 35 | if rule: 36 | rules.append(rule) 37 | if not any(r.negation for r in rules): 38 | return lambda file_path: any(r.match(file_path) for r in rules) 39 | else: 40 | # We have negation rules. We can't use a simple "any" to evaluate them. 41 | # Later rules override earlier rules. 42 | return lambda file_path: handle_negation(file_path, rules) 43 | 44 | 45 | def rule_from_pattern(pattern, base_path=None, source=None): 46 | """ 47 | Take a .gitignore match pattern, such as "*.py[cod]" or "**/*.bak", 48 | and return an IgnoreRule suitable for matching against files and 49 | directories. Patterns which do not match files, such as comments 50 | and blank lines, will return None. 51 | Because git allows for nested .gitignore files, a base_path value 52 | is required for correct behavior. The base path should be absolute. 53 | """ 54 | # Store the exact pattern for our repr and string functions 55 | orig_pattern = pattern 56 | # Early returns follow 57 | # Discard comments and separators 58 | if pattern.strip() == "" or pattern[0] == "#": 59 | return 60 | # Strip leading bang before examining double asterisks 61 | if pattern[0] == "!": 62 | negation = True 63 | pattern = pattern[1:] 64 | else: 65 | negation = False 66 | # Multi-asterisks not surrounded by slashes (or at the start/end) should 67 | # be treated like single-asterisks. 68 | pattern = re.sub(r"([^/])\*{2,}", r"\1*", pattern) 69 | pattern = re.sub(r"\*{2,}([^/])", r"*\1", pattern) 70 | 71 | # Special-casing '/', which doesn't match any files or directories 72 | if pattern.rstrip() == "/": 73 | return 74 | 75 | directory_only = pattern[-1] == "/" 76 | # A slash is a sign that we're tied to the base_path of our rule 77 | # set. 78 | anchored = "/" in pattern[:-1] 79 | if pattern[0] == "/": 80 | pattern = pattern[1:] 81 | if pattern[0] == "*" and len(pattern) >= 2 and pattern[1] == "*": 82 | pattern = pattern[2:] 83 | anchored = False 84 | if pattern[0] == "/": 85 | pattern = pattern[1:] 86 | if pattern[-1] == "/": 87 | pattern = pattern[:-1] 88 | # patterns with leading hashes or exclamation marks are escaped with a 89 | # backslash in front, unescape it 90 | if pattern[0] == "\\" and pattern[1] in ("#", "!"): 91 | pattern = pattern[1:] 92 | # trailing spaces are ignored unless they are escaped with a backslash 93 | i = len(pattern) - 1 94 | striptrailingspaces = True 95 | while i > 1 and pattern[i] == " ": 96 | if pattern[i - 1] == "\\": 97 | pattern = pattern[: i - 1] + pattern[i:] 98 | i = i - 1 99 | striptrailingspaces = False 100 | else: 101 | if striptrailingspaces: 102 | pattern = pattern[:i] 103 | i = i - 1 104 | regex = fnmatch_pathname_to_regex( 105 | pattern, directory_only, negation, anchored=bool(anchored) 106 | ) 107 | return IgnoreRule( 108 | pattern=orig_pattern, 109 | regex=regex, 110 | negation=negation, 111 | directory_only=directory_only, 112 | anchored=anchored, 113 | base_path=_normalize_path(base_path) if base_path else None, 114 | source=source, 115 | ) 116 | 117 | 118 | IGNORE_RULE_FIELDS = [ 119 | "pattern", 120 | "regex", # Basic values 121 | "negation", 122 | "directory_only", 123 | "anchored", # Behavior flags 124 | "base_path", # Meaningful for gitignore-style behavior 125 | "source", # (file, line) tuple for reporting 126 | ] 127 | 128 | 129 | class IgnoreRule(collections.namedtuple("IgnoreRule_", IGNORE_RULE_FIELDS)): 130 | def __str__(self): 131 | return self.pattern 132 | 133 | def __repr__(self): 134 | return "".join(["IgnoreRule('", self.pattern, "')"]) 135 | 136 | def match(self, abs_path: Union[str, Path]): 137 | matched = False 138 | if self.base_path: 139 | rel_path = str(_normalize_path(abs_path).relative_to(self.base_path)) 140 | else: 141 | rel_path = str(_normalize_path(abs_path)) 142 | # Path() strips the trailing slash, so we need to preserve it 143 | # in case of directory-only negation 144 | if self.negation and type(abs_path) == str and abs_path[-1] == "/": 145 | rel_path += "/" 146 | if rel_path.startswith("./"): 147 | rel_path = rel_path[2:] 148 | if re.search(self.regex, rel_path): 149 | matched = True 150 | return matched 151 | 152 | 153 | # Frustratingly, python's fnmatch doesn't provide the FNM_PATHNAME 154 | # option that .gitignore's behavior depends on. 155 | def fnmatch_pathname_to_regex( 156 | pattern, directory_only: bool, negation: bool, anchored: bool = False 157 | ): 158 | """ 159 | Implements fnmatch style-behavior, as though with FNM_PATHNAME flagged; 160 | the path separator will not match shell-style '*' and '.' wildcards. 161 | """ 162 | i, n = 0, len(pattern) 163 | 164 | seps = [re.escape(os.sep)] 165 | if os.altsep is not None: 166 | seps.append(re.escape(os.altsep)) 167 | seps_group = "[" + "|".join(seps) + "]" 168 | nonsep = r"[^{}]".format("|".join(seps)) 169 | 170 | res = [] 171 | while i < n: 172 | c = pattern[i] 173 | i += 1 174 | if c == "*": 175 | try: 176 | if pattern[i] == "*": 177 | i += 1 178 | if i < n and pattern[i] == "/": 179 | i += 1 180 | res.append("".join(["(.*", seps_group, ")?"])) 181 | else: 182 | res.append(".*") 183 | else: 184 | res.append("".join([nonsep, "*"])) 185 | except IndexError: 186 | res.append("".join([nonsep, "*"])) 187 | elif c == "?": 188 | res.append(nonsep) 189 | elif c == "/": 190 | res.append(seps_group) 191 | elif c == "[": 192 | j = i 193 | if j < n and pattern[j] == "!": 194 | j += 1 195 | if j < n and pattern[j] == "]": 196 | j += 1 197 | while j < n and pattern[j] != "]": 198 | j += 1 199 | if j >= n: 200 | res.append("\\[") 201 | else: 202 | stuff = pattern[i:j].replace("\\", "\\\\").replace("/", "") 203 | i = j + 1 204 | if stuff[0] == "!": 205 | stuff = "".join(["^", stuff[1:]]) 206 | elif stuff[0] == "^": 207 | stuff = "".join("\\" + stuff) 208 | res.append("[{}]".format(stuff)) 209 | else: 210 | res.append(re.escape(c)) 211 | if anchored: 212 | res.insert(0, "^") 213 | else: 214 | res.insert(0, f"(^|{seps_group})") 215 | if not directory_only: 216 | res.append("$") 217 | elif directory_only and negation: 218 | res.append("/$") 219 | else: 220 | res.append("($|\\/)") 221 | return "".join(res) 222 | 223 | 224 | def _normalize_path(path: Union[str, Path]) -> Path: 225 | """Normalize a path without resolving symlinks. 226 | 227 | This is equivalent to `Path.resolve()` except that it does not resolve symlinks. 228 | Note that this simplifies paths by removing double slashes, `..`, `.` etc. like 229 | `Path.resolve()` does. 230 | """ 231 | return Path(abspath(path)) 232 | -------------------------------------------------------------------------------- /tools/rectangle_packing.py: -------------------------------------------------------------------------------- 1 | import math 2 | from typing import List 3 | from data_struct.rectangle import Rectangle 4 | from data_struct.number_vector import NumberVector 5 | 6 | 7 | """ 8 | 装箱问题,排序矩形 9 | :param rectangles: N个矩形的大小和位置 10 | :param margin: 矩形之间的间隔(为了美观考虑) 11 | :return: 调整好后的N个矩形的大小和位置,数组内每个矩形一一对应。 12 | 例如: 13 | rectangles = [Rectangle(NumberVector(0, 0), 10, 10), Rectangle(NumberVector(10, 10), 1, 1)] 14 | 这两个矩形对角放,外套矩形空隙面积过大,空间浪费,需要调整位置。 15 | 16 | 调整后返回: 17 | 18 | [Rectangle(NumberVector(0, 0), 10, 10), Rectangle(NumberVector(12, 0), 1, 1)] 19 | 参数 margin = 2 20 | 横向放置,减少了空间浪费。 21 | """ 22 | 23 | 24 | def sort_rectangle_just_vertical( 25 | rectangles: list[Rectangle], margin: float 26 | ) -> list[Rectangle]: 27 | """ 28 | 仅仅将一些矩形左对齐 竖向简单排列 29 | 这会假设外层父文件夹左上角顶点为 0 0 30 | O(N) 31 | :param rectangles: 32 | :param margin: 33 | :return: 34 | """ 35 | current_y = margin 36 | 37 | for rectangle in rectangles: 38 | rectangle.location_left_top.y = current_y 39 | rectangle.location_left_top.x = margin 40 | current_y += rectangle.height + margin 41 | 42 | return rectangles 43 | 44 | 45 | def sort_rectangle_fast(rectangles: list[Rectangle], margin: float) -> list[Rectangle]: 46 | max_width = -margin 47 | max_height = -margin 48 | putable_locs = [NumberVector(0, 0)] 49 | for r in rectangles: 50 | if max_width > max_height: 51 | r.location_left_top.y = max_height + margin 52 | r.location_left_top.x = 0 53 | else: 54 | r.location_left_top.x = max_width + margin 55 | r.location_left_top.y = 0 56 | if r.right() > max_width: 57 | max_width = r.right() 58 | if r.bottom() > max_height: 59 | max_height = r.bottom() 60 | return rectangles 61 | 62 | 63 | def sort_rectangle_greedy( 64 | rectangles: list[Rectangle], margin: float 65 | ) -> list[Rectangle]: 66 | """ 67 | 贪心策略 68 | O(N^2) 69 | """ 70 | if len(rectangles) == 0: 71 | return [] 72 | 73 | def append_right( 74 | origin: Rectangle, rect: Rectangle, rects: list[Rectangle] 75 | ) -> Rectangle: 76 | ret = Rectangle( 77 | NumberVector(rect.location_left_top.x, rect.location_left_top.y), 78 | rect.width, 79 | rect.height, 80 | ) 81 | ret.location_left_top.x = origin.right() + margin 82 | ret.location_left_top.y = origin.top() 83 | # 碰撞检测 84 | collision = True 85 | while collision: 86 | collision = False 87 | for r in rects: 88 | if ret.is_collision(r, margin=margin): 89 | ret.location_left_top.y = r.bottom() + margin 90 | ret.location_left_top.x = max( 91 | ret.location_left_top.x, r.right() + margin 92 | ) 93 | collision = True 94 | break 95 | return ret 96 | 97 | def append_bottom( 98 | origin: Rectangle, rect: Rectangle, rects: list[Rectangle] 99 | ) -> Rectangle: 100 | ret = Rectangle( 101 | NumberVector(rect.location_left_top.x, rect.location_left_top.y), 102 | rect.width, 103 | rect.height, 104 | ) 105 | ret.location_left_top.y = origin.bottom() + margin 106 | ret.location_left_top.x = origin.left() 107 | # 碰撞检测 108 | collision = True 109 | while collision: 110 | collision = False 111 | for r in rects: 112 | if ret.is_collision(r, margin=margin): 113 | ret.location_left_top.x = r.right() + margin 114 | ret.location_left_top.y = max( 115 | ret.location_left_top.y, r.bottom() + margin 116 | ) 117 | collision = True 118 | break 119 | return ret 120 | 121 | rectangles[0].location_left_top.x = 0 122 | rectangles[0].location_left_top.y = 0 123 | ret = [rectangles[0]] 124 | width = rectangles[0].width 125 | height = rectangles[0].height 126 | for i in range(1, len(rectangles)): 127 | min_space_score = -1 128 | min_shape_score = -1 129 | min_rect = None 130 | for j in range(len(ret)): 131 | r = append_right(ret[j], rectangles[i], ret) 132 | space_score = r.right() - width + r.bottom() - height 133 | shape_score = abs(max(r.right(), width) - max(r.bottom(), height)) 134 | if ( 135 | min_space_score == -1 136 | or space_score < min_space_score 137 | or (space_score == min_space_score and shape_score < min_shape_score) 138 | ): 139 | min_space_score = space_score 140 | min_shape_score = shape_score 141 | min_rect = r 142 | r = append_bottom(ret[j], rectangles[i], ret) 143 | space_score = r.right() - width + r.bottom() - height 144 | shape_score = abs(max(r.right(), width) - max(r.bottom(), height)) 145 | if ( 146 | min_space_score == -1 147 | or space_score < min_space_score 148 | or (space_score == min_space_score and shape_score < min_shape_score) 149 | ): 150 | min_space_score = space_score 151 | min_shape_score = shape_score 152 | min_rect = r 153 | width = max(width, r.right()) 154 | height = max(height, r.bottom()) 155 | assert min_rect is not None 156 | ret.append(min_rect) 157 | 158 | return ret 159 | 160 | 161 | def sort_rectangle_many_files_less_folders( 162 | rectangles: List[Rectangle], margin: float 163 | ) -> list[Rectangle]: 164 | """ 165 | 多文件,少文件夹的情况 166 | 文件夹排在左上角,只拍成一行 167 | 文件另起一行以矩阵形式排列 168 | """ 169 | # 如何判定一个文件是文件夹还是文件?矩形的高度=100是文件,>100是文件夹 170 | files = [r for r in rectangles if r.height <= 100] 171 | folders = [r for r in rectangles if r.height > 100] 172 | files = sort_rectangle_all_files(files, margin) 173 | folders = sort_rectangle_all_files(folders, margin) 174 | # 找到文件夹列表中最靠左下角的那个文件夹矩形的左下角坐标 175 | min_x = min(folders, key=lambda r: r.location_left_top.x).location_left_top.x 176 | 177 | max_bottom_folder = max(folders, key=lambda r: r.bottom()) 178 | 179 | max_y = max_bottom_folder.bottom() + margin 180 | for file in files: 181 | file.location_left_top.x += min_x 182 | file.location_left_top.y += max_y 183 | # 看似没排,其实是排好了 184 | return rectangles 185 | pass 186 | 187 | 188 | def sort_rectangle_all_files( 189 | rectangles: List[Rectangle], margin: float 190 | ) -> list[Rectangle]: 191 | """ 192 | 专门解决一个文件夹里面全都是小文件的情况的矩形摆放位置的情况。 193 | 注:这种情况只适用于全是文件,没有文件夹的情况。 194 | """ 195 | if len(rectangles) == 0: 196 | return [] 197 | 198 | # 先找到所有矩形中最宽的矩形宽度 199 | max_width = 0 200 | for r in rectangles: 201 | if r.width > max_width: 202 | max_width = r.width 203 | 204 | # 再找到所有矩形中最高的矩形高度 205 | max_height = 0 206 | for r in rectangles: 207 | if r.height > max_height: 208 | max_height = r.height 209 | 210 | # 假设按照正方形摆放,不管宽高比例,边上的数量 211 | count_in_side = math.ceil(len(rectangles) ** 0.5) 212 | y_index = 0 213 | x_index = 0 214 | 215 | for rectangle in rectangles: 216 | if x_index > count_in_side - 1: 217 | x_index = 0 218 | y_index += 1 219 | rectangle.location_left_top.x = x_index * (max_width + margin) 220 | rectangle.location_left_top.y = y_index * (max_height + margin) 221 | x_index += 1 222 | 223 | return rectangles 224 | 225 | 226 | def sort_rectangle_right_bottom( 227 | rectangles: list[Rectangle], margin: float 228 | ) -> list[Rectangle]: 229 | """不停的往右下角放的策略""" 230 | 231 | def append_right( 232 | origin: Rectangle, rect: Rectangle, rects: list[Rectangle] 233 | ) -> None: 234 | rect.location_left_top.x = origin.right() + margin 235 | rect.location_left_top.y = origin.top() 236 | # 碰撞检测 237 | collision = True 238 | while collision: 239 | collision = False 240 | for r in rects: 241 | if rect.is_collision(r): 242 | rect.location_left_top.y = r.bottom() + margin 243 | collision = True 244 | break 245 | 246 | def append_bottom( 247 | origin: Rectangle, rect: Rectangle, rects: list[Rectangle] 248 | ) -> None: 249 | rect.location_left_top.y = origin.bottom() + margin 250 | rect.location_left_top.x = origin.left() 251 | # 碰撞检测 252 | collision = True 253 | while collision: 254 | collision = False 255 | for r in rects: 256 | if rect.is_collision(r): 257 | rect.location_left_top.x = r.right() + margin 258 | collision = True 259 | break 260 | 261 | rectangles[0].location_left_top.x = 0 262 | rectangles[0].location_left_top.y = 0 263 | ret = [rectangles[0]] 264 | width = rectangles[0].width 265 | height = rectangles[0].height 266 | index = 0 267 | for i in range(1, len(rectangles)): 268 | if width < height: 269 | append_right(rectangles[index], rectangles[i], ret) 270 | w = rectangles[i].right() 271 | if w > width: 272 | width = w 273 | index = i 274 | else: 275 | append_bottom(rectangles[index], rectangles[i], ret) 276 | h = rectangles[i].bottom() 277 | if h > height: 278 | height = h 279 | index = i 280 | ret.append(rectangles[i]) 281 | 282 | return ret 283 | -------------------------------------------------------------------------------- /tools/string_tools.py: -------------------------------------------------------------------------------- 1 | def get_width_by_file_name(file_name: str) -> int: 2 | """ 3 | 根据文件名获取宽度 4 | 一个英文或者标点数字字符占24像素,一个汉字占48像素 5 | :param file_name: 6 | :return: 7 | """ 8 | res = 0 9 | for c in file_name: 10 | if "\u4e00" <= c <= "\u9fff": 11 | res += 48 12 | else: 13 | res += 24 14 | return res 15 | -------------------------------------------------------------------------------- /tools/threads.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtCore import QThread, pyqtSignal 2 | 3 | from file_observer import FileObserver 4 | 5 | 6 | class OpenFolderThread(QThread): 7 | def __init__(self, observer: FileObserver, directory, parent=None): 8 | super(OpenFolderThread, self).__init__(parent) 9 | self._observer = observer 10 | self._directory = directory 11 | 12 | def run(self): 13 | self._observer.update_file_path(self._directory) 14 | -------------------------------------------------------------------------------- /visual-file.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | a = Analysis( 5 | ['main.py'], 6 | pathex=[], 7 | binaries=[], 8 | datas=[], 9 | hiddenimports=[], 10 | hookspath=[], 11 | hooksconfig={}, 12 | runtime_hooks=[], 13 | excludes=[], 14 | noarchive=False, 15 | optimize=0, 16 | ) 17 | pyz = PYZ(a.pure) 18 | 19 | exe = EXE( 20 | pyz, 21 | a.scripts, 22 | a.binaries, 23 | a.datas, 24 | [], 25 | name='visual-file', 26 | debug=False, 27 | bootloader_ignore_signals=False, 28 | strip=False, 29 | upx=True, 30 | upx_exclude=[], 31 | runtime_tmpdir=None, 32 | console=False, 33 | disable_windowed_traceback=False, 34 | argv_emulation=False, 35 | target_arch=None, 36 | codesign_identity=None, 37 | entitlements_file=None, 38 | icon=['assets\\favicon.ico'], 39 | ) 40 | --------------------------------------------------------------------------------