├── .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 |
13 |
14 |
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 |
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 |
--------------------------------------------------------------------------------