├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── LICENSE ├── Main.py ├── README.md ├── assets ├── fonts │ └── SourceHanSansCN-Medium.otf ├── icons_file │ ├── i_add.png │ ├── i_added.png │ ├── i_delete.png │ ├── i_deleted.png │ ├── i_file.png │ ├── i_folder.png │ ├── i_modified.png │ ├── i_modify.png │ ├── i_unchecked.png │ └── icons.psd └── icons_ui │ ├── i_archive.png │ ├── i_arrow_right.png │ ├── i_arrow_up.png │ ├── i_binoculars.png │ ├── i_browser.png │ ├── i_cancel.png │ ├── i_download.png │ ├── i_file.png │ ├── i_okay.png │ ├── i_paste.png │ ├── i_pause.png │ ├── i_play.png │ ├── i_rhombus.png │ ├── i_sitemap.png │ ├── i_synchronization.png │ ├── i_tools.png │ └── i_upload.png ├── docs └── imgs │ └── demo_dev_1.jpg ├── poetry.lock ├── pyproject.toml └── src ├── ArkStudioApp.py ├── __init__.py ├── backend ├── ABHandler.py ├── ArkClient.py ├── ArkClientPayload.py ├── GitHubClient.py ├── GitHubClientPayload.py └── __init__.py ├── dialogs ├── SelectVersionDialog.py └── __init__.py ├── pages ├── ABResolverPage.py ├── ArkStudioAppInterface.py ├── ResourceManagerPage.py └── __init__.py └── utils ├── AnalyUtils.py ├── AudioComposer.py ├── BiMap.py ├── Config.py ├── Logger.py ├── OSUtils.py ├── UIComponents.py ├── UIConcurrent.py ├── UIStyles.py └── __init__.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Exclude 2 | /*/ 3 | __pycache__/ 4 | *.exe 5 | *.json 6 | *.log 7 | 8 | # Include 9 | !.github/ 10 | !.vscode/ 11 | !assets/ 12 | !docs/ 13 | !src/ 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // 使用 IntelliSense 了解相关属性。 3 | // 悬停以查看现有属性的描述。 4 | // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: ArkStudio", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "program": "Main.py", 12 | "console": "integratedTerminal", 13 | "justMyCode": true 14 | }, 15 | { 16 | "name": "Python: Current File", 17 | "type": "debugpy", 18 | "request": "launch", 19 | "program": "${file}", 20 | "console": "integratedTerminal", 21 | "justMyCode": true 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "pylint.args": [ 3 | "--max-line-length=120", 4 | "--disable=R,W0212,W0223,W0621,W0622,W0718,C3001" 5 | ], 6 | "cSpell.language": "en,en-US,en-GB", 7 | "cSpell.words": [ 8 | "columnspan", 9 | "customtkinter", 10 | "minwidth", 11 | "padx", 12 | "pady", 13 | "pathid", 14 | "rowspan", 15 | "rtype", 16 | "tabview", 17 | "tkinter", 18 | "torappu" 19 | ], 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Project Setup", 6 | "type": "shell", 7 | "command": "poetry install", 8 | "group": { 9 | "kind": "build", 10 | "isDefault": true 11 | }, 12 | "icon": { 13 | "id": "project" 14 | }, 15 | "runOptions": { 16 | "runOn": "folderOpen", 17 | "instanceLimit": 1 18 | } 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Harry Huang 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /Main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | from src.ArkStudioApp import App 5 | from src.utils.AnalyUtils import TestRT 6 | 7 | 8 | app = App() 9 | app.mainloop() 10 | print(TestRT.get_avg_time_all()) 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |

Ark-Studio

7 |

8 | Arknights Assets Studio | 明日方舟游戏资源集成式管理平台
9 | WIP 10 |

11 |

12 | GitHub Top Language 13 | GitHub License 14 | Code Factor Grade 15 |

16 | 17 | This project only supports Chinese docs. If you are an English user, feel free to contact us. 18 | 19 |
20 | 21 | ## 介绍 Intro 22 | 23 | **正在开发中(This Project is Now Working in Progress...)** 24 | 25 | **ArkStudio** 是为游戏《明日方舟》开发的,能够一体化管理游戏资源的非官方项目。 26 | 27 | #### 背景 28 | 29 | 本项目的前身是 [ArkUnpacker](https://github.com/isHarryh/Ark-Unpacker) 解包器。由于后者拷贝所需游戏资源的方法繁琐、不能实现对整个游戏资源库的浏览、不能预览单个游戏文件的内容,因此特地开发此项目,旨在实现对游戏资源库的查看、下载和单向版本控制,以及对游戏资源文件的解包、预览和提取。 30 | 31 | #### 实现的功能 32 | 33 | 1. **资源库管理** 34 | - [x] 浏览本地资源库 35 | - [x] 比较并显示本地资源库和官方资源库的差异 36 | - [x] 从官方资源库下载或同步文件到本地 37 | - [x] 切换到指定的资源库版本 38 | - [ ] 按关键词搜索指定的文件 39 | - [ ] 多线程下载与解压 40 | 2. **AB 文件解包** 41 | - [x] 浏览 AB 文件的对象列表 42 | - [x] 预览文本和二进制文件 43 | - [x] 预览图片文件 44 | - [x] 预览音频文件 45 | - [ ] 区分显示不可提取对象和可提取对象 46 | - [ ] 提取和批量提取资源文件到本地 47 | - [ ] 按关键词或类型搜索指定的对象 48 | 3. **RGB-A 图片合并** 49 | - [ ] 选择并显示指定的 RGB 图和 Alpha 图 50 | - [ ] 导出合并后的图片到本地 51 | - [ ] 在文件夹中批量实现上述功能 52 | 4. **FlatBuffers 数据解码** 53 | - [ ] 选择指定的二进制文件并显示预测的 Schema 54 | - [ ] 导出解码后的数据到本地 55 | - [ ] 在文件夹中批量实现上述功能 56 | 5. **其他** 57 | - [ ] 提供错误提示 58 | - [ ] 提供设置选项 59 | - [ ] 提供教程组件 60 | 61 | #### 效果预览图 62 | 63 | 64 | 65 | 66 | 67 |
demo1
68 | 69 | ## 注意事项 Notice 70 | 71 | 用户应当在合理的范围内使用本项目。严禁将本项目的软件所提取的游戏资源内容用于商业用途或损害版权方(上海鹰角网络有限公司)的相关利益。 72 | 73 | ## 使用方法 Usage 74 | 75 | 本项目的 GUI 基于 tkinter 和 customtkinter 开发,采用 Poetry 作为依赖管理系统。 76 | 77 | 当前项目正处于开发阶段,功能尚不完整,请静候佳音。 78 | 79 | ## 许可证 Licensing 80 | 81 | 本项目基于 **BSD-3 开源协议**。任何人都可以自由地使用和修改项目内的源代码,前提是要在源代码或版权声明中保留作者说明和原有协议,且不可以使用本项目名称或作者名称进行宣传推广。 82 | -------------------------------------------------------------------------------- /assets/fonts/SourceHanSansCN-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/fonts/SourceHanSansCN-Medium.otf -------------------------------------------------------------------------------- /assets/icons_file/i_add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_file/i_add.png -------------------------------------------------------------------------------- /assets/icons_file/i_added.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_file/i_added.png -------------------------------------------------------------------------------- /assets/icons_file/i_delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_file/i_delete.png -------------------------------------------------------------------------------- /assets/icons_file/i_deleted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_file/i_deleted.png -------------------------------------------------------------------------------- /assets/icons_file/i_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_file/i_file.png -------------------------------------------------------------------------------- /assets/icons_file/i_folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_file/i_folder.png -------------------------------------------------------------------------------- /assets/icons_file/i_modified.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_file/i_modified.png -------------------------------------------------------------------------------- /assets/icons_file/i_modify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_file/i_modify.png -------------------------------------------------------------------------------- /assets/icons_file/i_unchecked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_file/i_unchecked.png -------------------------------------------------------------------------------- /assets/icons_file/icons.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_file/icons.psd -------------------------------------------------------------------------------- /assets/icons_ui/i_archive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_ui/i_archive.png -------------------------------------------------------------------------------- /assets/icons_ui/i_arrow_right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_ui/i_arrow_right.png -------------------------------------------------------------------------------- /assets/icons_ui/i_arrow_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_ui/i_arrow_up.png -------------------------------------------------------------------------------- /assets/icons_ui/i_binoculars.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_ui/i_binoculars.png -------------------------------------------------------------------------------- /assets/icons_ui/i_browser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_ui/i_browser.png -------------------------------------------------------------------------------- /assets/icons_ui/i_cancel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_ui/i_cancel.png -------------------------------------------------------------------------------- /assets/icons_ui/i_download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_ui/i_download.png -------------------------------------------------------------------------------- /assets/icons_ui/i_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_ui/i_file.png -------------------------------------------------------------------------------- /assets/icons_ui/i_okay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_ui/i_okay.png -------------------------------------------------------------------------------- /assets/icons_ui/i_paste.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_ui/i_paste.png -------------------------------------------------------------------------------- /assets/icons_ui/i_pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_ui/i_pause.png -------------------------------------------------------------------------------- /assets/icons_ui/i_play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_ui/i_play.png -------------------------------------------------------------------------------- /assets/icons_ui/i_rhombus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_ui/i_rhombus.png -------------------------------------------------------------------------------- /assets/icons_ui/i_sitemap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_ui/i_sitemap.png -------------------------------------------------------------------------------- /assets/icons_ui/i_synchronization.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_ui/i_synchronization.png -------------------------------------------------------------------------------- /assets/icons_ui/i_tools.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_ui/i_tools.png -------------------------------------------------------------------------------- /assets/icons_ui/i_upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/assets/icons_ui/i_upload.png -------------------------------------------------------------------------------- /docs/imgs/demo_dev_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isHarryh/Ark-Studio/fee100e9a3429ba28c7a250f8a2e629b1dbf6d5f/docs/imgs/demo_dev_1.jpg -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "ArkStudio" 3 | version = "0.1.0" 4 | description = "Arknights Assets Studio" 5 | authors = [ 6 | { name = "Harry Huang", email = "harryhuang2652@qq.com" } 7 | ] 8 | license = "BSD-3-Clause" 9 | readme = "README.md" 10 | requires-python = ">=3.9,<3.13" 11 | dependencies = [ 12 | "flatbuffers (~=24.3)", 13 | "numpy (~=1.26)", 14 | "Pillow (~=9.5)", 15 | "UnityPy (~=1.22)", 16 | "customtkinter (~=5.2)", 17 | "requests (~=2.32)", 18 | "simpleaudio (~=1.0)" 19 | ] 20 | 21 | [tool.poetry] 22 | package-mode = false 23 | 24 | [[tool.poetry.source]] 25 | name = "PyPI-Tsinghua" 26 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 27 | priority = "primary" 28 | 29 | [tool.poetry.group.dev.dependencies] 30 | pyinstaller = "6.12.0" 31 | 32 | [build-system] 33 | requires = ["poetry-core>=2.0"] 34 | build-backend = "poetry.core.masonry.api" 35 | -------------------------------------------------------------------------------- /src/ArkStudioApp.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | import customtkinter as ctk 5 | from src.pages.ResourceManagerPage import ResourceManagerPage 6 | from src.pages.ABResolverPage import ABResolverPage 7 | from src.utils.UIStyles import style, load_font_asset, load_ttk_style 8 | from src.utils import UIComponents as uic 9 | 10 | 11 | ctk.set_appearance_mode("light") 12 | ctk.ThemeManager.load_theme("assets/theme.json") 13 | 14 | class App(ctk.CTk): 15 | def __init__(self): 16 | super().__init__() 17 | # Init 18 | load_font_asset() 19 | load_ttk_style(self) 20 | 21 | # Window 22 | self.title("ArkStudio Dev") 23 | w = 1280 24 | h = 720 25 | x = (self.winfo_screenwidth() - w) // 2 26 | y = (self.winfo_screenheight() - h) // 2 27 | self.geometry(f"{w}x{h}+{x}+{y}") 28 | 29 | # Grid 30 | self.grid_rowconfigure((0), weight=1) 31 | self.grid_columnconfigure((1), weight=1) 32 | 33 | # Pages 34 | self.p_rm = ResourceManagerPage(self, 0, 1) 35 | self.p_ar = ABResolverPage(self, 0, 1) 36 | self.p_ic = uic.InfoLabelGroup(self, 0, 0, "WIP", "此页面正在开发中") 37 | self.p_fd = uic.InfoLabelGroup(self, 0, 0, "WIP", "此页面正在开发中") 38 | 39 | # Sidebar 40 | self.sidebar = Sidebar(self) 41 | self.sidebar.grid(row=0, column=0, rowspan=4, sticky='nsew') 42 | 43 | 44 | class Sidebar(ctk.CTkFrame): 45 | def __init__(self, app:App): 46 | super().__init__(app, width=150, corner_radius=0) 47 | self.app = app 48 | self.configure(**style('menu')) 49 | self.brand = ctk.CTkLabel(self, text="ArkStudio", **style('banner')) 50 | self.brand.grid(row=0, column=0, padx=20, pady=20) 51 | self.menu_buttons:"dict[int,ctk.CTkButton]" = {} 52 | self.pages:"dict[int,uic.HidableGridWidget]" = {} 53 | self._add_menu_button(1, "资源库管理", app.p_rm) 54 | self._add_menu_button(2, "AB文件解包", app.p_ar) 55 | self._add_menu_button(3, "RGB-A图片合并", app.p_ic) 56 | self._add_menu_button(4, "FlatBuffers数据解码", app.p_fd) 57 | self.activate_menu_button(1) 58 | 59 | def _add_menu_button(self, row_index:int, text:str, bind_page:uic.HidableGridWidget): 60 | btn = ctk.CTkButton(self, 61 | text=text, 62 | command=lambda: self.activate_menu_button(row_index), 63 | **style('menu_btn')) 64 | btn.grid(row=row_index, column=0, padx=15, pady=10) 65 | self.menu_buttons[row_index] = btn 66 | self.pages[row_index] = bind_page 67 | 68 | def activate_menu_button(self, row_index:int): 69 | for i, b in self.menu_buttons.items(): 70 | b.configure(**style('menu_btn_active' if i == row_index else 'menu_btn_regular')) 71 | for i, p in self.pages.items(): 72 | p.set_visible(i == row_index) 73 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | -------------------------------------------------------------------------------- /src/backend/ABHandler.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | import os 5 | import UnityPy 6 | from UnityPy import classes 7 | from UnityPy.files import ObjectReader 8 | from ..utils.AnalyUtils import TestRT 9 | 10 | 11 | class ABHandler: 12 | def __init__(self, path:str): 13 | if not os.path.isfile(path): 14 | raise FileNotFoundError(path) 15 | self._path = path 16 | with TestRT('res_load'): 17 | self._env = UnityPy.load(path) 18 | with TestRT('res_get_objs'): 19 | self._objs = [] 20 | for i in self._env.objects: 21 | try: 22 | self.objects.append(ObjectInfo(i)) 23 | except AttributeError: 24 | pass 25 | 26 | @property 27 | def filepath(self): 28 | return self._path 29 | 30 | @property 31 | def objects(self): 32 | return self._objs 33 | 34 | 35 | class ObjectInfo: 36 | def __init__(self, obj:ObjectReader): 37 | if obj is None: 38 | raise ValueError("Argument obj is None") 39 | self._obj:classes.GameObject = obj.read() 40 | if getattr(self._obj, 'type', None) is None: 41 | raise AttributeError("Missing type") 42 | 43 | #################### 44 | # Basic Properties # 45 | #################### 46 | 47 | @property 48 | def name(self): 49 | """Name of the object. `-` for nameless.""" 50 | return self._obj.name if getattr(self._obj, 'name', None) else '-' 51 | 52 | @property 53 | def pathid(self): 54 | """Path ID property of the object.""" 55 | return self._obj.path_id 56 | 57 | @property 58 | def type(self): 59 | """Class ID enumeration of the object's type.""" 60 | return self._obj.type 61 | 62 | #################### 63 | # Asset Properties # 64 | #################### 65 | 66 | _HAS_SCRIPT = classes.TextAsset 67 | _HAS_IMAGE = (classes.Sprite, classes.Texture2D) 68 | _HAS_AUDIO = classes.AudioClip 69 | _EXTRACTABLE = (classes.TextAsset, classes.Sprite, classes.Texture2D, classes.AudioClip) 70 | 71 | def is_extractable(self): 72 | """Returns `True` if the object can be extracted to a file.""" 73 | return isinstance(self._obj, ObjectInfo._EXTRACTABLE) 74 | 75 | @property 76 | def script(self): 77 | """Object script asset property. Returns bytes or `None` for no script.
78 | Conventionally, only `TextAsset` objects may has script, 79 | which may be bytes of either decodable text or undecodable binary data. 80 | """ 81 | if isinstance(self._obj, ObjectInfo._HAS_SCRIPT): 82 | script = bytes(self._obj.script) 83 | return script 84 | return None 85 | 86 | @property 87 | def image(self): 88 | """Object image asset property. Returns PIL `Image` instance or `None` for no image.
89 | Conventionally, only `Sprite` and `Texture2D` objects may has image. 90 | """ 91 | if isinstance(self._obj, ObjectInfo._HAS_IMAGE): 92 | image = self._obj.image 93 | if image.width * image.height > 0: 94 | return image 95 | return None 96 | 97 | @property 98 | def audio(self): 99 | """Object audio asset property. Returns `{audio_name(str): audio_data(bytes)}` or `None` for no audio.
100 | Conventionally, only `AudioClip` objects may has audio. 101 | """ 102 | if isinstance(self._obj, ObjectInfo._HAS_AUDIO): 103 | # TODO Access lock 104 | samples = self._obj.samples 105 | if samples: 106 | return {n: d for n, d in samples.items() if isinstance(n, str) and isinstance(d, bytes)} 107 | return None 108 | 109 | # TODO Detailed media info 110 | 111 | def __repr__(self): 112 | return f"GameObject({self.type.name}, {self.name})" 113 | -------------------------------------------------------------------------------- /src/backend/ArkClient.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | import requests, zipfile 5 | from io import BytesIO 6 | from ..backend import ArkClientPayload as acp 7 | from ..utils.AnalyUtils import TestRT 8 | 9 | 10 | class ArkClientRequestError(OSError): 11 | def __init__(self, *args:object): 12 | super().__init__(*args) 13 | 14 | 15 | class ArkClientStateError(RuntimeError): 16 | def __init__(self, *args:object): 17 | super().__init__(*args) 18 | 19 | 20 | class ArkClient: 21 | """Arknights C/S communication handler.""" 22 | DEFAULT_DEVICE = 'Android' 23 | CONN_TIMEOUT = 10 24 | 25 | def __init__(self, device:str=DEFAULT_DEVICE): 26 | """Initializes an ArkClient instance. 27 | 28 | :param device: The device tag of the client; 29 | """ 30 | self._session:requests.Session = requests.Session() 31 | self._version:acp.ArkVersion = None 32 | self._config:acp.ArkNetworkConfig = None 33 | self._device:str = device 34 | 35 | def _fetch_bytes(self, url:str): 36 | try: 37 | rsp = self._session.get(url, timeout=ArkClient.CONN_TIMEOUT) 38 | if rsp.status_code == 200: 39 | return bytes(rsp.content) 40 | else: 41 | raise ArkClientRequestError(f"{rsp.status_code}: {url}") 42 | except requests.RequestException as arg: 43 | raise ArkClientRequestError(f"Failed to GET binary content: {url}") from arg 44 | 45 | def _fetch_dict(self, url:str): 46 | try: 47 | rsp = self._session.get(url, timeout=ArkClient.CONN_TIMEOUT) 48 | if rsp.status_code == 200: 49 | return dict(rsp.json()) 50 | else: 51 | raise ArkClientRequestError(f"{rsp.status_code}: {url}") 52 | except requests.RequestException as arg: 53 | raise ArkClientRequestError(f"Failed to GET JSON content: {url}") from arg 54 | 55 | def get_remote_network_config(self): 56 | """Fetches the network config from the remote.""" 57 | return acp.ArkNetworkConfig(self._fetch_dict(acp.ArkNetworkConfig.SOURCE)) 58 | 59 | def get_remote_version(self): 60 | """Fetches the version info from the remote.""" 61 | if self._config is None: 62 | raise ArkClientStateError("Network config is not initialized yet") 63 | return acp.ArkVersion.from_dict(self._fetch_dict(self._config.api_version(self._device))) 64 | 65 | def get_asset(self, name:str, unzip:bool=False): 66 | """Fetches the bytes content of a hot-update asset from the remote. 67 | 68 | :param name: The name of the asset; 69 | :param unzip: Whether to return the unzipped data; 70 | """ 71 | if self._config is None: 72 | raise ArkClientStateError("Network config is not initialized yet") 73 | if self._version is None: 74 | raise ArkClientStateError("Version is not initialized yet") 75 | data = self._fetch_bytes( 76 | f"{self._config.get('hu')}/{self._device}/assets/{self._version.res}/{name}") 77 | if unzip: 78 | with TestRT('client_unzip_mem'): 79 | with zipfile.ZipFile(BytesIO(data)) as zf: 80 | nl = zf.namelist() 81 | if len(nl) != 1: 82 | raise ArkClientStateError("Zipfile contains unexpected entry length") 83 | return zf.open(nl[0]).read() 84 | else: 85 | return data 86 | 87 | def get_repo(self): 88 | """Fetches the remote asset repository info from the remote.""" 89 | if self._config is None: 90 | raise ArkClientStateError("Network config is not initialized yet") 91 | if self._version is None: 92 | raise ArkClientStateError("Version is not initialized yet") 93 | return acp.ArkRemoteAssetsRepo(self._fetch_dict( 94 | f"{self._config.get('hu')}/{self._device}/assets/{self._version.res}/hot_update_list.json")) 95 | 96 | def set_current_network_config(self, config:acp.ArkNetworkConfig=None): 97 | """Sets the network config of the client. 98 | 99 | :param config: The network config. If `None`, an fetching from the remote will be performed; 100 | """ 101 | if config is None: 102 | config = self.get_remote_network_config() 103 | self._config = config 104 | 105 | def set_current_version(self, version:acp.ArkVersion=None): 106 | """Sets the version info of the client. 107 | 108 | :param version: The new version. If `None`, an fetching from the remote will be performed; 109 | """ 110 | if version is None: 111 | version = self.get_remote_version() 112 | self._version = version 113 | -------------------------------------------------------------------------------- /src/backend/ArkClientPayload.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | import os, re, json, hashlib 5 | from functools import total_ordering 6 | from collections import defaultdict 7 | 8 | from ..utils.Config import Config 9 | from ..utils.AnalyUtils import TestRT 10 | 11 | 12 | CONN_TIMEOUT = 10 13 | 14 | class ArkNetworkConfig: 15 | """Arknights client network config record.""" 16 | 17 | SOURCE = "https://ak-conf.hypergryph.com/config/prod/official/network_config" 18 | """Response format: 19 | ```json 20 | { 21 | "sign": "Encoded string", 22 | "content": "JSON string" 23 | } 24 | ``` 25 | """ 26 | DEFAULT_DEVICE = 'Android' 27 | 28 | def __init__(self, network_config_dict:dict): 29 | # Retrieve config from response 30 | content:"dict[str,object]" = json.loads(network_config_dict.get('content')) 31 | configs:"dict[str,dict]" = content.get('configs') 32 | # Choose the first config with override=True, or the first config if none have override=True 33 | chosen_config = None 34 | for _, config in configs.items(): 35 | if config.get('override', False): 36 | chosen_config = config 37 | break 38 | if not chosen_config: 39 | chosen_config = list(configs.values())[0] 40 | self._dict:"dict[str,str]" = chosen_config.get('network') 41 | 42 | def get(self, key:str, *args:str): 43 | value = self._dict.get(key, '') 44 | if args: 45 | value = value.format(*args) 46 | return value 47 | 48 | def api_version(self, device:str=DEFAULT_DEVICE): 49 | return self.get('hv', device) 50 | 51 | def api_assets(self, res_version:str, device:str=DEFAULT_DEVICE): 52 | return f"{self.get('hu')}/{device}/assets/{res_version}" 53 | 54 | 55 | @total_ordering 56 | class ArkVersion: 57 | """Arknights version record.""" 58 | 59 | REG_RES_VERSION = r'\d\d-\d\d-\d\d-\d\d-\d\d-\d\d[-_][\da-f]{6}' 60 | 61 | def __init__(self, res:"str|None"=None, client:"str|None"=None): 62 | self._res = res 63 | self._client = client 64 | if not re.fullmatch(self.REG_RES_VERSION, self.res): 65 | raise ValueError("Incorrect resVersion format") 66 | 67 | @property 68 | def res(self): 69 | """Arknights resource version.""" 70 | return self._res 71 | 72 | @property 73 | def client(self): 74 | """Arknights client version.""" 75 | return self._client 76 | 77 | def _normalize_version(self, version:str): 78 | parts = re.split(r'[^a-zA-Z0-9]', version.lower()) 79 | return [(0, int(char)) if char.isdigit() else (1, char) for char in parts] 80 | 81 | def _compare_versions(self, v1:str, v2:str): 82 | if v1 is None or v2 is None: 83 | return 0 84 | n1 = self._normalize_version(v1) 85 | n2 = self._normalize_version(v2) 86 | return (n1 > n2) - (n1 < n2) 87 | 88 | def __lt__(self, other): 89 | if not isinstance(other, ArkVersion): 90 | raise NotImplementedError() 91 | res_cmp = self._compare_versions(self.res, other.res) 92 | client_cmp = self._compare_versions(self.client, other.client) 93 | 94 | if res_cmp == 0 and client_cmp == 0: 95 | return False 96 | elif res_cmp >= 0 and client_cmp >= 0: 97 | return False 98 | elif res_cmp <= 0 and client_cmp <= 0: 99 | return True 100 | else: 101 | raise ValueError("Inconsistent version comparisons") 102 | 103 | def __eq__(self, other): 104 | if not isinstance(other, ArkVersion): 105 | raise NotImplementedError() 106 | return self.res == other.res and self.client == other.client 107 | 108 | def __hash__(self): 109 | return hash((self._res, self._client)) 110 | 111 | def __repr__(self): 112 | return f"Version({self._res}, {self._client})" 113 | 114 | @classmethod 115 | def from_dict(cls, rsp:dict): 116 | """Creates a ArkVersion instance from API response dictionary.""" 117 | return cls(res=rsp['resVersion'], client=rsp['clientVersion']) 118 | 119 | 120 | class AssetRepoBase: 121 | """Assets repository handler base class.""" 122 | def __init__(self): 123 | pass 124 | 125 | @property 126 | def infos(self) -> "list[FileInfoBase]": 127 | raise NotImplementedError() 128 | 129 | def get_parent_map(self) -> "dict[FileInfoBase,FileInfoBase]": 130 | # Estimated RT: 0.02-0.1s (very fast) 131 | with TestRT('map_parent'): 132 | rst = {} 133 | for i in self.infos: 134 | rst[i] = i.parent 135 | return rst 136 | 137 | def get_children_map(self) -> "dict[FileInfoBase,set[FileInfoBase]]": 138 | # Estimated RT: 0.06~0.4s (fast) 139 | with TestRT('map_children'): 140 | rst = defaultdict(set) 141 | for i in self.infos: 142 | p, c = i.parent, i 143 | while p: 144 | rst[p].add(c) 145 | p, c = p.parent, p 146 | return rst 147 | 148 | def __repr__(self): 149 | return f"AssetRepo[{len(self.infos)} items]" 150 | 151 | 152 | class ArkLocalAssetsRepo(AssetRepoBase): 153 | """Arknights local assets repository handler.""" 154 | 155 | def __init__(self, root_dir:str): 156 | super().__init__() 157 | self._root_dir = root_dir 158 | self._infos = self._fetch_infos() 159 | 160 | @property 161 | def infos(self): 162 | return self._infos 163 | 164 | @property 165 | def root_dir(self): 166 | return self._root_dir 167 | 168 | def detect_res_version(self): 169 | for i in self.infos: 170 | if i.name == 'torappu_index.ab' and i.exist(): 171 | with i.open() as f: 172 | d = f.read().decode(encoding='UTF-8', errors='replace') 173 | matches = re.findall(ArkVersion.REG_RES_VERSION, d) 174 | if len(matches) == 1: 175 | return matches[0] 176 | break 177 | 178 | def _fetch_infos(self): 179 | # Estimated RT: 1~2s (slow) 180 | with TestRT('get_infos_local'): 181 | if not os.path.isdir(self._root_dir): 182 | raise FileNotFoundError(self._root_dir) 183 | infos:"list[ArkLocalFileInfo]" = [] 184 | for root, _, files in os.walk(self._root_dir): 185 | for f in files: 186 | name = os.path.realpath(os.path.join(root, f)) 187 | name = os.path.relpath(name, self._root_dir) 188 | name.replace('\\', '/') 189 | if any(re.match(p, name) for p in Config.get('local_ignore')): 190 | continue 191 | infos.append(ArkLocalFileInfo(name, self._root_dir)) 192 | return infos 193 | 194 | 195 | class ArkRemoteAssetsRepo(AssetRepoBase): 196 | """Arknights remote assets repository handler.""" 197 | 198 | def __init__(self, hot_update_list_dict:dict): 199 | super().__init__() 200 | # Estimated RT: 0.01-0.02s (very fast) 201 | with TestRT('get_infos_remote'): 202 | self._infos:"list[ArkRemoteFileInfo]" = \ 203 | [ArkRemoteFileInfo(i) for i in hot_update_list_dict.get('abInfos')] 204 | self._packs:"list[ArkPackInfo]" = \ 205 | [ArkPackInfo(i) for i in hot_update_list_dict.get('packInfos')] 206 | self._version:ArkVersion = ArkVersion(res=hot_update_list_dict.get('versionId')) 207 | 208 | @property 209 | def infos(self): 210 | return self._infos 211 | 212 | @property 213 | def packs(self): 214 | return self._packs 215 | 216 | @property 217 | def version(self): 218 | return self._version 219 | 220 | 221 | class FileInfoBase: 222 | """File information record base class.""" 223 | SEP = '/' 224 | RADIX = 1024 225 | UNITS = ('B', 'KB', 'MB', 'GB', 'TB') 226 | 227 | def __init__(self): 228 | self.__basename = None 229 | self.__parent = None 230 | 231 | @property 232 | def name(self) -> str: 233 | """Name. This property is the identifier and must be implemented by descendants classes.""" 234 | raise NotImplementedError() 235 | 236 | @property 237 | def status(self) -> int: 238 | """Version control status. This property may be implemented by descendants classes.""" 239 | raise NotImplementedError() 240 | 241 | @property 242 | def file_size(self) -> int: 243 | """File size in bytes. This property may be implemented by descendants classes.""" 244 | raise NotImplementedError() 245 | 246 | @property 247 | def basename(self) -> str: 248 | """Base name. This property is lazily auto generated by the property `name`.""" 249 | if self.__basename is None: 250 | chain = self.name.split(FileInfoBase.SEP) 251 | self.__basename = chain[-1] if len(chain) > 0 else '' 252 | return self.__basename 253 | 254 | @property 255 | def parent(self) -> "FileInfoBase|None": 256 | """Parent file info. This property is lazily auto generated by the property `name`.""" 257 | if not self.name: 258 | return None 259 | if self.__parent is None: 260 | chain = self.name.split(FileInfoBase.SEP) 261 | self.__parent = DirFileInfo(FileInfoBase.SEP.join(chain[:-1]) if len(chain) > 1 else '') 262 | return self.__parent 263 | 264 | def get_file_size_str(self, digits:int=0): 265 | try: 266 | s = self.file_size 267 | for i in FileInfoBase.UNITS: 268 | if s > FileInfoBase.RADIX: 269 | s /= FileInfoBase.RADIX 270 | else: 271 | break 272 | return f"{s:.{digits}f} {i}" 273 | except NotImplementedError: 274 | return "" 275 | 276 | def __eq__(self, other:object): 277 | if isinstance(other, FileInfoBase): 278 | return self.name == other.name 279 | return False 280 | 281 | def __hash__(self): 282 | return hash(self.name) 283 | 284 | def __repr__(self): 285 | return f"File({self.name})" 286 | 287 | 288 | class DirFileInfo(FileInfoBase): 289 | """Simple implementation of directory file information record.""" 290 | 291 | def __init__(self, name:str): 292 | super().__init__() 293 | self._name = name 294 | 295 | @property 296 | def name(self): 297 | return self._name 298 | 299 | @property 300 | def status(self): 301 | return FileStatus.DIRECTORY 302 | 303 | 304 | class ArkLocalFileInfo(FileInfoBase): 305 | """Arknights local file information record.""" 306 | 307 | def __init__(self, name:str, root_dir:str): 308 | super().__init__() 309 | self._name = name.replace(os.sep, '/') 310 | self._root_dir = root_dir 311 | self._path = os.path.join(self._root_dir, self._name).replace(os.sep, '/') 312 | 313 | @property 314 | def name(self): 315 | return self._name 316 | 317 | @property 318 | def path(self): 319 | return self._path 320 | 321 | @property 322 | def status(self): 323 | return FileStatus.UNCHECKED 324 | 325 | @property 326 | def md5(self): 327 | if os.path.isfile(self._path): 328 | byte = open(self._path, 'rb').read() 329 | return hashlib.md5(byte).hexdigest() 330 | return '' 331 | 332 | @property 333 | def file_size(self): 334 | if not os.path.isfile(self._path): 335 | return 0 336 | with open(self._path, 'rb') as f: 337 | return f.seek(0, os.SEEK_END) 338 | 339 | def exist(self): 340 | return os.path.isfile(self._path) 341 | 342 | def open(self): 343 | if self.exist(): 344 | return open(self._path, 'rb') 345 | 346 | def delete(self): 347 | if self.exist(): 348 | os.unlink(self._path) 349 | 350 | 351 | class ArkRemoteFileInfo(FileInfoBase): 352 | """Arknights remote file information record.""" 353 | 354 | def __init__(self, info_dict:dict): 355 | super().__init__() 356 | self._name:str = info_dict.get('name') # Required 357 | # Unused self._hash:str = info_dict.get('hash') # Required 358 | self._md5:str = info_dict.get('md5') # Required 359 | self._data_size:int = int(info_dict.get('totalSize')) # Required 360 | self._file_size:int = int(info_dict.get('abSize')) # Required 361 | # Unused self._thash:str = info_dict.get('thash', None) 362 | self._type:str = info_dict.get('type', None) 363 | self._pack:str = info_dict.get('pid', None) 364 | # Unused self._cid:int = info_dict.get('cid') # Required 365 | 366 | @property 367 | def name(self): 368 | return self._name 369 | 370 | @property 371 | def status(self): 372 | return FileStatus.UNCHECKED 373 | 374 | @property 375 | def md5(self): 376 | return self._md5 377 | 378 | @property 379 | def data_size(self): 380 | return self._data_size 381 | 382 | @property 383 | def file_size(self): 384 | return self._file_size 385 | 386 | @property 387 | def type(self): 388 | return self._type 389 | 390 | @property 391 | def pack(self): 392 | return self._pack 393 | 394 | @property 395 | def data_name(self): 396 | d_name = self._name.replace('/', '_').replace('#', '__') 397 | ext_matches = list(re.finditer(r'\..+', d_name)) 398 | if ext_matches: 399 | start, end = ext_matches[-1].span() 400 | d_name = f'{d_name[:start]}.dat{d_name[end:]}' 401 | return d_name 402 | 403 | 404 | class ArkPackInfo: 405 | def __init__(self, info_dict:dict): 406 | self._name:str = info_dict.get('name') # Required 407 | self._data_size:int = int(info_dict.get('totalSize')) # Required 408 | # Unused self._cid:int = info_dict.get('cid') # Required 409 | 410 | @property 411 | def name(self): 412 | return self._name 413 | 414 | @property 415 | def data_size(self): 416 | return self._data_size 417 | 418 | def __repr__(self): 419 | return f"Pack({self._name})" 420 | 421 | 422 | class FileStatus: 423 | DIRECTORY = -1 424 | UNCHECKED = 0 425 | OKAY = 1 426 | ADD = 2 427 | ADDED = 3 428 | MODIFY = 4 429 | MODIFIED = 5 430 | DELETE = 6 431 | DELETED = 7 432 | _DESC = ["未检查", "无变更", "新增", "已同步新增", "修改", "已同步修改", "删除", "已同步删除"] 433 | 434 | @staticmethod 435 | def to_str(status:int): 436 | return FileStatus._DESC[status] if 0 <= status <= 7 else "" 437 | 438 | 439 | class ArkIntegratedAssetRepo(AssetRepoBase): 440 | """Arknights integrated assets repository handler.""" 441 | 442 | def __init__(self, local:ArkLocalAssetsRepo, remote:ArkRemoteAssetsRepo): 443 | super().__init__() 444 | self._local = local 445 | self._remote = remote 446 | 447 | @property 448 | def infos(self): 449 | # Estimated RT: 0.01-0.07s (very fast) 450 | with TestRT('get_infos_integrated'): 451 | name2local = {l.name: l for l in self._local.infos} 452 | name2remote = {r.name: r for r in self._remote.infos} 453 | infos:"list[ArkIntegratedFileInfo]" = [] 454 | for l in self._local.infos: 455 | r = name2remote.get(l.name, None) 456 | infos.append(ArkIntegratedFileInfo(l, r)) 457 | for r in self._remote.infos: 458 | if r.name not in name2local: 459 | l = ArkLocalFileInfo(r.name, self._local.root_dir) 460 | infos.append(ArkIntegratedFileInfo(l, r)) 461 | return infos 462 | 463 | @property 464 | def local(self): 465 | return self._local 466 | 467 | @property 468 | def remote(self): 469 | return self._remote 470 | 471 | 472 | class ArkIntegratedFileInfo(FileInfoBase): 473 | """Arknights integrated file information record.""" 474 | 475 | def __init__(self, local:ArkLocalFileInfo, remote:ArkRemoteFileInfo=None): 476 | super().__init__() 477 | if local is None: 478 | raise ValueError("Argument local is none") 479 | if remote: 480 | if local.name != remote.name: 481 | raise ValueError("Inconsistent file name between the given local and remote") 482 | self._name = local.name 483 | self._local = local 484 | self._remote = remote 485 | self._status_cache = None 486 | 487 | @property 488 | def name(self): 489 | return self._name 490 | 491 | @property 492 | def path(self): 493 | return self.local.path 494 | 495 | @property 496 | def status(self): 497 | self._status_cache = self.get_status() 498 | return self._status_cache 499 | 500 | @property 501 | def file_size(self): 502 | return self.remote.file_size if self.remote else self.local.file_size 503 | 504 | @property 505 | def local(self): 506 | return self._local 507 | 508 | @property 509 | def remote(self): 510 | return self._remote 511 | 512 | def get_status(self): 513 | s_local = self._local.file_size 514 | s_remote = self._remote.file_size if self._remote else 0 515 | # Ensure local file existence 516 | if not s_local: 517 | return FileStatus.ADD if s_remote else FileStatus.DELETED 518 | # Check file size consistency 519 | if s_local == s_remote: 520 | md5_local = self.local.md5 521 | md5_remote = self.remote.md5 522 | # Check MD5 consistency 523 | if ( 524 | md5_local == md5_remote 525 | or len(md5_local) != len(md5_remote) # MD5 unavailable 526 | ): 527 | return ( 528 | FileStatus.MODIFIED if self._status_cache in (FileStatus.MODIFY, FileStatus.MODIFIED) else 529 | FileStatus.ADDED if self._status_cache in (FileStatus.ADD, FileStatus.ADDED) else 530 | FileStatus.OKAY 531 | ) 532 | return FileStatus.MODIFY if s_remote else FileStatus.DELETE 533 | -------------------------------------------------------------------------------- /src/backend/GitHubClient.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | import requests 5 | from datetime import datetime 6 | from ..backend import GitHubClientPayload as ghcp 7 | 8 | 9 | class GitHubClientRequestError(OSError): 10 | def __init__(self, *args:object): 11 | super().__init__(*args) 12 | 13 | 14 | class GitHubClientStateError(RuntimeError): 15 | def __init__(self, *args:object): 16 | super().__init__(*args) 17 | 18 | 19 | class GitHubClient: 20 | """GitHub C/S communication handler.""" 21 | CONN_TIMEOUT = 10 22 | BASE_URL_REPOS = "https://api.github.com/repos" 23 | 24 | def __init__(self): 25 | self._session:requests.Session = requests.Session() 26 | 27 | def _fetch_list(self, url:str, params:"dict|None"=None): 28 | try: 29 | rsp = self._session.get(url, params=params, timeout=GitHubClient.CONN_TIMEOUT) 30 | if rsp.status_code == 200: 31 | return list(rsp.json()) 32 | else: 33 | raise GitHubClientStateError(f"{rsp.status_code}: {url}") 34 | except requests.RequestException as arg: 35 | raise GitHubClientRequestError(f"Failed to GET JSON content: {url}") from arg 36 | 37 | def get_commits( 38 | self, 39 | repo:str, 40 | sha:"str|None"=None, 41 | path:"str|None"=None, 42 | since:"datetime|None"=None, 43 | until:"datetime|None"=None, 44 | committer:"str|None"=None, 45 | per_page:"int|None"=None, 46 | page:"int|None"=None, 47 | ) -> "list[ghcp.GitHubCommitData]": 48 | """Fetches commit history for a repository. 49 | 50 | :param repo: The repository in the format `owner/repo`; 51 | :param sha: Branch name or commit SHA (optional); 52 | :param path: Path to a file (optional); 53 | :param since: Start date for filtering commits. (optional); 54 | :param until: End date for filtering commits. (optional); 55 | :param committer: Committer username. (optional); 56 | :param per_page: Number of commits per page. (optional); 57 | :param page: Page number. (optional); 58 | :returns: A list of GitHubCommitData objects; 59 | """ 60 | url = f"{GitHubClient.BASE_URL_REPOS}/{repo}/commits" 61 | params = {} 62 | 63 | if sha: 64 | params['sha'] = str(sha) 65 | if path: 66 | params['path'] = str(path) 67 | if since: 68 | params['since'] = ghcp.datetime_to_iso_utc_str(since) 69 | if until: 70 | params['until'] = ghcp.datetime_to_iso_utc_str(until) 71 | if committer: 72 | params['committer'] = str(committer) 73 | if per_page: 74 | params['per_page'] = str(per_page) 75 | if page: 76 | params['page'] = str(page) 77 | 78 | data = self._fetch_list(url, params=params) 79 | return [ghcp.GitHubCommitData.from_dict(commit) for commit in data] 80 | -------------------------------------------------------------------------------- /src/backend/GitHubClientPayload.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | from dataclasses import dataclass 5 | from datetime import datetime, timezone 6 | 7 | 8 | def datetime_to_iso_utc_str(d:datetime): 9 | return d.astimezone(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat() + 'Z' 10 | 11 | 12 | def iso_utc_str_to_datetime(s:str): 13 | return datetime.strptime(s, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc) 14 | 15 | 16 | @dataclass 17 | class GitHubCommitData: 18 | """Data class to store GitHub commit information.""" 19 | sha: str 20 | author_name: str 21 | author_email: str 22 | committer_name: str 23 | committer_email: str 24 | message: str 25 | date: datetime 26 | url: str 27 | html_url: str 28 | 29 | @classmethod 30 | def from_dict(cls, rsp:dict): 31 | """Creates a GitHubCommitData instance from a GitHub API response dictionary.""" 32 | return cls( 33 | sha=str(rsp['sha']), 34 | author_name=str(rsp['commit']['author']['name']), 35 | author_email=str(rsp['commit']['author']['email']), 36 | committer_name=str(rsp['commit']['committer']['name']), 37 | committer_email=str(rsp['commit']['committer']['email']), 38 | message=str(rsp['commit']['message']), 39 | date=iso_utc_str_to_datetime(rsp['commit']['author']['date']), 40 | url=str(rsp['url']), 41 | html_url=str(rsp['html_url']) 42 | ) 43 | -------------------------------------------------------------------------------- /src/backend/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | -------------------------------------------------------------------------------- /src/dialogs/SelectVersionDialog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | import re 5 | import customtkinter as ctk 6 | from dataclasses import dataclass 7 | 8 | from ..backend import ArkClientPayload as acp 9 | from ..backend import GitHubClient as ghc 10 | from ..pages.ArkStudioAppInterface import App 11 | from ..utils import UIComponents as uic 12 | from ..utils.UIStyles import icon, style 13 | 14 | 15 | class SelectVersionDialog(ctk.CTkToplevel): 16 | def __init__(self, master:App): 17 | super().__init__(master) 18 | 19 | # Window 20 | self.title("Select") 21 | w = 640 22 | h = 640 23 | x = (self.winfo_screenwidth() - w) // 2 24 | y = (self.winfo_screenheight() - h) // 2 25 | self.geometry(f"{w}x{h}+{x}+{y}") 26 | 27 | # Modal 28 | self.transient(master) 29 | self.grab_set() 30 | self.lift() 31 | 32 | # Grid 33 | self.grid_rowconfigure((0, 1, 3, 4), weight=0) 34 | self.grid_rowconfigure(2, weight=1) 35 | self.grid_columnconfigure(0, weight=1) 36 | 37 | # Components 38 | self.title_label = ctk.CTkLabel(self, text="选择资源版本号", 39 | image=icon('goto'), **style('panel_title')) 40 | self.title_label.grid(row=0, column=0, pady=10, padx=20, sticky='nw') 41 | 42 | self.tip1_label = ctk.CTkLabel(self, text="从互联网获取到的版本记录:") 43 | self.tip1_label.grid(row=1, column=0, pady=10, padx=20, sticky='nw') 44 | 45 | self.treeview = uic.TreeviewFrame(self, 0, 0, columns=2, tree_mode=False, empty_tip="暂无数据") 46 | self.treeview.set_column(0, 200, "资源版本号") 47 | self.treeview.set_column(1, 150, "对应客户端版本") 48 | self.treeview.set_text_extractor(lambda v:v.res) 49 | self.treeview.set_icon_extractor(lambda _:'') 50 | self.treeview.set_value_extractor(lambda v:(v.client,)) 51 | self.treeview.set_insert_order(lambda l:list(reversed(sorted(l)))) 52 | self.treeview.set_on_item_selected(self._on_item_selected) 53 | self.treeview.clear() 54 | self.treeview.grid(row=2, column=0, pady=10, padx=20, sticky='nsew') 55 | 56 | self.entry = ctk.CTkEntry(self, placeholder_text="选择一个版本记录,或者在此手动输入资源版本号...") 57 | self.entry.grid(row=3, column=0, pady=10, padx=20, sticky='ew') 58 | 59 | self.button_group = ctk.CTkFrame(self) 60 | self.button_group.grid(row=4, column=0, pady=10, padx=20, sticky='ne') 61 | 62 | self.confirm_button = uic.OperationButton(self.button_group, 0, 0, "确认", 63 | image=icon('dialog_okay'), command=self._on_confirm) 64 | 65 | self.cancel_button = uic.OperationButton(self.button_group, 0, 1, "取消", 66 | image=icon('dialog_cancel'), command=self.destroy) 67 | 68 | # Data 69 | self.rst = SelectVersionResult(confirmed=False, version=None) 70 | self._agd_repo = "Kengxxiao/ArknightsGameData" 71 | self._agd_branch = "master" 72 | self._agd_server = "CN" 73 | self._agd_pp = 25 74 | self._agd_p = 0 75 | self._show_next_page_versions() 76 | 77 | def get_result(self): 78 | return self.rst 79 | 80 | def _on_item_selected(self, ver:acp.ArkVersion): 81 | self.entry.delete(0, ctk.END) 82 | self.entry.insert(0, ver.res) 83 | 84 | def _on_confirm(self): 85 | ver = acp.ArkVersion(res=self.entry.get().strip('\n'), client="") 86 | self.rst = SelectVersionResult(confirmed=True, version=ver) 87 | self.destroy() 88 | 89 | def _fetch_next_page_versions_from_agd(self): 90 | client = ghc.GitHubClient() 91 | self._agd_p += 1 92 | commits = client.get_commits( 93 | self._agd_repo, 94 | sha=self._agd_branch, 95 | per_page=self._agd_pp, 96 | page=self._agd_p 97 | ) 98 | rst:"list[acp.ArkVersion]" = [] 99 | for c in commits: 100 | m = re.match( 101 | r'\[([A-Z]{2}) UPDATE\] Client:([\d\.]+) Data:([a-zA-Z\d\-_]+)', 102 | c.message 103 | ) 104 | if m and m.group(1) == self._agd_server: 105 | ver = acp.ArkVersion(res=m.group(3), client=m.group(2)) 106 | rst.append(ver) 107 | return rst 108 | 109 | def _show_next_page_versions(self): 110 | self.treeview.insert(self._fetch_next_page_versions_from_agd()) 111 | 112 | 113 | @dataclass 114 | class SelectVersionResult: 115 | confirmed: bool 116 | version: acp.ArkVersion 117 | -------------------------------------------------------------------------------- /src/dialogs/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | -------------------------------------------------------------------------------- /src/pages/ABResolverPage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | import os 5 | import tkinter.filedialog as fd 6 | import customtkinter as ctk 7 | 8 | from src.backend import ArkClientPayload as acp 9 | from src.backend import ABHandler as abh 10 | from src.utils import UIComponents as uic 11 | from src.utils.UIStyles import file_icon, icon, style 12 | from src.utils.UIConcurrent import GUITaskBase, GUITaskCoordinator 13 | from .ArkStudioAppInterface import App 14 | 15 | 16 | class ABResolverPage(ctk.CTkFrame, uic.HidableGridWidget): 17 | def __init__(self, app:App, grid_row:int, grid_column:int): 18 | ctk.CTkFrame.__init__(self, app, corner_radius=0) 19 | uic.HidableGridWidget.__init__(self, grid_row, grid_column, init_visible=False, sticky='nsew') 20 | self.app = app 21 | self.grid_rowconfigure((0, 2), weight=0) 22 | self.grid_rowconfigure((1), weight=1) 23 | self.grid_columnconfigure((0, 1), weight=1, uniform='column') 24 | 25 | all_tasks = [_FileReloadTask, _FileExtractTask] 26 | for i in all_tasks: 27 | GUITaskCoordinator.register(i, all_tasks) 28 | 29 | self.abstract = _AbstractPanel(self) 30 | self.abstract.grid(row=0, column=0, columnspan=2, padx=10, pady=(10, 5), sticky='nsew') 31 | self.explorer = _ExplorerPanel(self) 32 | self.explorer.grid(row=1, column=0, rowspan=2, padx=(10, 5), pady=(5, 10), sticky='nsew') 33 | self.inspector = _InspectorPanel(self) 34 | self.inspector.grid(row=1, column=1, padx=(5, 10), pady=5, sticky='nsew') 35 | self.operation = _OperationPanel(self) 36 | self.operation.grid(row=2, column=1, padx=(5, 10), pady=(5, 10), sticky='nsew') 37 | self.cur_ab = None 38 | self.cur_path = None 39 | 40 | def invoke_load_tree(self, ab:abh.ABHandler): 41 | self.cur_ab = ab 42 | self.explorer.load_tree(self.cur_ab) 43 | 44 | def invoke_inspect(self, obj:abh.ObjectInfo): 45 | self.inspector.inspect(obj) 46 | self.operation.inspect(obj) 47 | 48 | 49 | class _FileReloadTask(GUITaskBase): 50 | def __init__(self, manager:ABResolverPage): 51 | super().__init__("正在读取对象列表...") 52 | self._manager = manager 53 | 54 | def _run(self): 55 | self.update(0.25, "正在读取对象列表") 56 | t = self._manager.after(500, lambda:self.update(0.5)) 57 | if self._manager.cur_path: 58 | ab = abh.ABHandler(self._manager.cur_path) 59 | self._manager.after_cancel(t) 60 | self.update(0.75, "正在加载浏览视图") 61 | self._manager.invoke_load_tree(ab) 62 | 63 | def _on_complete(self): 64 | self._manager.abstract.show_file_info() 65 | 66 | 67 | class _FileExtractTask(GUITaskBase): 68 | def __init__(self, manager:ABResolverPage): 69 | super().__init__("正在提取全部对象...") 70 | self._manager = manager 71 | 72 | def _run(self): 73 | # TODO WIP: Extract object 74 | pass 75 | 76 | 77 | class _AbstractPanel(ctk.CTkFrame): 78 | master:ABResolverPage 79 | 80 | def __init__(self, master:ABResolverPage): 81 | super().__init__(master) 82 | self.title = ctk.CTkLabel(self, text="资源文件概要", image=icon('abstract'), **style('panel_title')) 83 | self.title.grid(row=0, column=0, **style('panel_title_grid')) 84 | self.info_file_name = uic.InfoLabelGroup(self, 1, 0, "文件名称", tight=True) 85 | self.info_file_name.show("<未知>") 86 | self.info_file_path = uic.InfoLabelGroup(self, 2, 0, "文件路径", tight=True) 87 | self.info_file_path.show("<未知>") 88 | 89 | self.btn_open = uic.OperationButton( 90 | self, 91 | 1, 92 | 1, 93 | "打开", 94 | image=icon('file_open'), 95 | command=self.cmd_open, 96 | state_var=GUITaskCoordinator.get_unblocked_indicator(_FileReloadTask) 97 | ) 98 | self.btn_reload = uic.OperationButton( 99 | self, 100 | 2, 101 | 1, 102 | "刷新", 103 | image=icon('file_reload'), 104 | command=self.cmd_reload, 105 | state_var=GUITaskCoordinator.get_unblocked_indicator(_FileReloadTask), 106 | **style('operation_button_info') 107 | ) 108 | self.btn_extract = uic.OperationButton( 109 | self, 110 | 1, 111 | 2, 112 | "提取全部对象", 113 | image=icon('file_extract'), 114 | command=self.cmd_extract_all, 115 | state_var=GUITaskCoordinator.get_unblocked_indicator(_FileExtractTask) 116 | ) 117 | 118 | self.progress = uic.ProgressBarGroup(self, 0, 0, grid_columnspan=1, init_visible=False) 119 | self.grid_columnconfigure((0), weight=1) 120 | self.grid_columnconfigure((1, 2, 3, 4), weight=0) 121 | 122 | def show_file_info(self): 123 | ab = self.master.cur_ab 124 | if ab: 125 | self.info_file_name.show(os.path.basename(ab.filepath)) 126 | self.info_file_path.show(ab.filepath) 127 | 128 | def cmd_open(self): 129 | new_file = fd.askopenfilename(filetypes=[('Asset Bundle', '*.ab'), ('Any File', '*')]) 130 | if new_file and os.path.isfile(new_file): 131 | self.cmd_reload() 132 | self.master.cur_path = new_file 133 | 134 | def cmd_reload(self): 135 | task = _FileReloadTask(self.master) 136 | self.progress.bind_task_auto_hide(task) 137 | task.start() 138 | 139 | def cmd_extract_all(self): 140 | task = _FileExtractTask(self.master) 141 | self.progress.bind_task_auto_hide(task) 142 | task.start() 143 | 144 | 145 | class _ExplorerPanel(ctk.CTkFrame): 146 | master:ABResolverPage 147 | 148 | def __init__(self, master:ABResolverPage): 149 | super().__init__(master) 150 | self.title = ctk.CTkLabel(self, text="对象浏览器", image=icon('explorer'), **style('panel_title')) 151 | self.title.grid(row=0, column=0, **style('panel_title_grid')) 152 | self.children_map:"dict[acp.FileInfoBase,set[abh.ObjectInfo]]" = None 153 | self.treeview:"uic.TreeviewFrame[abh.ObjectInfo]" = uic.TreeviewFrame(self, 1, 0, columns=3, tree_mode=False, empty_tip="列表为空") 154 | self.treeview.set_column(0, 350, "对象名称") 155 | self.treeview.set_column(1, 100, "类型") 156 | self.treeview.set_column(2, 150, "PathID", anchor='ne') 157 | self.treeview.set_text_extractor(lambda x:x.name) 158 | self.treeview.set_icon_extractor(lambda x:file_icon(1)) 159 | self.treeview.set_value_extractor(lambda x:(x.type.name, x.pathid)) 160 | self.treeview.set_on_item_selected(self.master.invoke_inspect) 161 | self.treeview.set_insert_order(lambda x:sorted(x, key=lambda y:(y.type.name, y.name))) 162 | self.grid_rowconfigure((0), weight=0) 163 | self.grid_rowconfigure((1), weight=1) 164 | self.grid_columnconfigure((0), weight=1) 165 | 166 | def load_tree(self, ab:abh.ABHandler): 167 | # Clear the current items 168 | self.treeview.clear() 169 | # Load the new items 170 | self.treeview.insert(ab.objects) 171 | 172 | 173 | class _InspectorPanel(ctk.CTkTabview): 174 | master:ABResolverPage 175 | 176 | def __init__(self, master:ABResolverPage): 177 | super().__init__(master) 178 | self.tab_names = ("对象信息", "文本", "图像", "音频") 179 | # Tab 1 >>>>> Info 180 | self.tab1 = self.add(self.tab_names[0]) 181 | self.info_name = uic.InfoLabelGroup(self.tab1, 1, 0, "对象名称", tight=True) 182 | self.info_type = uic.InfoLabelGroup(self.tab1, 2, 0, "类型", tight=True) 183 | self.info_pathid = uic.InfoLabelGroup(self.tab1, 3, 0, "PathID", tight=True) 184 | # Tab 2 >>>>> Script 185 | self.tab2 = self.add(self.tab_names[1]) 186 | self.text_area = uic.TextPreviewer(self.tab2, 0, 0, "无文本或二进制资源") 187 | self.tab2.grid_columnconfigure((0), weight=1) 188 | # Tab 3 >>>>> Image 189 | self.tab3 = self.add(self.tab_names[2]) 190 | self.image_area = uic.ImagePreviewer(self.tab3, 0, 0, "无图像资源") 191 | self.tab3.grid_columnconfigure((0), weight=1) 192 | # Tab 4 >>>>> Audio 193 | self.tab4 = self.add(self.tab_names[3]) 194 | self.audio_area = uic.AudioPreviewer(self.tab4, 0, 0, "无可解码的音频") 195 | self.tab4.grid_columnconfigure((0), weight=1) 196 | 197 | def inspect(self, obj:abh.ObjectInfo): 198 | self.info_name.show(obj.name) 199 | self.info_type.show(obj.type.name) 200 | self.info_pathid.show(obj.pathid) 201 | script = obj.script 202 | image = obj.image 203 | audio = obj.audio 204 | if script: 205 | self.set(self.tab_names[1]) 206 | self.text_area.show(script) 207 | if image: 208 | self.set(self.tab_names[2]) 209 | self.image_area.show(obj.image) 210 | if audio: 211 | self.set(self.tab_names[3]) 212 | self.audio_area.show(obj.audio) 213 | if not any((script, image, audio)): 214 | self.set(self.tab_names[0]) 215 | 216 | 217 | class _OperationPanel(ctk.CTkFrame): 218 | master:ABResolverPage 219 | 220 | def __init__(self, master:ABResolverPage): 221 | super().__init__(master) 222 | self.title = ctk.CTkLabel(self, text="操作", image=icon('operation'), **style('panel_title')) 223 | self.title.grid(row=0, column=0, **style('panel_title_grid')) 224 | self.grid_columnconfigure((0), weight=1) 225 | 226 | self.btn_view = uic.OperationButton( 227 | self, 228 | 1, 229 | 0, 230 | "WIP:导出此对象", 231 | image=icon('file_extract'), 232 | state_var=GUITaskCoordinator.get_unblocked_indicator(_FileExtractTask) 233 | ) 234 | 235 | def inspect(self, obj:abh.ObjectInfo): 236 | self.btn_view.set_visible(obj.is_extractable()) 237 | -------------------------------------------------------------------------------- /src/pages/ArkStudioAppInterface.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | import customtkinter as ctk 5 | 6 | 7 | class App(ctk.CTkFrame): 8 | p_rm:ctk.CTkFrame 9 | p_ar:ctk.CTkFrame 10 | p_ic:ctk.CTkFrame 11 | p_fd:ctk.CTkFrame 12 | sidebar:ctk.CTkFrame 13 | -------------------------------------------------------------------------------- /src/pages/ResourceManagerPage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | import os 5 | import tkinter.filedialog as fd 6 | import customtkinter as ctk 7 | 8 | from src.backend import ArkClient as ac 9 | from src.backend import ArkClientPayload as acp 10 | from src.dialogs.SelectVersionDialog import SelectVersionDialog 11 | from src.utils import UIComponents as uic 12 | from src.utils.AnalyUtils import TestRT 13 | from src.utils.Config import Config 14 | from src.utils.OSUtils import FileSystem 15 | from src.utils.UIStyles import file_icon, icon, style 16 | from src.utils.UIConcurrent import GUITaskBase, GUITaskCoordinator 17 | from .ArkStudioAppInterface import App 18 | 19 | 20 | class ResourceManagerPage(ctk.CTkFrame, uic.HidableGridWidget): 21 | def __init__(self, app:App, grid_row:int, grid_column:int): 22 | ctk.CTkFrame.__init__(self, app, corner_radius=0) 23 | uic.HidableGridWidget.__init__(self, grid_row, grid_column, init_visible=False, sticky='nsew') 24 | self.app = app 25 | self.grid_rowconfigure((0, 2), weight=0) 26 | self.grid_rowconfigure((1), weight=1) 27 | self.grid_columnconfigure((0, 1), weight=1, uniform='column') 28 | 29 | all_tasks = [ 30 | _ResourceReloadTask, 31 | _ResourceSwitchLatestTask, 32 | _ResourceSwitchManualTask, 33 | _ResourceSyncAllFileTask, 34 | _ResourceSyncFileTask 35 | ] 36 | for i in all_tasks: 37 | GUITaskCoordinator.register(i, all_tasks) 38 | 39 | self.abstract = _AbstractPanel(self) 40 | self.abstract.grid(row=0, column=0, columnspan=2, padx=10, pady=(10, 5), sticky='nsew') 41 | self.explorer = _ExplorerPanel(self) 42 | self.explorer.grid(row=1, column=0, rowspan=2, padx=(10, 5), pady=(5, 10), sticky='nsew') 43 | self.inspector = _InspectorPanel(self) 44 | self.inspector.grid(row=1, column=1, padx=(5, 10), pady=5, sticky='nsew') 45 | self.operation = _OperationPanel(self) 46 | self.operation.grid(row=2, column=1, padx=(5, 10), pady=(5, 10), sticky='nsew') 47 | 48 | self.local_root = Config.get('local_repo_root') 49 | self.client = ac.ArkClient() 50 | self.repo = None 51 | if not self.local_root or not os.path.isdir(self.local_root): 52 | self.local_root = None 53 | else: 54 | self.abstract.cmd_reload() 55 | 56 | def invoke_inspect(self, info:acp.FileInfoBase): 57 | self.inspector.inspect(info) 58 | self.operation.inspect(info) 59 | self.explorer.treeview.refresh([info]) 60 | 61 | def invoke_inspect_alt(self, info:acp.FileInfoBase): 62 | self.inspector.inspect(info) 63 | self.operation.inspect(info) 64 | if self.operation.btn_view.is_visible(): 65 | self.operation.btn_view.invoke() 66 | 67 | def invoke_view_file(self, path:str): 68 | self.app.p_ar.cur_path = path 69 | self.app.p_ar.abstract.cmd_reload() 70 | self.app.sidebar.activate_menu_button(2) 71 | 72 | def invoke_load_tree(self, repo:acp.AssetRepoBase): 73 | self.repo = repo 74 | self.explorer.load_tree(self.repo) 75 | 76 | 77 | class _ResourceReloadTask(GUITaskBase): 78 | def __init__(self, manager:ResourceManagerPage): 79 | super().__init__("正在重载本地资源库...") 80 | self._manager = manager 81 | 82 | def _run(self): 83 | self.update(0.25, "正在读取本地仓库") 84 | t = self._manager.after(500, lambda:self.update(0.5)) 85 | self._manager.local_root = Config.get('local_repo_root') 86 | if self._manager.local_root: 87 | repo = acp.ArkLocalAssetsRepo(self._manager.local_root) 88 | self._manager.after_cancel(t) 89 | self.update(0.75, "正在加载浏览视图") 90 | self._manager.invoke_load_tree(repo) 91 | 92 | def _on_complete(self): 93 | self._manager.abstract.show_repo_res_version(self._manager.repo) 94 | 95 | 96 | class _ResourceSwitchLatestTask(GUITaskBase): 97 | def __init__(self, manager:ResourceManagerPage): 98 | super().__init__("正在切换到最新版本...") 99 | self._manager = manager 100 | 101 | def _run(self): 102 | if isinstance(self._manager.repo, (acp.ArkIntegratedAssetRepo, acp.ArkLocalAssetsRepo)): 103 | self.update(0.1, "正在获取网路配置") 104 | self._manager.client.set_current_network_config() 105 | self.update(0.3, "正在查询最新版本") 106 | self._manager.client.set_current_version() 107 | self.update(0.5, "正在获取资源列表") 108 | remote = self._manager.client.get_repo() 109 | self.update(0.8, "正在加载浏览视图") 110 | if isinstance(self._manager.repo, acp.ArkIntegratedAssetRepo): 111 | self._manager.repo = acp.ArkIntegratedAssetRepo(self._manager.repo.local, remote) 112 | else: 113 | self._manager.repo = acp.ArkIntegratedAssetRepo(self._manager.repo, remote) 114 | self.update(0.9) 115 | self._manager.explorer.load_tree(self._manager.repo) 116 | 117 | def _on_complete(self): 118 | self._manager.abstract.show_repo_res_version(self._manager.repo) 119 | 120 | 121 | class _ResourceSwitchManualTask(GUITaskBase): 122 | def __init__(self, manager:ResourceManagerPage, ver:acp.ArkVersion): 123 | super().__init__("正在切换到指定版本...") 124 | self._manager = manager 125 | self._ver = ver 126 | 127 | def _run(self): 128 | if isinstance(self._manager.repo, (acp.ArkIntegratedAssetRepo, acp.ArkLocalAssetsRepo)): 129 | self.update(0.1, "正在获取网路配置") 130 | self._manager.client.set_current_network_config() 131 | self._manager.client.set_current_version(self._ver) 132 | self.update(0.4, "正在获取资源列表") 133 | remote = self._manager.client.get_repo() 134 | self.update(0.8, "正在加载浏览视图") 135 | if isinstance(self._manager.repo, acp.ArkIntegratedAssetRepo): 136 | self._manager.repo = acp.ArkIntegratedAssetRepo(self._manager.repo.local, remote) 137 | else: 138 | self._manager.repo = acp.ArkIntegratedAssetRepo(self._manager.repo, remote) 139 | self.update(0.9) 140 | self._manager.explorer.load_tree(self._manager.repo) 141 | 142 | def _on_complete(self): 143 | self._manager.abstract.show_repo_res_version(self._manager.repo) 144 | 145 | 146 | class _ResourceSyncAllFileTask(GUITaskBase): 147 | def __init__(self, manager:ResourceManagerPage): 148 | super().__init__("正在同步文件...") 149 | self._manager = manager 150 | 151 | def _run(self): 152 | if isinstance(self._manager.repo, acp.ArkIntegratedAssetRepo): 153 | STEP1_WEIGHT = 0.2 154 | STEP2_WEIGHT = 0.8 155 | # Step1 156 | self.update(0.0, "正在初始化...") 157 | NEED_DELETE = (acp.FileStatus.DELETE,) 158 | NEED_DOWNLOAD = (acp.FileStatus.ADD, acp.FileStatus.MODIFY) 159 | NEED_SYNC = NEED_DELETE + NEED_DOWNLOAD 160 | infos = self._manager.repo.infos 161 | infos_len = len(infos) 162 | filtered_infos = [] 163 | for i, info in enumerate(infos): 164 | self.update(STEP1_WEIGHT * i / infos_len, f"正在计算变更 {i / infos_len:.1%}") 165 | if info.status in NEED_SYNC: 166 | filtered_infos.append(info) 167 | # Step2 168 | filtered_infos_len = len(filtered_infos) 169 | for i, info in enumerate(filtered_infos): 170 | self.update(STEP1_WEIGHT + STEP2_WEIGHT * i / filtered_infos_len, f"已完成 {i}/{filtered_infos_len}") 171 | if info.status in NEED_DELETE: 172 | print('D', info) 173 | FileSystem.rm(info.local.path) 174 | elif info.status in NEED_DOWNLOAD: 175 | print(info) 176 | d = self._manager.client.get_asset(info.remote.data_name, unzip=True) 177 | FileSystem.mkdir_for(info.local.path) 178 | with open(info.local.path, 'wb') as f: 179 | f.write(d) 180 | 181 | def _on_complete(self): 182 | self._manager.abstract.cmd_switch_latest() 183 | 184 | 185 | class _AbstractPanel(ctk.CTkFrame): 186 | master:ResourceManagerPage 187 | 188 | def __init__(self, master:ResourceManagerPage): 189 | super().__init__(master) 190 | self.title = ctk.CTkLabel(self, text="资源库概要", image=icon('abstract'), **style('panel_title')) 191 | self.title.grid(row=0, column=0, **style('panel_title_grid')) 192 | self.info_local_ver = uic.InfoLabelGroup(self, 1, 0, "当前资源版本号", tight=True) 193 | self.info_local_ver.show("<未知>") 194 | self.info_remote_ver = uic.InfoLabelGroup(self, 2, 0, "目标资源版本号", tight=True) 195 | self.info_remote_ver.show("<未知>") 196 | 197 | self.btn_open = uic.OperationButton( 198 | self, 199 | 1, 200 | 1, 201 | "浏览", 202 | image=icon('repo_open'), 203 | command=self.cmd_open, 204 | state_var=GUITaskCoordinator.get_unblocked_indicator(_ResourceReloadTask) 205 | ) 206 | self.btn_reload = uic.OperationButton( 207 | self, 208 | 2, 209 | 1, 210 | "重载", 211 | image=icon('repo_reload'), 212 | command=self.cmd_reload, 213 | state_var=GUITaskCoordinator.get_unblocked_indicator(_ResourceReloadTask), 214 | **style('operation_button_info') 215 | ) 216 | self.btn_switch_latest = uic.OperationButton( 217 | self, 218 | 1, 219 | 2, 220 | "切换最新版本", 221 | image=icon('switch_latest'), 222 | command=self.cmd_switch_latest, 223 | state_var=GUITaskCoordinator.get_unblocked_indicator(_ResourceSwitchLatestTask) 224 | ) 225 | self.btn_switch_manual = uic.OperationButton( 226 | self, 227 | 2, 228 | 2, 229 | "切换其他版本", 230 | image=icon('switch_manual'), 231 | command=self.cmd_switch_manual, 232 | state_var=GUITaskCoordinator.get_unblocked_indicator(_ResourceSwitchLatestTask), 233 | **style('operation_button_info') 234 | ) 235 | self.btn_sync = uic.OperationButton( 236 | self, 237 | 1, 238 | 3, 239 | "同步所有变更", 240 | image=icon('repo_sync'), 241 | state_var=GUITaskCoordinator.get_unblocked_indicator(_ResourceSyncAllFileTask), 242 | command=self.cmd_sync_all_file 243 | ) 244 | 245 | self.progress = uic.ProgressBarGroup(self, 0, 0, grid_columnspan=1, init_visible=False) 246 | self.grid_columnconfigure((0), weight=1) 247 | self.grid_columnconfigure((1, 2, 3, 4), weight=0) 248 | 249 | def show_repo_res_version(self, repo:acp.AssetRepoBase): 250 | self.info_local_ver.show("<未知>") 251 | self.info_remote_ver.show("<未知>") 252 | local_ver = "" 253 | if isinstance(repo, acp.ArkLocalAssetsRepo): 254 | local_ver = repo.detect_res_version() 255 | elif isinstance(repo, acp.ArkIntegratedAssetRepo): 256 | local_ver = repo.local.detect_res_version() 257 | self.info_remote_ver.show(repo.remote.version.res, "<未知>") 258 | self.info_local_ver.show(local_ver, "<未知>") 259 | 260 | def cmd_open(self): 261 | new_root = fd.askdirectory(mustexist=True) 262 | if new_root and os.path.isdir(new_root): 263 | self.cmd_reload() 264 | Config.set('local_repo_root', new_root) 265 | 266 | def cmd_reload(self): 267 | task = _ResourceReloadTask(self.master) 268 | self.progress.bind_task_auto_hide(task) 269 | task.start() 270 | 271 | def cmd_switch_latest(self): 272 | task = _ResourceSwitchLatestTask(self.master) 273 | self.progress.bind_task_auto_hide(task) 274 | task.start() 275 | 276 | def cmd_switch_manual(self): 277 | if self.master.repo is None: 278 | return 279 | dialog = SelectVersionDialog(self.master.master) 280 | dialog.wait_window() 281 | print(dialog.get_result()) 282 | task = _ResourceSwitchManualTask(self.master, dialog.get_result().version) 283 | self.progress.bind_task_auto_hide(task) 284 | task.start() 285 | 286 | def cmd_sync_all_file(self): 287 | task = _ResourceSyncAllFileTask(self.master) 288 | self.progress.bind_task_auto_hide(task) 289 | task.start() 290 | 291 | 292 | class _ExplorerPanel(ctk.CTkFrame): 293 | master:ResourceManagerPage 294 | 295 | def __init__(self, master:ResourceManagerPage): 296 | super().__init__(master) 297 | self.title = ctk.CTkLabel(self, text="资源浏览器", image=icon('explorer'), **style('panel_title')) 298 | self.title.grid(row=0, column=0, **style('panel_title_grid')) 299 | self.children_map:"dict[acp.FileInfoBase,set[acp.FileInfoBase]]" = None 300 | self.treeview:"uic.TreeviewFrame[acp.FileInfoBase]" = uic.TreeviewFrame(self, 1, 0, columns=3, empty_tip="列表为空") 301 | self.treeview.set_column(0, 300, "资源名称") 302 | self.treeview.set_column(1, 100, "状态") 303 | self.treeview.set_column(2, 100, "大小") 304 | self.treeview.set_text_extractor(lambda x:x.basename) 305 | self.treeview.set_icon_extractor(lambda x:file_icon(x.status)) 306 | self.treeview.set_value_extractor(lambda x:(acp.FileStatus.to_str(x.status), x.get_file_size_str())) 307 | self.treeview.set_parent_extractor(lambda x:x.parent if x.parent and x.parent.name else None) 308 | self.treeview.set_children_extractor(lambda x:self.children_map.get(x, None)) 309 | self.treeview.set_on_item_selected(self.master.invoke_inspect) 310 | self.treeview.set_on_item_double_click(self.master.invoke_inspect_alt) 311 | self.treeview.set_insert_order(lambda x:sorted(x, key=lambda y:(not isinstance(y, acp.DirFileInfo), y.name))) 312 | self.grid_rowconfigure((0), weight=0) 313 | self.grid_rowconfigure((1), weight=1) 314 | self.grid_columnconfigure((0), weight=1) 315 | 316 | def load_tree(self, repo:acp.AssetRepoBase): 317 | with TestRT('repo_load_tree'): 318 | # Clear the current items 319 | self.treeview.clear() 320 | # Load the new items 321 | if repo is not None: 322 | self.children_map = self.master.repo.get_children_map() 323 | self.treeview.insert(list(self.children_map[acp.DirFileInfo('')])) 324 | 325 | 326 | class _InspectorPanel(ctk.CTkFrame): 327 | master:ResourceManagerPage 328 | 329 | def __init__(self, master:ResourceManagerPage): 330 | super().__init__(master) 331 | self.title = ctk.CTkLabel(self, text="资源检视器", image=icon('inspector'), **style('panel_title')) 332 | self.title.grid(row=0, column=0, **style('panel_title_grid')) 333 | self.info_name = uic.InfoLabelGroup(self, 1, 0, "名称", tight=True) 334 | self.info_location = uic.InfoLabelGroup(self, 2, 0, "位置", tight=True) 335 | self.info_status = uic.InfoLabelGroup(self, 3, 0, "状态", tight=True) 336 | self.info_size_local = uic.InfoLabelGroup(self, 4, 0, "本地文件大小", tight=True) 337 | self.info_size_remote = uic.InfoLabelGroup(self, 5, 0, "远程文件大小", tight=True) 338 | self.info_digest_local = uic.InfoLabelGroup(self, 6, 0, "本地文件哈希", tight=True) 339 | self.info_digest_remote = uic.InfoLabelGroup(self, 7, 0, "远程文件哈希", tight=True) 340 | self.grid_columnconfigure((0), weight=1) 341 | 342 | def inspect(self, info:acp.FileInfoBase): 343 | self.info_name.show(info.basename, "<空>") 344 | self.info_location.show(info.parent.name, "<根>") 345 | if isinstance(info, acp.ArkIntegratedFileInfo): 346 | self.info_status.show(acp.FileStatus.to_str(info.status), "<未知>") 347 | self.info_size_local.show(f"{info.local.file_size} B", "0 B") 348 | self.info_size_remote.show(f"{info.remote.file_size if info.remote else 0} B", "0 B") 349 | self.info_digest_local.show(info.local.md5, "<未知>") 350 | self.info_digest_remote.show(info.remote.md5 if info.remote else "", "<未知>") 351 | elif isinstance(info, acp.ArkLocalFileInfo): 352 | self.info_status.show("尚未检查更新") 353 | self.info_size_local.show(f"{info.file_size} B", "0 B") 354 | self.info_size_remote.show(None) 355 | self.info_digest_local.show(info.md5, "<未知>") 356 | self.info_digest_remote.show(None) 357 | elif isinstance(info, acp.DirFileInfo): 358 | self.info_status.show("文件夹") 359 | self.info_size_local.show(None) 360 | self.info_size_remote.show(None) 361 | self.info_digest_local.show(None) 362 | self.info_digest_remote.show(None) 363 | 364 | 365 | class _OperationPanel(ctk.CTkFrame): 366 | master:ResourceManagerPage 367 | 368 | def __init__(self, master:ResourceManagerPage): 369 | super().__init__(master) 370 | self.title = ctk.CTkLabel(self, text="操作", image=icon('operation'), **style('panel_title')) 371 | self.title.grid(row=0, column=0, **style('panel_title_grid')) 372 | self.grid_columnconfigure((0), weight=1) 373 | 374 | self.btn_view = uic.OperationButton( 375 | self, 376 | 1, 377 | 0, 378 | "查看此文件", 379 | image=icon('file_view') 380 | ) 381 | self.btn_sync = uic.OperationButton( 382 | self, 383 | 2, 384 | 0, 385 | "同步此文件", 386 | image=icon('file_sync'), 387 | state_var=GUITaskCoordinator.get_unblocked_indicator(_ResourceSyncFileTask) 388 | ) 389 | self.btn_goto = uic.OperationButton( 390 | self, 391 | 3, 392 | 0, 393 | "在文件夹中显示", 394 | image=icon('file_goto'), 395 | **style('operation_button_info') 396 | ) 397 | 398 | def inspect(self, info:acp.FileInfoBase): 399 | if isinstance(info, (acp.ArkIntegratedFileInfo, acp.ArkLocalFileInfo, acp.DirFileInfo)): 400 | if not isinstance(info, acp.DirFileInfo) and \ 401 | info.status in (acp.FileStatus.ADD, acp.FileStatus.DELETE, acp.FileStatus.MODIFY): 402 | self.btn_sync.set_visible(True) 403 | self.btn_sync.set_command(lambda:self.cmd_sync(info)) 404 | else: 405 | self.btn_sync.set_visible(False) 406 | self.btn_sync.set_command(None) 407 | if not isinstance(info, acp.DirFileInfo) and os.path.exists(info.path): 408 | self.btn_view.set_visible(True) 409 | self.btn_view.set_command(lambda:self.master.invoke_view_file(info.path)) 410 | self.btn_goto.set_visible(True) 411 | self.btn_goto.set_command(lambda:FileSystem.see_file(info.path)) 412 | else: 413 | self.btn_view.set_visible(False) 414 | self.btn_view.set_command(None) 415 | self.btn_goto.set_visible(False) 416 | self.btn_goto.set_command(None) 417 | 418 | def cmd_sync(self, info:acp.FileInfoBase): 419 | if isinstance(info, acp.ArkIntegratedFileInfo): 420 | task = _ResourceSyncFileTask(self.master, info) 421 | self.master.abstract.progress.bind_task_auto_hide(task) 422 | task.start() 423 | 424 | 425 | class _ResourceSyncFileTask(GUITaskBase): 426 | def __init__(self, manager:ResourceManagerPage, info:acp.ArkIntegratedFileInfo): 427 | super().__init__("正在同步文件...") 428 | self._manager = manager 429 | self._info = info 430 | 431 | def _run(self): 432 | if isinstance(self._manager.repo, acp.ArkIntegratedAssetRepo) and \ 433 | isinstance(self._info, acp.ArkIntegratedFileInfo): 434 | if self._info.status == acp.FileStatus.DELETE: 435 | self.update(0.2, "正在删除...") 436 | FileSystem.rm(self._info.local.path) 437 | elif self._info.status in [acp.FileStatus.ADD, acp.FileStatus.MODIFY]: 438 | self.update(0.2, "正在下载...") 439 | d = self._manager.client.get_asset(self._info.remote.data_name, unzip=True) 440 | self.update(0.8, "正在写入...") 441 | FileSystem.mkdir_for(self._info.local.path) 442 | with open(self._info.local.path, 'wb') as f: 443 | f.write(d) 444 | self.update(0.9, "正在校验...") 445 | self._manager.invoke_inspect(self._info) 446 | -------------------------------------------------------------------------------- /src/pages/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | -------------------------------------------------------------------------------- /src/utils/AnalyUtils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | import time 5 | from contextlib import ContextDecorator 6 | from collections import defaultdict 7 | 8 | 9 | class DurationFormatter: 10 | @staticmethod 11 | def apply(sec:"int|float"): 12 | if not isinstance(sec, (int, float)): 13 | raise TypeError("Argument sec should be int or float") 14 | h = int(sec / 3600) 15 | m = int(sec % 3600 / 60) 16 | s = int(sec % 60) 17 | ms = round((sec - int(sec)) * 1000) if isinstance(sec, float) else None 18 | if h != 0: 19 | return f'{h}:{m:02}:{s:02}' + f'.{ms:03}' if isinstance(sec, float) else '' 20 | return f'{m:02}:{s:02}' + f'.{ms:03}' if isinstance(sec, float) else '' 21 | 22 | 23 | class TestRT(ContextDecorator): 24 | """Utility class for testing the running time of a code block. Usage is shown below. 25 | 26 | ``` 27 | with TestRT('scope'): 28 | pass # The codes to test 29 | print(TestRT.get_avg_time('scope')) 30 | ``` 31 | """ 32 | 33 | # 类级别的字典,用于存储每个name的运行时间 34 | _records = defaultdict(list) 35 | 36 | def __init__(self, name): 37 | self.name = name 38 | self.start_time = None 39 | 40 | def __enter__(self): 41 | self.start_time = time.time() 42 | return self 43 | 44 | def __exit__(self, exc_type, exc_val, exc_tb): 45 | span = time.time() - self.start_time 46 | TestRT._records[self.name].append(span) 47 | return False # Hand down the exception 48 | 49 | @staticmethod 50 | def get_avg_time(name): 51 | times = TestRT._records.get(name, None) 52 | return sum(times) / len(times) if times else None 53 | 54 | @staticmethod 55 | def get_avg_time_all(): 56 | return {k: sum(v) / len(v) for k, v in TestRT._records.items()} 57 | -------------------------------------------------------------------------------- /src/utils/AudioComposer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | import time 5 | import wave 6 | import simpleaudio 7 | from io import BytesIO 8 | 9 | 10 | class AudioComposer: 11 | """Audio composer.""" 12 | 13 | __instance:"AudioComposer" = None 14 | 15 | def __init__(self): 16 | """Not recommended to use. Please use the singleton instance.""" 17 | self._tracks:"dict[int,AudioTrack]" = {} 18 | 19 | @staticmethod 20 | def load(track:"AudioTrack", track_id:int=0): 21 | """Loads the given track to the specified track id. 22 | If the track id is existed, it will be overwritten without disposing.""" 23 | if not AudioComposer.__instance: 24 | AudioComposer.__instance = AudioComposer() 25 | return AudioComposer.__instance._load(track, track_id) 26 | 27 | @staticmethod 28 | def dispose(track_id:int): 29 | """Disposes the specified track id.""" 30 | if not AudioComposer.__instance: 31 | AudioComposer.__instance = AudioComposer() 32 | return AudioComposer.__instance._dispose(track_id) 33 | 34 | def _load(self, track:"AudioTrack", track_id:int=0): 35 | if track_id in self._tracks: 36 | self._tracks[track_id].stop() 37 | self._tracks[track_id] = track 38 | return track 39 | 40 | def _dispose(self, track_id:int): 41 | if track_id in self._tracks: 42 | self._tracks[track_id].dispose() 43 | del self._tracks[track_id] 44 | 45 | 46 | class AudioTrack: 47 | """Track session that controls the audio to be played.""" 48 | 49 | def __init__(self, audio_data:bytes): 50 | """Initializes the audio track session with the given audio data bytes.""" 51 | wave_read = wave.open(BytesIO(audio_data)) 52 | wave_obj = simpleaudio.WaveObject.from_wave_read(wave_read) 53 | self._wave_obj = wave_obj 54 | self._play_obj = None 55 | self._play_start_time = None 56 | 57 | def _sec_to_idx(self, sec:float): 58 | if sec is None: 59 | return None 60 | idx = sec * self.bytes_per_second 61 | idx = idx // self.bytes_per_sample // self.channels 62 | idx = idx * self.bytes_per_sample * self.channels 63 | return int(min(idx, self.size) if idx >= 0 else max(-self.size, idx)) 64 | 65 | def play(self, begin:float=None, end:float=None): 66 | """Starts playing. This will stops previous playing on this track first.""" 67 | if not self._wave_obj: 68 | raise RuntimeError("Nothings is playable") 69 | begin_idx = self._sec_to_idx(begin) 70 | end_idx = self._sec_to_idx(end) 71 | data = self._wave_obj.audio_data 72 | if begin_idx is not None: 73 | data = data[begin_idx:] 74 | if end_idx is not None: 75 | data = data[:end_idx] 76 | if len(data) > 0: 77 | self._play_start_time = time.time() - begin 78 | self._play_obj = simpleaudio.play_buffer(data, self.channels, self.bytes_per_sample, self.sample_rate) 79 | 80 | def stop(self): 81 | """Stops playing.""" 82 | if self._play_obj: 83 | self._play_obj.stop() 84 | self._play_obj = None 85 | self._play_start_time = None 86 | 87 | def dispose(self): 88 | """Releases resources.""" 89 | self.stop() 90 | self._wave_obj = None 91 | 92 | def is_playing(self): 93 | """Returns `True` if the audio is playing now.""" 94 | if self._play_obj: 95 | return self._play_obj.is_playing() 96 | return False 97 | 98 | def get_playing_duration(self): 99 | """Returns the current playing duration in seconds. Not accurate.""" 100 | if self._play_start_time: 101 | return time.time() - self._play_start_time 102 | return 0.0 103 | 104 | @property 105 | def bytes_per_sample(self): 106 | """Audio's bytes per sample.""" 107 | return self._wave_obj.bytes_per_sample 108 | 109 | @property 110 | def bytes_per_second(self): 111 | """Audio's bytes per second.""" 112 | return self.bytes_per_sample * self.channels * self.sample_rate 113 | 114 | @property 115 | def channels(self): 116 | """Audio's number of channels.""" 117 | return self._wave_obj.num_channels 118 | 119 | @property 120 | def duration(self, ndigits:int=3): 121 | """Audio's duration in seconds.""" 122 | return round(self.size / self.bytes_per_second, ndigits) 123 | 124 | @property 125 | def sample_rate(self): 126 | """Audio's sample rate.""" 127 | return self._wave_obj.sample_rate 128 | 129 | @property 130 | def size(self): 131 | """Audio's data size in bytes.""" 132 | return len(self._wave_obj.audio_data) 133 | -------------------------------------------------------------------------------- /src/utils/BiMap.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | from typing import TypeVar, Generic 5 | 6 | 7 | _KT = TypeVar('_KT') 8 | _VT = TypeVar('_VT') 9 | 10 | class BiMap(Generic[_KT,_VT]): 11 | """Bi-directional mapping class.""" 12 | 13 | def __init__(self, init_forward:"dict[_KT,_VT]"=None): 14 | self._forward:"dict[_KT,_VT]" = {} 15 | self._backward:"dict[_VT,_KT]" = {} 16 | if init_forward: 17 | for k, v in init_forward.items(): 18 | self[k] = v 19 | 20 | def __setitem__(self, key:_KT, value:_VT): 21 | if key in self._forward: 22 | del self._backward[self._forward[key]] 23 | if value in self._backward: 24 | raise ValueError(f"Duplicate values are not supported: {value}") 25 | self._forward[key] = value 26 | self._backward[value] = key 27 | 28 | def __delitem__(self, key:_KT): 29 | value = self._forward.pop(key) 30 | del self._backward[value] 31 | 32 | def keys(self): 33 | return self._forward.keys() 34 | 35 | def values(self): 36 | return self._backward.keys() 37 | 38 | def items_k2v(self): 39 | return self._forward.items() 40 | 41 | def items_v2k(self): 42 | return self._backward.items() 43 | 44 | def get_key(self, value:_VT, default:_VT=None) -> _KT: 45 | return self._backward.get(value, default) 46 | 47 | def get_value(self, key:_KT, default:_KT=None) -> _VT: 48 | return self._forward.get(key, default) 49 | 50 | def __contains__(self, key:_KT) -> bool: 51 | return key in self._forward 52 | 53 | def __repr__(self) -> str: 54 | return f"{self.__class__.__name__}({self._forward})" 55 | -------------------------------------------------------------------------------- /src/utils/Config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | import json 5 | import os 6 | import os.path as osp 7 | 8 | from .Logger import Logger 9 | 10 | 11 | class PerformanceLevel(): 12 | """Enumeration class for performance level.""" 13 | 14 | MINIMAL = 0 15 | LOW = 1 16 | STANDARD = 2 17 | HIGH = 3 18 | 19 | __CPU = max(1, os.cpu_count() if os.cpu_count() is not None else 1) 20 | __MAP = { 21 | MINIMAL: 1, 22 | LOW: max(2, __CPU // 2), 23 | STANDARD: max(4, __CPU), 24 | HIGH: max(8, __CPU * 2) 25 | } 26 | 27 | @staticmethod 28 | def get_thread_limit(performance_level:int): 29 | """Gets the maximum thread count according to the given performance level.""" 30 | return PerformanceLevel.__MAP.get(performance_level, PerformanceLevel.__MAP[PerformanceLevel.STANDARD]) 31 | 32 | 33 | class Config(): 34 | """Configuration class for ArkStudio.""" 35 | 36 | __instance = None 37 | __config_path = "ArkStudioConfig.json" 38 | __file_encoding = 'UTF-8' 39 | __default_config = { 40 | 'local_ignore': [ 41 | "^temp", 42 | ".exe$", 43 | ".log$", 44 | ".json$" 45 | ], 46 | 'local_repo_root': None, 47 | 'log_file': "ArkStudioLogs.log", 48 | 'log_level': Logger.LV_INFO, 49 | 'performance_level': PerformanceLevel.STANDARD 50 | } 51 | 52 | def __init__(self): 53 | """Not recommended to use. Please use the static methods.""" 54 | self.config = {} 55 | 56 | def _get(self, key): 57 | return self.config.get(key, None) 58 | 59 | def _set(self, key, value): 60 | self.config[key] = value 61 | self.save_config() 62 | 63 | def _read_config(self): 64 | if osp.isfile(Config.__config_path): 65 | try: 66 | loaded_config = json.load(open(Config.__config_path, 'r', encoding=Config.__file_encoding)) 67 | if isinstance(loaded_config, dict): 68 | for k in Config.__default_config.keys(): 69 | default_val = Config.__default_config[k] 70 | self.config[k] = loaded_config[k] if isinstance(loaded_config.get(k, None), type(default_val)) else default_val 71 | Logger.set_instance(self.get('log_file'), self.get('log_level')) 72 | Logger.set_level(self.get('log_level')) 73 | Logger.info("Config: Applied config.") 74 | except Exception as arg: 75 | self.config = Config.__default_config 76 | Logger.set_instance(self.get('log_file'), self.get('log_level')) 77 | Logger.set_level(self.get('log_level')) 78 | Logger.error(f"Config: Failed to parsing config, now using default config, cause: {arg}") 79 | else: 80 | self.config = Config.__default_config 81 | Logger.set_instance(self.get('log_file'), self.get('log_level')) 82 | Logger.set_level(self.get('log_level')) 83 | Logger.info("Config: Applied default config.") 84 | self.save_config() 85 | 86 | def _save_config(self): 87 | try: 88 | json.dump(self.config, open(self.__config_path, 'w', encoding=Config.__file_encoding), indent=4, ensure_ascii=False) 89 | Logger.info("Config: Saved config.") 90 | except Exception as arg: 91 | Logger.error(f"Config: Failed to save config, cause: {arg}") 92 | 93 | @staticmethod 94 | def _get_instance(): 95 | if not Config.__instance: 96 | Config.__instance = Config() 97 | Config.__instance._read_config() 98 | return Config.__instance 99 | 100 | @staticmethod 101 | def get(key): 102 | """Gets the specified config field. 103 | 104 | :param key: The JSON key to the field; 105 | :returns: The value of the field, `None` if the key doesn't exist; 106 | :rtype: Any; 107 | """ 108 | return Config._get_instance()._get(key) 109 | 110 | @staticmethod 111 | def set(key, value): 112 | """Sets the specified config field. 113 | 114 | :param key: The JSON key to the field; 115 | :param value: The new value of the field; 116 | :rtype: None; 117 | """ 118 | return Config._get_instance()._set(key, value) 119 | 120 | @staticmethod 121 | def read_config(): 122 | """Reads the config from file, aka. deserialize the config. 123 | The default config will be used if the config file doesn't exist or an error occurs. 124 | The logging level of `Logger` class will be updated according to the config. 125 | """ 126 | return Config._get_instance()._read_config() 127 | 128 | @staticmethod 129 | def save_config(): 130 | """Saves the config to file, aka. serialize the config.""" 131 | return Config._get_instance()._save_config() 132 | -------------------------------------------------------------------------------- /src/utils/Logger.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | import threading 5 | from datetime import datetime 6 | 7 | 8 | class Logger(): 9 | """Logger class for ArkStudio""" 10 | 11 | __time_format = '%Y-%m-%d %H:%M:%S' 12 | __file_encoding = 'UTF-8' 13 | __instance = None 14 | LV_NONE = 0 15 | LV_ERROR = 1 16 | LV_WARN = 2 17 | LV_INFO = 3 18 | LV_DEBUG = 4 19 | 20 | def __init__(self, log_file_path:str, level:int): 21 | """Not recommended to use. Please use the singleton instance.""" 22 | self.log_level = level 23 | self.log_file_path = log_file_path 24 | self.internal_lock = threading.Condition() 25 | self.file = None 26 | self.queue = [] 27 | def loop(self:Logger): 28 | while True: 29 | try: 30 | with self.internal_lock: 31 | while not self.queue: 32 | self.internal_lock.wait() 33 | t = self.queue.pop(0) 34 | if isinstance(self.log_file_path, str) and len(self.log_file_path) > 0: 35 | with open(self.log_file_path, 'a', encoding=Logger.__file_encoding) as f: 36 | f.write(t) 37 | except BaseException: 38 | pass 39 | self.thread = threading.Thread(name=self.__class__.__name__, target=loop, args=(self,), daemon=True) 40 | self.thread.start() 41 | 42 | def _set_level(self, level:int): 43 | self.log_level = level 44 | 45 | def _log(self, tag:str, msg:str): 46 | try: 47 | self.queue.append(f"{datetime.now().strftime(Logger.__time_format)} [{tag}] {msg}\n") 48 | self.internal_lock.notify_all() 49 | except BaseException: 50 | pass 51 | 52 | def _error(self, msg:str): 53 | if self.log_level >= Logger.LV_ERROR: 54 | self._log('ERROR', msg) 55 | 56 | def _warn(self, msg:str): 57 | if self.log_level >= Logger.LV_WARN: 58 | self._log('WARN', msg) 59 | 60 | def _info(self, msg:str): 61 | if self.log_level >= Logger.LV_INFO: 62 | self._log('INFO', msg) 63 | 64 | def _debug(self, msg:str): 65 | if self.log_level >= Logger.LV_DEBUG: 66 | self._log('DEBUG', msg) 67 | 68 | @staticmethod 69 | def set_instance(log_file_path:str, level:int=LV_INFO): 70 | """Initializes the Logger static instance. 71 | If the instance has been initialized yet, this method does nothing. 72 | 73 | :param log_file_path: The path to the log file; 74 | :param level: The logging level; 75 | :rtype: None; 76 | """ 77 | if not Logger.__instance: 78 | Logger.set_instance_override(log_file_path, level) 79 | 80 | @staticmethod 81 | def set_instance_override(log_file_path:str, level:int=LV_INFO): 82 | """Initializes the Logger static instance forcibly. 83 | If the instance has been initialized yet, this method will override it. 84 | 85 | :param log_file_path: The path to the log file; 86 | :param level: The logging level; 87 | :rtype: None; 88 | """ 89 | Logger.__instance = Logger(log_file_path, level) 90 | 91 | @staticmethod 92 | def set_level(level:int): 93 | """Sets the logging level 94 | 95 | :param level: The new logging level; 96 | :rtype: None; 97 | """ 98 | if Logger.__instance: 99 | Logger.__instance._set_level(level) 100 | 101 | @staticmethod 102 | def log(tag:str, msg:str): 103 | if Logger.__instance: 104 | Logger.__instance._log(tag, msg) 105 | 106 | @staticmethod 107 | def error(msg:str): 108 | if Logger.__instance: 109 | Logger.__instance._error(msg) 110 | 111 | @staticmethod 112 | def warn(msg:str): 113 | if Logger.__instance: 114 | Logger.__instance._warn(msg) 115 | 116 | @staticmethod 117 | def info(msg:str): 118 | if Logger.__instance: 119 | Logger.__instance._info(msg) 120 | 121 | @staticmethod 122 | def debug(msg:str): 123 | if Logger.__instance: 124 | Logger.__instance._debug(msg) 125 | -------------------------------------------------------------------------------- /src/utils/OSUtils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | import os 5 | import platform 6 | import shutil 7 | import subprocess 8 | 9 | 10 | class FileSystem: 11 | @staticmethod 12 | def rm(path:str): 13 | """**DANGEROUS**: Deletes a file or a directory.""" 14 | if os.path.isfile(path): 15 | os.unlink(path) 16 | elif os.path.isdir(path): 17 | if path.strip().rstrip() in ['', '/', '\\']: 18 | raise OSError("Dangerous action") 19 | shutil.rmtree(path, ignore_errors=True) 20 | 21 | @staticmethod 22 | def mkdir(dirpath:str): 23 | """Makes a directory.""" 24 | os.makedirs(dirpath, exist_ok=True) 25 | 26 | @staticmethod 27 | def mkdir_for(filepath:str): 28 | """Makes the parent directory of the given file.""" 29 | os.makedirs(os.path.dirname(filepath), exist_ok=True) 30 | 31 | @staticmethod 32 | def see_file(path:str): 33 | """Uses the platform explorer to see the file.""" 34 | if not os.path.exists(path): 35 | raise FileNotFoundError(f"The specified path does not exist: {path}") 36 | 37 | os_name = platform.system() 38 | 39 | if os_name == 'Windows': 40 | subprocess.run(f'explorer /select,"{os.path.abspath(path)}"', check=False) 41 | elif os_name == "Darwin": 42 | subprocess.run(['open', '-R', path], check=False) 43 | elif os_name == "Linux": 44 | try: 45 | subprocess.run(['nautilus', '--select', path], check=False) 46 | except FileNotFoundError: 47 | try: 48 | subprocess.run(['xdg-open', path], check=False) 49 | except FileNotFoundError: 50 | raise OSError("No supported file manager found on this Linux system.") 51 | else: 52 | raise OSError(f"Unsupported operating system: {os_name}") 53 | -------------------------------------------------------------------------------- /src/utils/UIComponents.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | import tkinter as tk 5 | import tkinter.ttk as ttk 6 | import customtkinter as ctk 7 | from PIL import Image, ImageTk 8 | from typing import Callable, Any, TypeVar, Generic 9 | 10 | from .UIStyles import style, icon 11 | from .UIConcurrent import GUITaskBase 12 | from ..utils.AnalyUtils import TestRT, DurationFormatter 13 | from ..utils.BiMap import BiMap 14 | from .AudioComposer import AudioComposer, AudioTrack 15 | 16 | 17 | ################ 18 | # Base Classes # 19 | ################ 20 | 21 | class HidableGridWidget(tk.Grid): 22 | """Hidable widget base class for gird layout.""" 23 | 24 | def __init__(self, 25 | grid_row:int, 26 | grid_column:int, 27 | grid_rowspan:int=1, 28 | grid_columnspan:int=1, 29 | init_visible:bool=True, 30 | **grid_addition:Any): 31 | self.__grid_kwargs = grid_addition 32 | self.__grid_kwargs.update({'row': grid_row, 33 | 'column': grid_column, 34 | 'rowspan': grid_rowspan, 35 | 'columnspan': grid_columnspan}) 36 | self.set_visible(init_visible) 37 | 38 | def set_visible(self, value:bool): 39 | """Sets the visibility of the widget.""" 40 | if value: 41 | self.grid(**self.__grid_kwargs) 42 | else: 43 | self.grid_remove() 44 | self.__visible = value 45 | 46 | def is_visible(self): 47 | """Gets the visibility of the widget.""" 48 | return self.__visible 49 | 50 | ########################## 51 | # Frequent Used Controls # 52 | ########################## 53 | 54 | class OperationButton(ctk.CTkButton, HidableGridWidget): 55 | """Operation button widget.""" 56 | 57 | def __init__(self, 58 | master:ctk.CTkFrame, 59 | grid_row:int, 60 | grid_column:int, 61 | text:str, 62 | image:ctk.CTkImage=None, 63 | command:"Callable[[],Any]"=None, 64 | state_var:tk.BooleanVar=None, 65 | **kwargs): 66 | ctk.CTkButton.__init__(self, master, text=text, image=image, command=command, 67 | **style('operation_button'), **kwargs) 68 | HidableGridWidget.__init__(self, grid_row, grid_column, init_visible=True, **style('operation_button_grid')) 69 | if state_var is not None: 70 | self.bind_state(state_var) 71 | 72 | def set_command(self, command:"Callable[[], Any]"): 73 | """Sets the active command of the button, `None` for disable the command.""" 74 | self.configure(command=command) 75 | 76 | def bind_state(self, var:tk.BooleanVar): 77 | """Binds the state property of the button to the given boolean variable.""" 78 | var.trace_add('write', lambda *args: self.configure(state=tk.NORMAL if var.get() else tk.DISABLED)) 79 | 80 | 81 | class InfoLabelGroup(ctk.CTkFrame, HidableGridWidget): 82 | """Information label group widget.""" 83 | 84 | def __init__(self, 85 | master:ctk.CTkFrame, 86 | grid_row:int, 87 | grid_column:int, 88 | head_text:str, 89 | body_text:str="", 90 | tight:bool=False, 91 | init_visible:bool=True): 92 | ctk.CTkFrame.__init__(self, master, fg_color='transparent') 93 | HidableGridWidget.__init__(self, grid_row, grid_column, init_visible=init_visible, 94 | **style('info_label_grid')) 95 | self._head = ctk.CTkLabel(self, text=head_text, **style('info_label_head')) 96 | self._head.grid(row=0, column=0, **style('info_label_head_grid')) 97 | self._body = ctk.CTkLabel(self, text=body_text, **style('info_label_body')) 98 | self._body.grid(row=0 if tight else 1, column=1 if tight else 0, **style('info_label_body_grid')) 99 | self._set_body_text(body_text) 100 | 101 | def show(self, value:str="", placeholder:str=""): 102 | """Shows a new text. If value is empty, placeholder will be shown. If value is `None`, label will hide.""" 103 | if value is None: 104 | self.set_visible(False) 105 | self._set_body_text() 106 | else: 107 | self.set_visible(True) 108 | self._set_body_text(value, placeholder) 109 | 110 | def _set_body_text(self, value:str="", placeholder:str=""): 111 | if value: 112 | self._body.configure(text=value) 113 | else: 114 | self._body.configure(text=placeholder) 115 | self._text = value 116 | 117 | 118 | class ProgressBarGroup(ctk.CTkFrame, HidableGridWidget): 119 | """Progress bar group widget.""" 120 | 121 | def __init__(self, 122 | master:ctk.CTkFrame, 123 | grid_row:int, 124 | grid_column:int, 125 | grid_rowspan:int=1, 126 | grid_columnspan:int=1, 127 | head_text:str="", 128 | body_text:str="", 129 | init_visible:bool=True): 130 | ctk.CTkFrame.__init__(self, master) 131 | HidableGridWidget.__init__(self, grid_row, grid_column, grid_rowspan, grid_columnspan, init_visible, 132 | **style('progress_bar_grid')) 133 | self._head = ctk.CTkLabel(self, text=head_text, image=icon('progress'), compound='left', 134 | **style('progress_bar_head')) 135 | self._head.grid(row=0, column=0, **style('progress_bar_head_grid')) 136 | self._head.configure(text=head_text) 137 | self._body = ctk.CTkLabel(self, text=body_text, **style('progress_bar_body')) 138 | self._body.grid(row=0, column=2, **style('progress_bar_body_grid')) 139 | self._body.configure(text=body_text) 140 | self._prog = ctk.CTkProgressBar(self) 141 | self._prog.grid(row=0, column=1) 142 | 143 | def set_head_text(self, value:str): 144 | """Sets a new head text.""" 145 | self._head.configure(text=value) 146 | 147 | def bind_task(self, task:GUITaskBase): 148 | """Binds the progress bar to a task.""" 149 | self.set_head_text(task.title) 150 | self._prog.configure(variable=task.observable_progress) 151 | self._body.configure(textvariable=task.observable_message) 152 | 153 | def bind_task_auto_hide(self, task:GUITaskBase): 154 | """Binds the progress to a task. Hides the progress bar when `progress>=1.0`.""" 155 | self.bind_task(task) 156 | self.set_visible(True) 157 | def auto_hide(*args): 158 | nonlocal t 159 | if task.observable_progress.get() >= 1.0: 160 | self.set_visible(False) 161 | task.observable_progress.trace_remove('write', t) 162 | t = task.observable_progress.trace_add('write', auto_hide) 163 | 164 | ############################### 165 | # Specialized Preview Widgets # 166 | ############################### 167 | 168 | _ITEM_TYPE = TypeVar('_ITEM_TYPE') 169 | 170 | class TreeviewFrame(ctk.CTkFrame, HidableGridWidget, Generic[_ITEM_TYPE]): 171 | """Treeview frame widget.""" 172 | 173 | def __init__(self, master:ctk.CTkFrame, grid_row:int, grid_column:int, columns:int=1, tree_mode:bool=True, empty_tip:str=""): 174 | ctk.CTkFrame.__init__(self, master, fg_color='transparent') 175 | HidableGridWidget.__init__(self, grid_row, grid_column, init_visible=True, sticky='nsew') 176 | self._inited = False 177 | # Init settings 178 | self._tree_mode = tree_mode 179 | self._columns = tuple(range(1, columns)) if columns > 1 else () 180 | self._columns_settings = {} 181 | self.grid_rowconfigure((0), weight=1) 182 | self.grid_columnconfigure((0), weight=1) 183 | # Post settings 184 | self._text_of:"Callable[[_ITEM_TYPE],str]" = lambda _:'' 185 | self._icon_of:"Callable[[_ITEM_TYPE],ImageTk.PhotoImage]" = lambda _:None 186 | self._value_of:"Callable[[_ITEM_TYPE],tuple]" = lambda _:() 187 | self._parent_of:"Callable[[_ITEM_TYPE],_ITEM_TYPE]" = lambda _:None 188 | self._children_of:"Callable[[_ITEM_TYPE],list[_ITEM_TYPE]]" = lambda _:None 189 | self._on_item_selected:"Callable[[_ITEM_TYPE],None]" = lambda _:None 190 | self._on_item_double_click:"Callable[[_ITEM_TYPE],None]" = lambda _:None 191 | self._insert_sorter:"Callable[[list[_ITEM_TYPE]],list[_ITEM_TYPE]]" = lambda x:x 192 | # Runtime variables 193 | self.treeview:ttk.Treeview = None 194 | self.iid2item:"BiMap[int,_ITEM_TYPE]" = None 195 | self._scroll_bar = None 196 | self._sort_reverse = False 197 | # Show empty tip 198 | self._empty_tip_label = ctk.CTkLabel(self, text=empty_tip, **style('treeview_empty_tip')) 199 | self._empty_tip_label.grid(row=0, column=0) 200 | 201 | def set_column(self, column_index:int, pref_width:int, head_text:str, anchor:str='nw'): 202 | """Sets the detailed config of the specified column. Index starts from `0`.""" 203 | column = f'#{column_index}' 204 | self._columns_settings[column_index] = lambda: ( 205 | self.treeview.heading(column, 206 | text=head_text, 207 | anchor='nw', 208 | command=lambda:self._sort_by_column(column)), 209 | self.treeview.column(column, 210 | width=pref_width, 211 | minwidth=pref_width // 2, anchor=anchor) 212 | ) 213 | 214 | def set_text_extractor(self, consumer:"Callable[[_ITEM_TYPE],str]"): 215 | """Sets the item text string extractor.""" 216 | self._text_of = consumer 217 | 218 | def set_icon_extractor(self, consumer:"Callable[[_ITEM_TYPE],ImageTk.PhotoImage]"): 219 | """Sets the item icon image extractor.""" 220 | self._icon_of = consumer 221 | 222 | def set_value_extractor(self, consumer:"Callable[[_ITEM_TYPE],tuple]"): 223 | """Sets the item value extractor.""" 224 | self._value_of = consumer 225 | 226 | def set_parent_extractor(self, consumer:"Callable[[_ITEM_TYPE],_ITEM_TYPE]"): 227 | """Sets the item parent extractor. Valid only in tree mode.""" 228 | if not self._tree_mode: 229 | raise RuntimeError("Only supported in tree mode") 230 | self._parent_of = consumer 231 | 232 | def set_children_extractor(self, consumer:"Callable[[_ITEM_TYPE],list[_ITEM_TYPE]]"): 233 | """Sets the item children extractor. Valid only in tree mode.""" 234 | if not self._tree_mode: 235 | raise RuntimeError("Only supported in tree mode") 236 | self._children_of = consumer 237 | 238 | def set_on_item_selected(self, consumer:"Callable[[_ITEM_TYPE],None]"): 239 | """Sets a callback that will be called when an item is selected.""" 240 | self._on_item_selected = consumer 241 | 242 | def set_on_item_double_click(self, consumer:"Callable[[_ITEM_TYPE],None]"): 243 | """Sets a callback that will be called when an item has been double clicked.""" 244 | self._on_item_double_click = consumer 245 | 246 | def set_insert_order(self, sorter:"Callable[[list[_ITEM_TYPE]],list[_ITEM_TYPE]]"): 247 | """Sets a sorter that sorts the item list to be inserted.""" 248 | self._insert_sorter = sorter 249 | 250 | def clear(self): 251 | """Clears the whole treeview and resets all data. Must be invoked before any `insert` action.""" 252 | # Treeview reset 253 | if self.treeview: 254 | self.treeview.destroy() 255 | self.treeview = ttk.Treeview(self, columns=self._columns, height=20) 256 | for i in self._columns_settings.values(): 257 | i() 258 | self.treeview.grid(row=0, column=0, padx=(5, 0), pady=(0, 5), sticky='nsew') 259 | self.treeview.tag_bind('general_tag', '<>', self._item_opened) 260 | self.treeview.tag_bind('general_tag', '<>', self._item_selected) 261 | self.treeview.bind('', self._item_double_click) 262 | # Scroll bar reset 263 | self._scroll_bar = ctk.CTkScrollbar(self, orientation='vertical', command=self.treeview.yview) 264 | self._scroll_bar.grid(row=0, column=1, sticky='ns') 265 | self.treeview.configure(yscrollcommand=self._scroll_bar.set) 266 | # IID map reset 267 | self.iid2item = BiMap() 268 | self._inited = True 269 | 270 | def insert(self, items:"list[_ITEM_TYPE]"): 271 | """Inserts an item list.""" 272 | for i in self._insert_sorter(items): 273 | self._insert_one(i) 274 | 275 | def refresh(self, items:"list[_ITEM_TYPE]"): 276 | """Updates the given items by refreshing their text, image and column values.""" 277 | for i in items: 278 | self.treeview.item( 279 | self.iid2item.get_key(i), 280 | text=self._text_of(i), 281 | image=self._icon_of(i), 282 | values=self._value_of(i) 283 | ) 284 | 285 | def _insert_one(self, item:_ITEM_TYPE): 286 | if not self._inited: 287 | raise RuntimeError("Treeview not initialized") 288 | if item not in self.iid2item.values(): 289 | # Insert parent items first 290 | parent = self._parent_of(item) 291 | if parent and parent not in self.iid2item.values(): 292 | self.insert(parent) 293 | # Insert this item 294 | iid = self.treeview.insert( 295 | self.iid2item.get_key(parent, '') if parent else '', 296 | tk.END, 297 | text=self._text_of(item), 298 | image=self._icon_of(item), 299 | values=self._value_of(item), 300 | tags=('general_tag') 301 | ) 302 | self.iid2item[iid] = item 303 | # Insert a pre-contained item into this item if it it has children 304 | if self._children_of(item): 305 | self.treeview.insert(iid, tk.END, text="") 306 | 307 | def _delete_one(self, *iid:int): 308 | if not self._inited: 309 | raise RuntimeError("Treeview not initialized") 310 | for i in iid: 311 | # Delete the descendants items first 312 | self._delete_one(*self.treeview.get_children(i)) 313 | # Delete this item 314 | self.treeview.delete(i) 315 | if i in self.iid2item.keys(): 316 | del self.iid2item[i] 317 | 318 | def _sort_by_column(self, column:str): 319 | if not self._inited: 320 | raise RuntimeError("Treeview not initialized") 321 | if self._tree_mode: 322 | return # Sorting is not supported in tree mode 323 | # Sort the items 324 | if column == '#0': 325 | # For the display column, just sort by the raw insert order 326 | li = [self.iid2item.get_value(iid) for iid in self.treeview.get_children('')] 327 | li = self._insert_sorter(li) 328 | for i, item in enumerate(reversed(li) if self._sort_reverse else li): 329 | self.treeview.move(self.iid2item.get_key(item), '', i) 330 | else: 331 | # For value columns, sort by the cell value 332 | li = [(self.treeview.set(iid, column), iid) for iid in self.treeview.get_children('')] 333 | li.sort(reverse=self._sort_reverse) 334 | for i, (_, iid) in enumerate(li): 335 | self.treeview.move(iid, '', i) 336 | self._sort_reverse = not self._sort_reverse 337 | 338 | def _item_opened(self, _:tk.Event): 339 | self.treeview.configure(cursor='watch') 340 | iid = self.treeview.selection()[0] 341 | item = self.iid2item.get_value(iid) 342 | # Clear the current descendants of this item 343 | self._delete_one(*self.treeview.get_children(iid)) 344 | # Insert the children of this item 345 | children = self._children_of(item) 346 | if children: 347 | self.insert(children) 348 | self.treeview.configure(cursor='arrow') 349 | 350 | def _item_selected(self, _:tk.Event): 351 | self.treeview.configure(cursor='watch') 352 | iid = self.treeview.selection()[0] 353 | self._on_item_selected(self.iid2item.get_value(iid)) 354 | self.treeview.configure(cursor='arrow') 355 | 356 | def _item_double_click(self, event:tk.Event): 357 | self.treeview.configure(cursor='watch') 358 | iid = self.treeview.identify('item', event.x, event.y) 359 | self._on_item_double_click(self.iid2item.get_value(iid)) 360 | self.treeview.configure(cursor='arrow') 361 | 362 | 363 | class TextPreviewer(ctk.CTkFrame, HidableGridWidget): 364 | _START = 0.0 365 | _END = 'end' 366 | 367 | def __init__(self, master:ctk.CTkFrame, grid_row:int, grid_column:int, empty_tip:str=""): 368 | ctk.CTkFrame.__init__(self, master, fg_color='transparent') 369 | HidableGridWidget.__init__(self, grid_row, grid_column, init_visible=True, sticky='nsew') 370 | self.display = ctk.CTkTextbox(self, state='disabled', wrap='none') 371 | self.display.grid(row=0, column=0, sticky='nsew') 372 | self.grid_rowconfigure((0), weight=1) 373 | self.grid_columnconfigure((0), weight=1) 374 | self._empty_tip = empty_tip 375 | self.show(None) 376 | 377 | def show(self, value:"bytes|None"): 378 | self.display.configure(state='normal') 379 | self.display.delete(TextPreviewer._START, TextPreviewer._END) 380 | if value: 381 | with TestRT('preview_text'): 382 | decoded = value.decode(errors='replace') if len(value) <= 10 << 20 else "该内容的数据量较大,已关闭预览" 383 | self.display.insert(TextPreviewer._START, decoded) 384 | else: 385 | self.display.insert(TextPreviewer._START, self._empty_tip) 386 | self.display.configure(state='disabled') 387 | 388 | 389 | class ImagePreviewer(ctk.CTkFrame, HidableGridWidget): 390 | _FILL = 0.66667 391 | _LIMIT_SIZE = 1024 392 | 393 | def __init__(self, master:ctk.CTkFrame, grid_row:int, grid_column:int, empty_tip:str=""): 394 | ctk.CTkFrame.__init__(self, master, fg_color='transparent') 395 | HidableGridWidget.__init__(self, grid_row, grid_column, init_visible=True, sticky='nsew') 396 | self.display = ctk.CTkLabel(self, compound='bottom') 397 | self.display.grid(row=0, column=0, pady=5, sticky='ew') 398 | self.info = ctk.CTkLabel(self, anchor='center') 399 | self.info.grid(row=1, column=0, sticky='ew') 400 | self.grid_rowconfigure((0), weight=1) 401 | self.grid_rowconfigure((1), weight=0) 402 | self.grid_columnconfigure((0), weight=1) 403 | master.bind('', self._on_resize) 404 | self._empty_tip = empty_tip 405 | self._empty_tk_image = ctk.CTkImage(Image.new('RGBA', (1, 1))) 406 | self._tk_image = None 407 | self._size_request = (self.display.winfo_width(), self.display.winfo_height()) 408 | self._size_current = (-1, -1) 409 | self._aspect_ratio = 1 410 | self.show(None) 411 | 412 | def show(self, value:"Image.Image|None"): 413 | if value: 414 | with TestRT('preview_image'): 415 | self.info.configure(text=f"{value.width} * {value.height}") 416 | # Limit raw image size 417 | if (scale := max(map(lambda x:ImagePreviewer._LIMIT_SIZE / x, value.size))) < 1: 418 | value = value.resize(tuple(map(lambda x:int(x * scale), value.size)), resample=Image.BILINEAR) 419 | # Replace displaying image 420 | self._tk_image = ctk.CTkImage(value, size=value.size) 421 | self._size_current = value.size 422 | self._aspect_ratio = value.width / value.height 423 | self.display.configure(text="", image=self._tk_image) 424 | self._fit() 425 | else: 426 | self.info.configure(text="") 427 | self._tk_image = self._empty_tk_image 428 | self._size_current = (1, 1) 429 | self._aspect_ratio = 1.0 430 | self.display.configure(text=self._empty_tip, image=self._tk_image) 431 | self._fit() 432 | 433 | def _fit(self): 434 | if self._tk_image and self._size_request != self._size_current: 435 | w, h = self._size_request 436 | ratio = w / h 437 | if ratio > self._aspect_ratio: 438 | w = h * self._aspect_ratio 439 | else: 440 | h = w / self._aspect_ratio 441 | size_real = tuple(map(lambda x:max(1, int(x * ImagePreviewer._FILL)), (w, h))) 442 | self._tk_image.configure(size=size_real) 443 | self._size_current = self._size_request 444 | 445 | def _on_resize(self, event:tk.Event): 446 | self._size_request = (event.width, event.height) 447 | self._fit() 448 | 449 | 450 | class AudioController(ctk.CTkFrame, HidableGridWidget): 451 | def __init__(self, master:"AudioPreviewer", grid_row:int, grid_column:int, audio_name:str, audio_data:bytes): 452 | ctk.CTkFrame.__init__(self, master, fg_color='transparent') 453 | HidableGridWidget.__init__(self, grid_row, grid_column, init_visible=True, sticky='ew') 454 | self.track = AudioTrack(audio_data) 455 | self.name = ctk.CTkLabel(self, **style('audio_ctrl_name')) 456 | self.name.grid(row=0, column=0, columnspan=3) 457 | self.name.configure(text=audio_name) 458 | self.info = ctk.CTkLabel(self, **style('audio_ctrl_info')) 459 | self.info.grid(row=1, column=0, columnspan=3) 460 | self.info.configure(text=f"{self.track.duration} s | " + 461 | f"{self.track.channels} Chs | " + 462 | f"{self.track.bytes_per_sample * 8} bits | " + 463 | f"{self.track.sample_rate} Hz") 464 | self.var_duration = tk.DoubleVar(self, value=0.0) 465 | self.time_cur = ctk.CTkLabel(self, text=DurationFormatter.apply(0.0), **style('audio_ctrl_info')) 466 | self.time_cur.grid(row=2, column=0, sticky='w', **style('audio_ctrl_operation_grid')) 467 | self.time_end = ctk.CTkLabel(self, text=DurationFormatter.apply(self.track.duration), **style('audio_ctrl_info')) 468 | self.time_end.grid(row=2, column=2, sticky='e', **style('audio_ctrl_operation_grid')) 469 | self.slider = ctk.CTkSlider(self, from_=0.0, to=self.track.duration, command=self._slider_action, variable=self.var_duration) 470 | self.slider.grid(row=3, column=0, columnspan=3, sticky='ew', **style('audio_ctrl_operation_grid')) 471 | self.btn_play = ctk.CTkButton(self, text='播放', image=icon('audio_play'), command=self._btn_play_action) 472 | self.btn_play.grid(row=4, column=1, **style('audio_ctrl_operation_grid')) 473 | self.btn_play_is_playing_cache = False 474 | self.after_id = None 475 | self.grid_columnconfigure((0, 1, 2), weight=1) 476 | 477 | def _slider_action(self, value:float): 478 | self.time_cur.configure(text=DurationFormatter.apply(value)) 479 | flag = self.track.is_playing() 480 | self.push_status(False) 481 | self.push_status(flag) 482 | 483 | def _btn_play_action(self): 484 | self.push_status(not self.track.is_playing()) 485 | 486 | def pull_status(self): 487 | # Sync duration 488 | new_duration = self.track.get_playing_duration() 489 | self.var_duration.set(new_duration) 490 | self.time_cur.configure(text=DurationFormatter.apply(min(new_duration, self.track.duration))) 491 | # Sync playing status 492 | self.push_status(self.track.is_playing()) 493 | # Register the next run 494 | if self.after_id is not None: 495 | self.after_id = self.after(5, self.pull_status) 496 | 497 | def push_status(self, status:bool): 498 | if status != self.btn_play_is_playing_cache: 499 | self.btn_play_is_playing_cache = status 500 | if status: 501 | # Switch to playing mode 502 | # Reset duration to zero if previous playing has completed 503 | if self.var_duration.get() >= self.track.duration: 504 | self.var_duration.set(0.0) 505 | # Reload track and start to play 506 | AudioComposer.load(self.track) 507 | self.track.play(self.var_duration.get()) 508 | # Enable scheduled refreshing task 509 | self.after_id = self.after(5, self.pull_status) 510 | # Toggle button status 511 | self.btn_play.configure(text='暂停', image=icon('audio_pause')) 512 | else: 513 | # Switch to paused mode 514 | # Disable scheduled refreshing task 515 | if self.after_id: 516 | self.after_cancel(self.after_id) 517 | self.after_id = None 518 | # Stop playing 519 | self.track.stop() 520 | # Toggle button status 521 | self.btn_play.configure(text='播放', image=icon('audio_play')) 522 | 523 | def dispose(self): 524 | self.push_status(False) 525 | self.track.dispose() 526 | 527 | 528 | class AudioPreviewer(ctk.CTkFrame, HidableGridWidget): 529 | def __init__(self, master:ctk.CTkFrame, grid_row:int, grid_column:int, empty_tip:str=""): 530 | ctk.CTkFrame.__init__(self, master, fg_color='transparent') 531 | HidableGridWidget.__init__(self, grid_row, grid_column, init_visible=True, sticky='nsew') 532 | self.info = ctk.CTkLabel(self, anchor='center') 533 | self.info.grid(row=0, column=0, sticky='ew') 534 | self.controllers:"list[AudioController]" = [] 535 | self.grid_columnconfigure((0), weight=1) 536 | self._empty_tip = empty_tip 537 | self.show(None) 538 | 539 | def show(self, value:"dict[str,bytes]|None"): 540 | # Clear previous audios 541 | for i in self.controllers: 542 | i.dispose() 543 | i.set_visible(False) 544 | self.controllers.clear() 545 | if isinstance(value, dict): 546 | with TestRT('preview_audio'): 547 | # Add current audios 548 | for i, (k, v) in enumerate(value.items()): 549 | self.controllers.append(AudioController(self, i + 1, 0, k, v)) 550 | self.info.configure(text="") 551 | else: 552 | self.info.configure(text=self._empty_tip) 553 | -------------------------------------------------------------------------------- /src/utils/UIConcurrent.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | import threading 5 | import tkinter as tk 6 | 7 | 8 | class GUITaskBase(): 9 | """GUI task handler base class.""" 10 | 11 | DEFAULT_START_MESSAGE = "正在初始化" 12 | DEFAULT_SUCCESS_MESSAGE = "完成" 13 | DEFAULT_FAILURE_MESSAGE = "失败" 14 | 15 | def __init__(self, title:str=""): 16 | self._title = title 17 | self._completed = False 18 | self._cancelled = False 19 | self._running = False 20 | self._exception = None 21 | self.__progress = tk.DoubleVar(value=0.0) 22 | self.__message = tk.StringVar(value="") 23 | self.__thread = None 24 | 25 | def _run(self): 26 | """The main execution of the task. Must be implemented.""" 27 | raise NotImplementedError() 28 | 29 | def _on_succeed(self): 30 | """The callback that will be called when the task succeed.""" 31 | 32 | def _on_fail(self): 33 | """The callback that will be called when the task fail.""" 34 | raise self.get_exception() 35 | 36 | def _on_complete(self): 37 | """The callback that will be called when the task succeed or fail. 38 | This callback will be called after `on_success` and `on_failure`. 39 | """ 40 | 41 | def start(self): 42 | """Starts the task. It must be called at most once.""" 43 | if self._completed: 44 | raise TaskReuseError("This task has completed") 45 | if self._running: 46 | raise TaskReuseError("This task is running now") 47 | GUITaskCoordinator.add_task(self) 48 | def target(): 49 | self._completed = False 50 | self._cancelled = False 51 | self._running = True 52 | try: 53 | self.__progress.set(0.0) 54 | self.__message.set(GUITaskBase.DEFAULT_START_MESSAGE) 55 | self._run() 56 | self.__progress.set(1.0) 57 | self.__message.set(GUITaskBase.DEFAULT_SUCCESS_MESSAGE) 58 | self._on_succeed() 59 | except BaseException as arg: 60 | self._exception = arg 61 | self.__message.set(GUITaskBase.DEFAULT_FAILURE_MESSAGE) 62 | self._on_fail() 63 | finally: 64 | self._completed = True 65 | self._running = False 66 | self._on_complete() 67 | GUITaskCoordinator.remove_task(self) 68 | self.__thread = threading.Thread(target=target, daemon=True, name=f"GUITask:{self.__class__.__name__}") 69 | self.__thread.start() 70 | 71 | def cancel(self): 72 | """Cancels the task. It will only sets the status to cancelled.""" 73 | self._cancelled = True 74 | 75 | def update(self, progress:float=None, message:str=None): 76 | """Updates the progress variable or the message variable. `None` for not updated.""" 77 | if progress: 78 | self.__progress.set(progress) 79 | if message: 80 | self.__message.set(message) 81 | 82 | def is_completed(self): 83 | """Returns `True` if the task was succeeded or failed.""" 84 | return self._completed 85 | 86 | def is_cancelled(self): 87 | """Returns `True` if the task was cancelled.""" 88 | return self._cancelled 89 | 90 | def is_failed(self): 91 | """Returns `True` if the task was failed.""" 92 | return self._exception is not None 93 | 94 | def is_running(self): 95 | """Returns `True` if the task is running.""" 96 | return self._running 97 | 98 | def get_exception(self): 99 | """Returns the exception that cause the failure, or `None` if no exception occurred.""" 100 | return self._exception 101 | 102 | @property 103 | def title(self): 104 | """The title of the task.""" 105 | return self._title 106 | 107 | @property 108 | def observable_progress(self): 109 | """The progress variable that in [0.0, 1.0].""" 110 | return self.__progress 111 | 112 | @property 113 | def observable_message(self): 114 | """The message variable that may be displayed to the user.""" 115 | return self.__message 116 | 117 | 118 | class GUITaskCoordinator(): 119 | _REGISTRY:"dict[type[GUITaskBase],tuple[tk.BooleanVar,list[type[GUITaskBase]]]]" = { 120 | # (type) Task class : (BooleanVar) Unblocked indicator, (list) Blocking task classes 121 | } 122 | _REGISTRY_LOCK = threading.Lock() 123 | 124 | _RUNNING:"list[GUITaskBase]" = [ 125 | # (GuiTaskBase, ...) Currently running tasks 126 | ] 127 | _RUNNING_LOCK = threading.Lock() 128 | 129 | @staticmethod 130 | def register(task_cls:"type[GUITaskBase]", blocking_tasks_cls:"list[type[GUITaskBase]]"): 131 | """Registers a new task class.""" 132 | with GUITaskCoordinator._REGISTRY_LOCK: 133 | if task_cls in GUITaskCoordinator._REGISTRY: 134 | raise TaskCoordinatorError("Duplicated registry entry") 135 | bool_var = tk.BooleanVar(value=True) 136 | GUITaskCoordinator._REGISTRY[task_cls] = (bool_var, blocking_tasks_cls) 137 | 138 | @staticmethod 139 | def get_unblocked_indicator(task_cls:"type[GUITaskBase]"): 140 | """Gets the unblocked indicator of the given task class.""" 141 | with GUITaskCoordinator._REGISTRY_LOCK: 142 | if task_cls not in GUITaskCoordinator._REGISTRY: 143 | raise TaskCoordinatorError(f"{task_cls} has not been registered yet") 144 | return GUITaskCoordinator._REGISTRY[task_cls][0] 145 | 146 | @staticmethod 147 | def add_task(new_task:GUITaskBase): 148 | """Adds a new task instance. Error will be raised if blocking triggered.""" 149 | with GUITaskCoordinator._RUNNING_LOCK: 150 | if any(new_task == t for t in GUITaskCoordinator._RUNNING): 151 | raise TaskReuseError(f"This task already exists") 152 | if not GUITaskCoordinator.get_unblocked_indicator(new_task.__class__).get(): 153 | raise TaskBlockingError(f"{new_task.__class__} cannot run due to blocking rule") 154 | GUITaskCoordinator._RUNNING.append(new_task) 155 | GUITaskCoordinator._update_vars() 156 | 157 | @staticmethod 158 | def remove_task(old_task:GUITaskBase): 159 | """Removes an old task instance.""" 160 | with GUITaskCoordinator._RUNNING_LOCK: 161 | if old_task not in GUITaskCoordinator._RUNNING: 162 | return 163 | GUITaskCoordinator._RUNNING.remove(old_task) 164 | GUITaskCoordinator._update_vars() 165 | 166 | @staticmethod 167 | def _update_vars(): 168 | with GUITaskCoordinator._REGISTRY_LOCK: 169 | for _, (bool_var, blocking_tasks_cls) in GUITaskCoordinator._REGISTRY.items(): 170 | bool_var.set( 171 | all( 172 | all( 173 | not isinstance(t, r) 174 | for r in blocking_tasks_cls 175 | ) 176 | for t in GUITaskCoordinator._RUNNING 177 | ) 178 | ) 179 | 180 | 181 | class TaskReuseError(RuntimeError): 182 | def __init__(self, *args:object): 183 | super().__init__(*args) 184 | 185 | 186 | class TaskBlockingError(RuntimeError): 187 | def __init__(self, *args:object): 188 | super().__init__(*args) 189 | 190 | 191 | class TaskCoordinatorError(RuntimeError): 192 | def __init__(self, *args:object): 193 | super().__init__(*args) 194 | -------------------------------------------------------------------------------- /src/utils/UIStyles.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | import tkinter as tk 5 | import tkinter.ttk as ttk 6 | import customtkinter as ctk 7 | from PIL import Image, ImageTk 8 | from ..backend import ArkClientPayload as acp 9 | 10 | 11 | def icon(icon_key:str): 12 | if icon_key not in _IconHub.DATA: 13 | raise KeyError(f"Icon ID not found: {icon_key}") 14 | rst = _IconHub.DATA[icon_key] 15 | if isinstance(rst, tuple): 16 | return tuple(i.get() for i in rst) 17 | elif isinstance(rst, _DefImage): 18 | return rst.get() 19 | else: 20 | raise TypeError(f"Not recognized value: {rst}") 21 | 22 | 23 | def file_icon(file_status:int): 24 | if file_status not in _FileIconHub.DATA: 25 | raise KeyError(f"File status icon not found: {file_status}") 26 | return _FileIconHub.DATA[file_status].get() 27 | 28 | 29 | def style(class_key:str): 30 | if class_key not in _StyleHub.DATA: 31 | raise KeyError(f"Style class not found: {class_key}") 32 | rst = _StyleHub.DATA[class_key].copy() 33 | lazy_cls = (_DefFont, _DefImage) 34 | for k, v in rst.items(): 35 | if isinstance(v, lazy_cls): 36 | rst[k] = v.get() 37 | elif isinstance(v, tuple): 38 | new_v = tuple(i.get() if isinstance(i, lazy_cls) else i for i in v) 39 | rst[k] = new_v 40 | elif not isinstance(v, (str, int, float)): 41 | raise TypeError(f"Not recognized style value: {v}") 42 | return rst 43 | 44 | 45 | def load_font_asset(): 46 | ctk.FontManager.load_font("assets/fonts/SourceHanSansCN-Medium.otf") 47 | 48 | 49 | def load_ttk_style(master:tk.Misc): 50 | _TTkStyleHub.load_style_to(master) 51 | 52 | 53 | class _KwDict: 54 | def __init__(self, **kwargs): 55 | self._kwargs = kwargs 56 | 57 | def __getitem__(self, key:str): 58 | return self._kwargs[key] 59 | 60 | def __eq__(self, other:object): 61 | if isinstance(other, _KwDict): 62 | return hash(self) == hash(other) 63 | return False 64 | 65 | def __hash__(self): 66 | return hash(tuple(self._kwargs.items())) 67 | 68 | 69 | class _DefFont: 70 | """Lazy load ctk font definition record.""" 71 | _CACHE:"dict[int,ctk.CTkFont]" = {} 72 | _DEFAULT_FONT_FAMILY = 'Source Han Sans CN Medium' 73 | _DEFAULT_FONT_SIZE = 13 74 | 75 | def __init__(self, 76 | family:str=_DEFAULT_FONT_FAMILY, 77 | size:int=_DEFAULT_FONT_SIZE, 78 | bold:bool=False, 79 | italic:bool=False): 80 | self._data = _KwDict(family=family, 81 | size=size, 82 | weight='bold' if bold else 'normal', 83 | slant='italic' if italic else 'roman') 84 | 85 | def get(self): 86 | if self._data not in _DefFont._CACHE: 87 | _DefFont._CACHE[self._data] = ctk.CTkFont(**self._data._kwargs) 88 | return _DefFont._CACHE[self._data] 89 | 90 | def as_tuple(self): 91 | return (self._data['family'], self._data['size'], self._data['weight']) 92 | 93 | def dispose(self): 94 | _DefFont._CACHE.pop(self, None) 95 | 96 | 97 | class _DefImage: 98 | """Lazy load ctk image definition record.""" 99 | _CACHE:"dict[int,ctk.CTkImage|ImageTk.PhotoImage]" = {} 100 | 101 | def __init__(self, path:str, size:"tuple[int,int]|int", repaint:str=None, use_ctk:bool=True): 102 | self._data = _KwDict(path=path, 103 | size=size if isinstance(size, tuple) else (size, size), 104 | repaint=repaint, 105 | use_ctk=use_ctk) 106 | self._image = None 107 | 108 | def get(self): 109 | if self._data not in _DefImage._CACHE: 110 | if self._image is None: 111 | self._image = Image.open(self._data['path']) 112 | image = self._image.copy() 113 | if self._data['repaint'] is not None: 114 | grayscale = image.convert('L') 115 | image = Image.new('RGBA', image.size, self._data['repaint']) 116 | image.putalpha(grayscale) 117 | if self._data['use_ctk']: 118 | rst = ctk.CTkImage(image, size=self._data['size']) 119 | else: 120 | image = image.resize(self._data['size'], resample=Image.BILINEAR) 121 | rst = ImageTk.PhotoImage(image) 122 | self._CACHE[self._data] = rst 123 | return self._CACHE[self._data] 124 | 125 | def dispose(self): 126 | _DefImage._CACHE.pop(self._data, None) 127 | 128 | 129 | class _StyleHub: 130 | COLOR_WHITE = '#FFF' 131 | COLOR_BLACK = '#000' 132 | FONT_XXS = _DefFont(size=10) 133 | FONT_XS = _DefFont(size=11) 134 | FONT_S = _DefFont(size=12) 135 | FONT_M = _DefFont(size=13) 136 | FONT_MB = _DefFont(size=13) 137 | FONT_L = _DefFont(size=14) 138 | FONT_LB = _DefFont(size=14, bold=True) 139 | FONT_XLB = _DefFont(size=20, bold=True) 140 | THEME = [ 141 | "#f4f6f9", #[0] 142 | "#cbd5e3", #[1] 143 | "#a3b4cd", #[2] 144 | "#7a93b7", #[3] 145 | "#5272a1", #[4] 146 | "#2A528C", #[5] 147 | "#214170", #[6] 148 | "#193154", #[7] 149 | "#102038", #[8] 150 | "#08101b", #[9] 151 | ] 152 | DATA:"dict[str,dict]" = { 153 | # Banner 154 | 'banner': {'font': FONT_XLB}, 155 | # Menu 156 | 'menu': {'fg_color': THEME[3]}, 157 | 'menu_btn': {'font': FONT_LB, 158 | 'width': 150, 159 | 'height': 35}, 160 | 'menu_btn_regular': {'fg_color': THEME[0], 161 | 'hover_color': THEME[1], 162 | 'text_color': THEME[9]}, 163 | 'menu_btn_active': {'fg_color': THEME[5], 164 | 'hover_color': THEME[6], 165 | 'text_color': THEME[0]}, 166 | # Panel 167 | 'panel_title': {'font': FONT_MB, 168 | 'text_color': THEME[3], 169 | 'anchor': 'w', 170 | 'compound': 'left'}, 171 | 'panel_title_grid': {'padx': 10, 172 | 'pady': 10, 173 | 'sticky': 'nw'}, 174 | # Button 175 | 'operation_button': {'font': FONT_S}, 176 | 'operation_button_info': {'fg_color': (THEME[1], THEME[8]), 177 | 'hover_color': (THEME[2], THEME[7]), 178 | 'text_color': (THEME[7], THEME[2]), 179 | 'border_color': (THEME[5], THEME[5]), 180 | 'border_width': 1}, 181 | 'operation_button_grid': {'padx': (5, 10), 182 | 'pady': (5, 10)}, 183 | # Label 184 | 'info_label_head': {'font': FONT_MB, 185 | 'corner_radius': 10, 186 | 'fg_color': THEME[1], 187 | 'text_color': THEME[5], 188 | 'anchor': 'e'}, 189 | 'info_label_body': {'font': FONT_S, 190 | 'anchor': 'nw'}, 191 | 'info_label_head_grid': {'padx': 5, 192 | 'pady': 0, 193 | 'sticky': 'nw'}, 194 | 'info_label_body_grid': {'padx': (10, 5), 195 | 'pady': 0, 196 | 'sticky': 'nw'}, 197 | 'info_label_grid': {'padx': 5, 198 | 'pady': 5, 199 | 'sticky': 'ew'}, 200 | # Progress bar 201 | 'progress_bar_head': {'font': FONT_M, 202 | 'text_color': THEME[5], 203 | 'anchor': 'w'}, 204 | 'progress_bar_body': {'font': FONT_S, 205 | 'text_color': (THEME[3], THEME[6]), 206 | 'anchor': 'e'}, 207 | 'progress_bar_head_grid': {'padx': 5, 208 | 'pady': 0, 209 | 'sticky': 'nw'}, 210 | 'progress_bar_body_grid': {'padx': 5, 211 | 'pady': 0, 212 | 'sticky': 'ne'}, 213 | 'progress_bar_grid': {'padx': 10, 214 | 'pady': 5}, 215 | # Treeview 216 | 'treeview_empty_tip': {'font': FONT_L}, 217 | # Audio Controller 218 | 'audio_ctrl_name': {'font': FONT_M, 219 | 'anchor': 'center'}, 220 | 'audio_ctrl_info': {'font': FONT_S, 221 | 'anchor': 'center'}, 222 | 'audio_ctrl_operation_grid': {'padx': 10, 223 | 'pady': (0, 5)}, 224 | } 225 | 226 | 227 | class _IconHub: 228 | DATA:"dict[str,_DefImage|tuple[_DefImage]]" = { 229 | 'progress': _DefImage('assets/icons_ui/i_rhombus.png', 18, repaint=_StyleHub.THEME[5]), 230 | 'explorer': _DefImage('assets/icons_ui/i_sitemap.png', 18, repaint=_StyleHub.THEME[3]), 231 | 'inspector': _DefImage('assets/icons_ui/i_file.png', 18, repaint=_StyleHub.THEME[3]), 232 | 'abstract': _DefImage('assets/icons_ui/i_archive.png', 18, repaint=_StyleHub.THEME[3]), 233 | 'operation': _DefImage('assets/icons_ui/i_tools.png', 18, repaint=_StyleHub.THEME[3]), 234 | 'goto': _DefImage('assets/icons_ui/i_arrow_right.png', 18, repaint=_StyleHub.THEME[3]), 235 | 'repo_open': _DefImage('assets/icons_ui/i_binoculars.png', 18, repaint=_StyleHub.THEME[0]), 236 | 'repo_reload': _DefImage('assets/icons_ui/i_synchronization.png', 18, repaint=_StyleHub.THEME[7]), 237 | 'switch_latest': _DefImage('assets/icons_ui/i_arrow_up.png', 18, repaint=_StyleHub.THEME[0]), 238 | 'switch_manual': _DefImage('assets/icons_ui/i_arrow_right.png', 18, repaint=_StyleHub.THEME[7]), 239 | 'repo_sync': _DefImage('assets/icons_ui/i_download.png', 18, repaint=_StyleHub.THEME[0]), 240 | 'file_view': _DefImage('assets/icons_ui/i_paste.png', 18, repaint=_StyleHub.THEME[0]), 241 | 'file_sync': _DefImage('assets/icons_ui/i_download.png', 18, repaint=_StyleHub.THEME[0]), 242 | 'file_goto': _DefImage('assets/icons_ui/i_browser.png', 18, repaint=_StyleHub.THEME[7]), 243 | 'file_open': _DefImage('assets/icons_ui/i_paste.png', 18, repaint=_StyleHub.THEME[0]), 244 | 'file_reload': _DefImage('assets/icons_ui/i_synchronization.png', 18, repaint=_StyleHub.THEME[7]), 245 | 'file_extract': _DefImage('assets/icons_ui/i_upload.png', 18, repaint=_StyleHub.THEME[0]), 246 | 'audio_play': _DefImage('assets/icons_ui/i_play.png', 14, repaint=_StyleHub.THEME[0]), 247 | 'audio_pause': _DefImage('assets/icons_ui/i_pause.png', 14, repaint=_StyleHub.THEME[0]), 248 | 'dialog_okay': _DefImage('assets/icons_ui/i_okay.png', 10, repaint=_StyleHub.THEME[0]), 249 | 'dialog_cancel': _DefImage('assets/icons_ui/i_cancel.png', 10, repaint=_StyleHub.THEME[0]), 250 | } 251 | 252 | 253 | class _FileIconHub: 254 | DATA:"dict[int,_DefImage]" = { 255 | acp.FileStatus.DIRECTORY: _DefImage('assets/icons_file/i_folder.png', 18, use_ctk=False), 256 | acp.FileStatus.UNCHECKED: _DefImage('assets/icons_file/i_unchecked.png', 18, use_ctk=False), 257 | acp.FileStatus.OKAY: _DefImage('assets/icons_file/i_file.png', 18, use_ctk=False), 258 | acp.FileStatus.ADD: _DefImage('assets/icons_file/i_add.png', 18, use_ctk=False), 259 | acp.FileStatus.ADDED: _DefImage('assets/icons_file/i_added.png', 18, use_ctk=False), 260 | acp.FileStatus.MODIFY: _DefImage('assets/icons_file/i_modify.png', 18, use_ctk=False), 261 | acp.FileStatus.MODIFIED: _DefImage('assets/icons_file/i_modified.png', 18, use_ctk=False), 262 | acp.FileStatus.DELETE: _DefImage('assets/icons_file/i_delete.png', 18, use_ctk=False), 263 | acp.FileStatus.DELETED: _DefImage('assets/icons_file/i_deleted.png', 18, use_ctk=False), 264 | } 265 | 266 | 267 | class _TTkStyleHub: 268 | @staticmethod 269 | def load_style_to(master:tk.Misc): 270 | ttk_style = ttk.Style(master) 271 | ttk_style.theme_use('default') 272 | # https://www.tcl-lang.org/man/tcl8.6/TkCmd/ttk_treeview.htm 273 | ttk_style.configure('Treeview', 274 | font=_StyleHub.FONT_XXS.as_tuple(), 275 | padding=2, 276 | foreground=_StyleHub.COLOR_BLACK, 277 | background=_StyleHub.COLOR_WHITE, 278 | fieldbackground='transparent') 279 | ttk_style.map('Treeview', background=[('selected', _StyleHub.THEME[4])]) 280 | ttk_style.configure('Treeview.Heading', 281 | font=_StyleHub.FONT_XS.as_tuple(), 282 | padding=4, 283 | foreground=_StyleHub.THEME[6], 284 | background=_StyleHub.THEME[0], 285 | relief='none') 286 | ttk_style.map('Treeview.Heading', 287 | background=[('active', _StyleHub.THEME[1])],) 288 | # https://www.tcl-lang.org/man/tcl8.6/TkCmd/ttk_scrollbar.htm 289 | ttk_style.configure('TScrollbar', relief='none') 290 | -------------------------------------------------------------------------------- /src/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2024-2025, Harry Huang 3 | # @ BSD 3-Clause License 4 | --------------------------------------------------------------------------------