├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples ├── color threshold_demo.py ├── demo.py └── menu_test_demo.py ├── pics ├── button.jpg ├── checkbox.jpg ├── install_tutorial.png ├── radiobutton.jpg ├── slider.jpg └── switch.jpg └── src └── maixpy_ui ├── __init__.py ├── components ├── __init__.py ├── button.py ├── checkbox.py ├── radio.py ├── slider.py └── switch.py └── core ├── __init__.py ├── resolution_adapter.py └── ui_manager.py /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | pyproject.toml 3 | dist/ 4 | src/maixpy_ui_lib.egg-info/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Version 1.0 (Jul 20, 2025) 2 | 3 | - 当前已实现的组件:Button, Slider, Switch, Checkbox, RadioButton。 4 | 5 | Version 1.1 (Jul 20, 2025) 6 | 7 | - 增加了 ResolutionAdapter 以实现对不同分辨率的 UI 适配(由 @levi_jia 实现)。 8 | 9 | Version 1.2 (Jul 20, 2025) 10 | 11 | - ResolutionAdapter 增加了自定义基础分辨率的功能(默认仍为 320*240)。 12 | - 更新了 README,增加了对 ResolutionAdapter 的说明。 13 | - 更新了 demo 以支持 ResolutionAdapter。 14 | 15 | Version 1.3 (Jul 20, 2025) 16 | 17 | - 增加了 UIManager 以实现页面间的导航(进入和返回)功能。 18 | - 使用 UIManager 重构了 demo。 19 | 20 | Version 2.0 (Jul 21, 2025) 21 | 22 | - 进行了大规模代码重构,以提高可读性和可维护性。 23 | - 为所有类和方法添加了全面的文档字符串,以提供清晰的内联文档。 24 | - 集成了完整的类型注解。 25 | - 完善了 README。 26 | 27 | Version 2.1 (Jul 23, 2025) 28 | 29 | - 尝试改进Page为树型结构(由 @HYKMAX 实现)。 30 | 31 | Version 2.2 (Jul 24, 2025) 32 | - 拆分文件,为上传至PyPI做准备。 33 | 34 | Version 2.3 (Jul 24, 2025) 35 | - 添加离线阈值和多级菜单的示例程序。 36 | 37 | Version 2.4 (Jul 24, 2025) 38 | - UIManager添加remove_page方法。 39 | - 修复README显示问题。 40 | - 细化安装步骤。 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MaixPy-UI-Lib:一款为 MaixPy 开发的轻量级 UI 组件库 2 | 3 | [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/aristorechina/MaixPy-UI-Lib/blob/main/LICENSE) [![Version](https://img.shields.io/badge/version-2.4-brightgreen.svg)](https://github.com/aristorechina/MaixPy-UI-Lib) 4 | 5 | 本项目是一款为 MaixPy 开发的轻量级 UI 组件库,遵循 `Apache 2.0` 协议。 6 | 7 | 欢迎给本项目提pr,要是觉得好用的话请给本项目点个star⭐ 8 | 9 | --- 10 | 11 | ## 🖼️功能展示 12 | 13 | 以下画面均截取自本项目的 `main.py` 14 | 15 | ![button](https://raw.githubusercontent.com/aristorechina/MaixPy-UI-Lib/main/pics/button.jpg) 16 | 17 | ![switch](https://raw.githubusercontent.com/aristorechina/MaixPy-UI-Lib/main/pics/switch.jpg) 18 | 19 | ![slider](https://raw.githubusercontent.com/aristorechina/MaixPy-UI-Lib/main/pics/slider.jpg) 20 | 21 | ![radiobutton](https://raw.githubusercontent.com/aristorechina/MaixPy-UI-Lib/main/pics/radiobutton.jpg) 22 | 23 | ![checkbox](https://raw.githubusercontent.com/aristorechina/MaixPy-UI-Lib/main/pics/checkbox.jpg) 24 | 25 | --- 26 | 27 | ## 📦 安装 28 | 29 | `pip install maixpy-ui-lib` 30 | 31 | ![install_tutorial](https://raw.githubusercontent.com/aristorechina/MaixPy-UI-Lib/main/pics/install_tutorial.png) 32 | 33 | --- 34 | 35 | ## 🚀快速上手 36 | 37 | 您可以通过运行仓库中的示例程序 `examples/demo.py` 来快速熟悉本项目的基本功能和使用方法。 38 | 39 | --- 40 | 41 | ## 📖组件详解 42 | 43 | ### 1. 按钮 (Button) 44 | 45 | 按钮是最基本的交互组件,用于触发一个操作。 46 | 47 | #### 使用方式 48 | 1. 创建一个 `ButtonManager` 实例。 49 | 2. 创建 `Button` 实例,定义其矩形区域、标签文本和回调函数。 50 | 3. 使用 `manager.add_button()` 将按钮实例添加到管理器中。 51 | 4. 在主循环中,调用 `manager.handle_events(img)` 来处理触摸事件并绘制按钮。 52 | 53 | #### 示例 54 | 55 | ```python 56 | from maix import display, camera, app, touchscreen, image 57 | from maixpy_ui import Button, ButtonManager 58 | import time 59 | 60 | # 1. 初始化硬件 61 | disp = display.Display() 62 | ts = touchscreen.TouchScreen() 63 | cam = camera.Camera() 64 | 65 | # 2. 定义回调函数 66 | # 当按钮被点击时,这个函数会被调用 67 | def on_button_click(): 68 | print("Hello, World! The button was clicked.") 69 | # 你可以在这里执行任何操作,比如切换页面、拍照等 70 | 71 | # 3. 初始化UI 72 | # 创建一个按钮管理器 73 | btn_manager = ButtonManager(ts, disp) 74 | 75 | # 创建一个按钮实例 76 | # rect: [x, y, 宽度, 高度] 77 | # label: 按钮上显示的文字 78 | # callback: 点击时调用的函数 79 | hello_button = Button( 80 | rect=[240, 200, 160, 80], 81 | label="Click Me", 82 | text_scale=2.0, 83 | callback=on_button_click, 84 | bg_color=(0, 120, 220), # 蓝色背景 85 | pressed_color=(0, 80, 180), # 按下时深蓝色 86 | text_color=(255, 255, 255) # 白色文字 87 | ) 88 | 89 | # 将按钮添加到管理器 90 | btn_manager.add_button(hello_button) 91 | 92 | # 4. 主循环 93 | print("Button example running. Press the button on the screen.") 94 | while not app.need_exit(): 95 | img = cam.read() 96 | 97 | # 在每一帧中,让管理器处理事件并绘制按钮 98 | btn_manager.handle_events(img) 99 | 100 | disp.show(img) 101 | time.sleep(0.02) # 降低CPU使用率 102 | ``` 103 | 104 | #### `Button` 类 105 | 创建一个可交互的按钮组件。该组件可以响应触摸事件,并在按下时改变外观,释放时执行回调函数。 106 | 107 | ##### 构造函数: `__init__` 108 | | 参数 | 类型 | 描述 | 默认值 | 109 | | :----------------: | :--------------------: | :-----------------------------------------: | :---------------: | 110 | | `rect` | `Sequence[int]` | 按钮的位置和尺寸 `[x, y, w, h]`。**必需**。 | - | 111 | | `label` | `str` | 按钮上显示的文本。**必需**。 | - | 112 | | `callback` | `Callable \| None` | 当按钮被点击时调用的函数。**必需**。 | - | 113 | | `bg_color` | `Sequence[int] \| None` | 背景颜色 (R, G, B)。 | `(50, 50, 50)` | 114 | | `pressed_color` | `Sequence[int] \| None` | 按下状态的背景颜色 (R, G, B)。 | `(0, 120, 220)` | 115 | | `text_color` | `Sequence[int]` | 文本颜色 (R, G, B)。 | `(255, 255, 255)` | 116 | | `border_color` | `Sequence[int]` | 边框颜色 (R, G, B)。 | `(200, 200, 200)` | 117 | | `border_thickness` | `int` | 边框厚度(像素)。 | `2` | 118 | | `text_scale` | `float` | 文本的缩放比例。 | `1.5` | 119 | | `font` | `str \| None` | 使用的字体文件路径。 | `None` | 120 | | `align_h` | `str` | 水平对齐方式 ('left', 'center', 'right')。 | `'center'` | 121 | | `align_v` | `str` | 垂直对齐方式 ('top', 'center', 'bottom')。 | `'center'` | 122 | 123 | ##### 方法 (Methods) 124 | | 方法 | 参数 | 描述 | 125 | | :-----------------: | :----------------------------------------------------------: | :--------------------------: | 126 | | `draw(img)` | `img` (`maix.image.Image`): 将要绘制按钮的目标图像。 | 在指定的图像上绘制按钮。 | 127 | | `handle_event(...)` | `x` (`int`): 触摸点的 X 坐标。
`y` (`int`): 触摸点的 Y 坐标。
`pressed` (`bool\|int`): 触摸屏是否被按下。
`img_w` (`int`): 图像缓冲区的宽度。
`img_h` (`int`): 图像缓冲区的高度。
`disp_w` (`int`): 显示屏的宽度。
`disp_h` (`int`): 显示屏的高度。 | 处理触摸事件并更新按钮状态。 | 128 | 129 | #### `ButtonManager` 类 130 | 管理一组按钮的事件处理和绘制。 131 | 132 | ##### 构造函数: `__init__` 133 | | 参数 | 类型 | 描述 | 134 | | :----: | :-----------------------: | :--------------: | 135 | | `ts` | `touchscreen.TouchScreen` | 触摸屏设备实例。**必需**。 | 136 | | `disp` | `display.Display` | 显示设备实例。**必需**。 | 137 | 138 | ##### 方法 (Methods) 139 | | 方法 | 参数 | 描述 | 140 | | :------------------: | :----------------------------------------------: | :--------------------------------: | 141 | | `add_button(button)` | `button` (`Button`): 要添加的 Button 实例。 | 向管理器中添加一个按钮。 | 142 | | `handle_events(img)` | `img` (`maix.image.Image`): 绘制按钮的目标图像。 | 处理所有受管按钮的事件并进行绘制。 | 143 | 144 | --- 145 | 146 | ### 2. 滑块 (Slider) 147 | 148 | 滑块允许用户在一个连续的范围内选择一个值。 149 | 150 | #### 使用方式 151 | 1. 创建一个 `SliderManager` 实例。 152 | 2. 创建 `Slider` 实例,定义其区域、数值范围和回调函数。 153 | 3. 使用 `manager.add_slider()` 添加滑块。 154 | 4. 在主循环中,调用 `manager.handle_events(img)`。 155 | 156 | #### 示例 157 | ```python 158 | from maix import display, camera, app, touchscreen, image 159 | from maixpy_ui import Slider, SliderManager 160 | import time 161 | 162 | # 1. 初始化硬件 163 | disp = display.Display() 164 | ts = touchscreen.TouchScreen() 165 | cam = camera.Camera() 166 | 167 | # 全局变量,用于存储滑块的值 168 | current_brightness = 128 169 | 170 | # 2. 定义回调函数 171 | # 当滑块的值改变时,这个函数会被调用 172 | def on_slider_update(value): 173 | global current_brightness 174 | current_brightness = value 175 | print(f"Slider value updated to: {value}") 176 | 177 | # 3. 初始化UI 178 | slider_manager = SliderManager(ts, disp) 179 | 180 | brightness_slider = Slider( 181 | rect=[50, 230, 540, 20], 182 | scale=2.0, 183 | min_val=0, 184 | max_val=255, 185 | default_val=current_brightness, 186 | callback=on_slider_update, 187 | label="Slider" 188 | ) 189 | 190 | slider_manager.add_slider(brightness_slider) 191 | 192 | # 4. 主循环 193 | print("Slider example running. Drag the slider.") 194 | title_color = image.Color.from_rgb(255, 255, 255) 195 | 196 | while not app.need_exit(): 197 | img = cam.read() 198 | 199 | # 实时显示滑块的值 200 | img.draw_string(20, 20, f"Value: {current_brightness}", scale=2.0, color=title_color) 201 | 202 | # 处理滑块事件并绘制 203 | slider_manager.handle_events(img) 204 | 205 | disp.show(img) 206 | time.sleep(0.02) 207 | ``` 208 | 209 | #### `Slider` 类 210 | 创建一个可拖动的滑块组件,用于在一定范围内选择一个值。 211 | 212 | ##### 构造函数: `__init__` 213 | | 参数 | 类型 | 描述 | 默认值 | 214 | | :--------------------: | :---------------: | :-----------------------------------------: | :---------------: | 215 | | `rect` | `Sequence[int]` | 滑块的位置和尺寸 `[x, y, w, h]`。**必需**。 | - | 216 | | `scale` | `float` | 滑块的整体缩放比例。 | `1.0` | 217 | | `min_val` | `int` | 滑块的最小值。 | `0` | 218 | | `max_val` | `int` | 滑块的最大值。 | `100` | 219 | | `default_val` | `int` | 滑块的默认值。 | `50` | 220 | | `callback` | `Callable \| None` | 值改变时调用的函数,接收新值作为参数。 | `None` | 221 | | `label` | `str` | 滑块上方的标签文本。 | `""` | 222 | | `track_color` | `Sequence[int]` | 滑轨背景颜色 (R, G, B)。 | `(60, 60, 60)` | 223 | | `progress_color` | `Sequence[int]` | 滑轨进度条颜色 (R, G, B)。 | `(0, 120, 220)` | 224 | | `handle_color` | `Sequence[int]` | 滑块手柄颜色 (R, G, B)。 | `(255, 255, 255)` | 225 | | `handle_border_color` | `Sequence[int]` | 滑块手柄边框颜色 (R, G, B)。 | `(100, 100, 100)` | 226 | | `handle_pressed_color` | `Sequence[int]` | 按下时手柄颜色 (R, G, B)。 | `(220, 220, 255)` | 227 | | `label_color` | `Sequence[int]` | 标签文本颜色 (R, G, B)。 | `(200, 200, 200)` | 228 | | `tooltip_bg_color` | `Sequence[int]` | 拖动时提示框背景色 (R, G, B)。 | `(0, 0, 0)` | 229 | | `tooltip_text_color` | `Sequence[int]` | 拖动时提示框文本颜色 (R, G, B)。 | `(255, 255, 255)` | 230 | | `show_tooltip_on_drag` | `bool \| int` | 是否在拖动时显示数值提示框。 | `True` | 231 | 232 | ##### 方法 (Methods) 233 | | 方法 | 参数 | 描述 | 234 | | :-----------------: | :----------------------------------------------------------: | :--------------------------: | 235 | | `draw(img)` | `img` (`maix.image.Image`): 将要绘制滑块的目标图像。 | 在指定的图像上绘制滑块。 | 236 | | `handle_event(...)` | `x` (`int`): 触摸点的 X 坐标。
`y` (`int`): 触摸点的 Y 坐标。
`pressed` (`bool\|int`): 触摸屏是否被按下。
`img_w` (`int`): 图像缓冲区的宽度。
`img_h` (`int`): 图像缓冲区的高度。
`disp_w` (`int`): 显示屏的宽度。
`disp_h` (`int`): 显示屏的高度。 | 处理触摸事件并更新滑块状态。 | 237 | 238 | #### `SliderManager` 类 239 | 管理一组滑块的事件处理和绘制。 240 | 241 | ##### 构造函数: `__init__` 242 | | 参数 | 类型 | 描述 | 243 | | :----: | :-----------------------: | :--------------: | 244 | | `ts` | `touchscreen.TouchScreen` | 触摸屏设备实例。**必需**。 | 245 | | `disp` | `display.Display` | 显示设备实例。**必需**。 | 246 | 247 | ##### 方法 (Methods) 248 | | 方法 | 参数 | 描述 | 249 | | :------------------: | :----------------------------------------------: | :--------------------------------: | 250 | | `add_slider(slider)` | `slider` (`Slider`): 要添加的 Slider 实例。 | 向管理器中添加一个滑块。 | 251 | | `handle_events(img)` | `img` (`maix.image.Image`): 绘制滑块的目标图像。 | 处理所有受管滑块的事件并进行绘制。 | 252 | 253 | --- 254 | 255 | ### 3. 开关 (Switch) 256 | 257 | 一个具有“开”和“关”两种状态的切换控件。 258 | 259 | #### 使用方式 260 | 1. 创建一个 `SwitchManager` 实例。 261 | 2. 创建 `Switch` 实例,定义其位置、初始状态和回调函数。 262 | 3. 使用 `manager.add_switch()` 添加开关。 263 | 4. 在主循环中,调用 `manager.handle_events(img)`。 264 | 265 | #### 示例 266 | ```python 267 | from maix import display, camera, app, touchscreen, image 268 | from maixpy_ui import Switch, SwitchManager 269 | import time 270 | 271 | # 1. 初始化硬件 272 | disp = display.Display() 273 | ts = touchscreen.TouchScreen() 274 | cam = camera.Camera() 275 | 276 | # 全局变量,用于存储开关状态 277 | is_light_on = False 278 | 279 | # 2. 定义回调函数 280 | def on_switch_toggle(is_on): 281 | global is_light_on 282 | is_light_on = is_on 283 | status = "ON" if is_on else "OFF" 284 | print(f"Switch toggled. Light is now {status}.") 285 | 286 | # 3. 初始化UI 287 | switch_manager = SwitchManager(ts, disp) 288 | 289 | light_switch = Switch( 290 | position=[280, 190], 291 | scale=2.0, 292 | is_on=is_light_on, 293 | callback=on_switch_toggle 294 | ) 295 | 296 | switch_manager.add_switch(light_switch) 297 | 298 | # 4. 主循环 299 | print("Switch example running. Tap the switch.") 300 | title_color = image.Color.from_rgb(255, 255, 255) 301 | status_on_color = image.Color.from_rgb(30, 200, 30) 302 | status_off_color = image.Color.from_rgb(80, 80, 80) 303 | 304 | while not app.need_exit(): 305 | img = cam.read() 306 | 307 | # 根据开关状态显示一个状态指示灯 308 | status_text = "Light: ON" if is_light_on else "Light: OFF" 309 | status_color = status_on_color if is_light_on else status_off_color 310 | img.draw_string(20, 20, status_text, scale=1.5, color=title_color) 311 | img.draw_rect(310, 280, 50, 50, color=status_color, thickness=-1) 312 | 313 | switch_manager.handle_events(img) 314 | 315 | disp.show(img) 316 | time.sleep(0.02) 317 | ``` 318 | 319 | #### `Switch` 类 320 | 创建一个开关(Switch)组件,用于在开/关两种状态之间切换。 321 | 322 | ##### 构造函数: `__init__` 323 | | 参数 | 类型 | 描述 | 默认值 | 324 | | :----------------------: | :---------------: | :--------------------------------------------------: | :---------------: | 325 | | `position` | `Sequence[int]` | 开关的左上角坐标 `[x, y]`。**必需**。 | - | 326 | | `scale` | `float` | 开关的整体缩放比例。 | `1.0` | 327 | | `is_on` | `bool \| int` | 开关的初始状态,True 为开。 | `False` | 328 | | `callback` | `Callable \| None` | 状态切换时调用的函数,接收一个布尔值参数表示新状态。 | `None` | 329 | | `on_color` | `Sequence[int]` | 开启状态下的背景颜色 (R, G, B)。 | `(30, 200, 30)` | 330 | | `off_color` | `Sequence[int]` | 关闭状态下的背景颜色 (R, G, B)。 | `(100, 100, 100)` | 331 | | `handle_color` | `Sequence[int]` | 手柄的颜色 (R, G, B)。 | `(255, 255, 255)` | 332 | | `handle_pressed_color` | `Sequence[int]` | 按下时手柄的颜色 (R, G, B)。 | `(220, 220, 255)` | 333 | | `handle_radius_increase` | `int` | 按下时手柄半径增加量。 | `2` | 334 | 335 | ##### 方法 (Methods) 336 | | 方法 | 参数 | 描述 | 337 | | :-----------------: | :----------------------------------------------------------: | :------------------------------: | 338 | | `toggle()` | - | 切换开关的状态,并执行回调函数。 | 339 | | `draw(img)` | `img` (`maix.image.Image`): 将要绘制开关的目标图像。 | 在指定的图像上绘制开关。 | 340 | | `handle_event(...)` | `x` (`int`): 触摸点的 X 坐标。
`y` (`int`): 触摸点的 Y 坐标。
`pressed` (`bool\|int`): 触摸屏是否被按下。
`img_w` (`int`): 图像缓冲区的宽度。
`img_h` (`int`): 图像缓冲区的高度。
`disp_w` (`int`): 显示屏的宽度。
`disp_h` (`int`): 显示屏的高度。 | 处理触摸事件并更新开关状态。 | 341 | 342 | #### `SwitchManager` 类 343 | 管理一组开关的事件处理和绘制。 344 | 345 | ##### 构造函数: `__init__` 346 | | 参数 | 类型 | 描述 | 347 | | :----: | :-----------------------: | :--------------: | 348 | | `ts` | `touchscreen.TouchScreen` | 触摸屏设备实例。**必需**。 | 349 | | `disp` | `display.Display` | 显示设备实例。**必需**。 | 350 | 351 | ##### 方法 (Methods) 352 | | 方法 | 参数 | 描述 | 353 | | :------------------: | :----------------------------------------------: | :--------------------------------: | 354 | | `add_switch(switch)` | `switch` (`Switch`): 要添加的 Switch 实例。 | 向管理器中添加一个开关。 | 355 | | `handle_events(img)` | `img` (`maix.image.Image`): 绘制开关的目标图像。 | 处理所有受管开关的事件并进行绘制。 | 356 | 357 | --- 358 | 359 | ### 4. 复选框 (Checkbox) 360 | 361 | 允许用户从一组选项中进行多项选择。 362 | 363 | #### 使用方式 364 | 1. 创建一个 `CheckboxManager` 实例。 365 | 2. 创建多个 `Checkbox` 实例,每个都有独立的回调和状态。 366 | 3. 使用 `manager.add_checkbox()` 添加它们。 367 | 4. 在主循环中,调用 `manager.handle_events(img)`。 368 | 369 | #### 示例 370 | ```python 371 | from maix import display, camera, app, touchscreen, image 372 | from maixpy_ui import Checkbox, CheckboxManager 373 | import time 374 | 375 | # 1. 初始化硬件 376 | disp = display.Display() 377 | ts = touchscreen.TouchScreen() 378 | cam = camera.Camera() 379 | 380 | # 全局字典,用于存储每个复选框的状态 381 | options = {'Checkbox A': True, 'Checkbox B': False} 382 | 383 | # 2. 定义回调函数 (使用闭包来区分是哪个复选框被点击) 384 | def create_checkbox_callback(key): 385 | def on_check_change(is_checked): 386 | options[key] = is_checked 387 | print(f"Option '{key}' is now {'checked' if is_checked else 'unchecked'}.") 388 | return on_check_change 389 | 390 | # 3. 初始化UI 391 | checkbox_manager = CheckboxManager(ts, disp) 392 | 393 | checkbox_a = Checkbox( 394 | position=[80, 150], 395 | label="Checkbox A", 396 | is_checked=options['Checkbox A'], 397 | callback=create_checkbox_callback('Checkbox A'), 398 | scale=2.0 399 | ) 400 | checkbox_b = Checkbox( 401 | position=[80, 300], 402 | label="Checkbox B", 403 | is_checked=options['Checkbox B'], 404 | callback=create_checkbox_callback('Checkbox B'), 405 | scale=2.0 406 | ) 407 | 408 | checkbox_manager.add_checkbox(checkbox_a) 409 | checkbox_manager.add_checkbox(checkbox_b) 410 | 411 | # 4. 主循环 412 | print("Checkbox example running. Tap the checkboxes.") 413 | title_color = image.Color.from_rgb(255, 255, 255) 414 | while not app.need_exit(): 415 | img = cam.read() 416 | 417 | # 显示当前状态 418 | a_status = "ON" if options['Checkbox A'] else "OFF" 419 | b_status = "ON" if options['Checkbox B'] else "OFF" 420 | img.draw_string(20, 20, f"Checkbox A: {a_status}, Checkbox B: {b_status}", scale=1.5, color=title_color) 421 | 422 | checkbox_manager.handle_events(img) 423 | 424 | disp.show(img) 425 | time.sleep(0.02) 426 | ``` 427 | 428 | #### `Checkbox` 类 429 | 创建一个复选框(Checkbox)组件,可独立选中或取消。 430 | 431 | ##### 构造函数: `__init__` 432 | | 参数 | 类型 | 描述 | 默认值 | 433 | | :-----------------: | :---------------: | :--------------------------------------------------: | :---------------: | 434 | | `position` | `Sequence[int]` | 复选框的左上角坐标 `[x, y]`。**必需**。 | - | 435 | | `label` | `str` | 复选框旁边的标签文本。**必需**。 | - | 436 | | `scale` | `float` | 复选框的整体缩放比例。 | `1.0` | 437 | | `is_checked` | `bool \| int` | 复选框的初始状态,True 为选中。 | `False` | 438 | | `callback` | `Callable \| None` | 状态切换时调用的函数,接收一个布尔值参数表示新状态。 | `None` | 439 | | `box_color` | `Sequence[int]` | 未选中时方框的颜色 (R, G, B)。 | `(200, 200, 200)` | 440 | | `box_checked_color` | `Sequence[int]` | 选中时方框的颜色 (R, G, B)。 | `(0, 120, 220)` | 441 | | `check_color` | `Sequence[int]` | 选中标记(对勾)的颜色 (R, G, B)。 | `(255, 255, 255)` | 442 | | `text_color` | `Sequence[int]` | 标签文本的颜色 (R, G, B)。 | `(200, 200, 200)` | 443 | | `box_thickness` | `int` | 方框边框的厚度。 | `2` | 444 | 445 | ##### 方法 (Methods) 446 | | 方法 | 参数 | 描述 | 447 | | :-----------------: | :----------------------------------------------------------: | :--------------------------------: | 448 | | `toggle()` | - | 切换复选框的选中状态,并执行回调。 | 449 | | `draw(img)` | `img` (`maix.image.Image`): 将要绘制复选框的目标图像。 | 在指定的图像上绘制复选框。 | 450 | | `handle_event(...)` | `x` (`int`): 触摸点的 X 坐标。
`y` (`int`): 触摸点的 Y 坐标。
`pressed` (`bool\|int`): 触摸屏是否被按下。
`img_w` (`int`): 图像缓冲区的宽度。
`img_h` (`int`): 图像缓冲区的高度。
`disp_w` (`int`): 显示屏的宽度。
`disp_h` (`int`): 显示屏的高度。 | 处理触摸事件并更新复选框状态。 | 451 | 452 | #### `CheckboxManager` 类 453 | 管理一组复选框的事件处理和绘制。 454 | 455 | ##### 构造函数: `__init__` 456 | | 参数 | 类型 | 描述 | 457 | | :----: | :-----------------------: | :--------------: | 458 | | `ts` | `touchscreen.TouchScreen` | 触摸屏设备实例。**必需**。 | 459 | | `disp` | `display.Display` | 显示设备实例。**必需**。 | 460 | 461 | ##### 方法 (Methods) 462 | | 方法 | 参数 | 描述 | 463 | | :----------------------: | :------------------------------------------------: | :----------------------------------: | 464 | | `add_checkbox(checkbox)` | `checkbox` (`Checkbox`): 要添加的 Checkbox 实例。 | 向管理器中添加一个复选框。 | 465 | | `handle_events(img)` | `img` (`maix.image.Image`): 绘制复选框的目标图像。 | 处理所有受管复选框的事件并进行绘制。 | 466 | 467 | --- 468 | 469 | ### 5. 单选框 (RadioButton) 470 | 471 | 允许用户从一组互斥的选项中只选择一项。 472 | 473 | #### 使用方式 474 | 1. 创建一个 `RadioManager` 实例。**注意**:`RadioManager` 构造时需要接收 `default_value` 和一个全局 `callback`。 475 | 2. 创建 `RadioButton` 实例,每个按钮必须有唯一的 `value`。 476 | 3. 使用 `manager.add_radio()` 添加它们。 477 | 4. 在主循环中,调用 `manager.handle_events(img)`。管理器会自动处理互斥逻辑。 478 | 479 | #### 示例 480 | ```python 481 | from maix import display, camera, app, touchscreen, image 482 | from maixpy_ui import RadioButton, RadioManager 483 | import time 484 | 485 | # 1. 初始化硬件 486 | disp = display.Display() 487 | ts = touchscreen.TouchScreen() 488 | cam = camera.Camera() 489 | 490 | # 全局变量,存储当前选择的模式 491 | current_mode = None 492 | 493 | # 2. 定义回调函数 494 | # 这个回调由 RadioManager 调用,传入被选中项的 value 495 | def on_mode_change(selected_value): 496 | global current_mode 497 | current_mode = selected_value 498 | print(f"Mode changed to: {selected_value}") 499 | 500 | # 3. 初始化UI 501 | # 创建 RadioManager,并传入默认值和回调 502 | radio_manager = RadioManager(ts, disp, 503 | default_value=current_mode, 504 | callback=on_mode_change) 505 | 506 | # 创建三个 RadioButton 实例,注意它们的 value 是唯一的 507 | radio_a = RadioButton(position=[80, 100], label="Mode A", value="Mode A", scale=2.0) 508 | radio_b = RadioButton(position=[80, 200], label="Mode B", value="Mode B", scale=2.0) 509 | radio_c = RadioButton(position=[80, 300], label="Mode C", value="Mode C", scale=2.0) 510 | 511 | # 将它们都添加到管理器中 512 | radio_manager.add_radio(radio_a) 513 | radio_manager.add_radio(radio_b) 514 | radio_manager.add_radio(radio_c) 515 | 516 | # 4. 主循环 517 | print("Radio button example running. Select a mode.") 518 | title_color = image.Color.from_rgb(255, 255, 255) 519 | while not app.need_exit(): 520 | img = cam.read() 521 | 522 | img.draw_string(20, 20, f"Current: {current_mode}", scale=1.8, color=title_color) 523 | 524 | radio_manager.handle_events(img) 525 | 526 | disp.show(img) 527 | time.sleep(0.02) 528 | ``` 529 | 530 | #### `RadioButton` 类 531 | 创建一个单选框(RadioButton)项。通常与 `RadioManager` 结合使用。 532 | 533 | ##### 构造函数: `__init__` 534 | | 参数 | 类型 | 描述 | 默认值 | 535 | | :---------------------: | :-------------: | :-------------------------------: | :---------------: | 536 | | `position` | `Sequence[int]` | 单选框圆圈的左上角坐标 `[x, y]`。**必需**。 | - | 537 | | `label` | `str` | 按钮旁边的标签文本。**必需**。 | - | 538 | | `value` | `any` | 与此单选框关联的唯一值。**必需**。 | - | 539 | | `scale` | `float` | 组件的整体缩放比例。 | `1.0` | 540 | | `circle_color` | `Sequence[int]` | 未选中时圆圈的颜色 (R, G, B)。 | `(200, 200, 200)` | 541 | | `circle_selected_color` | `Sequence[int]` | 选中时圆圈的颜色 (R, G, B)。 | `(0, 120, 220)` | 542 | | `dot_color` | `Sequence[int]` | 选中时中心圆点的颜色 (R, G, B)。 | `(255, 255, 255)` | 543 | | `text_color` | `Sequence[int]` | 标签文本的颜色 (R, G, B)。 | `(200, 200, 200)` | 544 | | `circle_thickness` | `int` | 圆圈边框的厚度。 | `2` | 545 | 546 | ##### 方法 (Methods) 547 | | 方法 | 参数 | 描述 | 548 | | :---------: | :----------------------------------------------------: | :------------------------: | 549 | | `draw(img)` | `img` (`maix.image.Image`): 将要绘制单选框的目标图像。 | 在指定的图像上绘制单选框。 | 550 | 551 | #### `RadioManager` 类 552 | 管理一个单选框组,确保只有一个按钮能被选中。 553 | 554 | ##### 构造函数: `__init__` 555 | | 参数 | 类型 | 描述 | 默认值 | 556 | | :-------------: | :-----------------------: | :------------------------------------------------: | :----: | 557 | | `ts` | `touchscreen.TouchScreen` | 触摸屏设备实例。**必需**。 | - | 558 | | `disp` | `display.Display` | 显示设备实例。**必需**。 | - | 559 | | `default_value` | `any` | 默认选中的按钮的值。 | `None` | 560 | | `callback` | `Callable \| None` | 选中项改变时调用的函数,接收新选中项的值作为参数。 | `None` | 561 | 562 | ##### 方法 (Methods) 563 | | 方法 | 参数 | 描述 | 564 | | :------------------: | :--------------------------------------------------: | :------------------------------: | 565 | | `add_radio(radio)` | `radio` (`RadioButton`): 要添加的 RadioButton 实例。 | 向管理器中添加一个单选框。 | 566 | | `handle_events(img)` | `img` (`maix.image.Image`): 绘制单选框的目标图像。 | 处理所有单选框的事件并进行绘制。 | 567 | 568 | --- 569 | 570 | ### 6. 分辨率适配器 (ResolutionAdapter) 571 | 572 | 一个辅助工具类,用于自动适配不同分辨率的屏幕,以保持UI布局的一致性。 573 | 574 | #### 使用方式 575 | 576 | 1. 创建一个 `ResolutionAdapter` 实例,并指定目标屏幕尺寸和可选的设计基础分辨率。 577 | 2. 基于您的设计基础分辨率,定义组件的原始 `rect`、`position` 等参数。 578 | 3. 调用 `adapter.scale_rect()` 等方法,将原始参数转换为适配后的值。 579 | 4. 使用转换后的值来创建您的UI组件。 580 | 581 | #### 示例 582 | 583 | ```python 584 | from maix import display, camera, app, touchscreen 585 | from maixpy_ui import Button, ButtonManager, ResolutionAdapter 586 | import time 587 | 588 | # 1. 初始化硬件 589 | disp = display.Display() 590 | ts = touchscreen.TouchScreen() 591 | # cam = camera.Camera(640,480) 592 | cam = camera.Camera(320,240) 593 | 594 | # 2. 创建分辨率适配器,并明确指定我们的设计是基于 640x480 的 595 | adapter = ResolutionAdapter( 596 | display_width=cam.width(), 597 | display_height=cam.height(), 598 | base_width=640, 599 | base_height=480 600 | ) 601 | 602 | # 3. 基于 640x480 的画布来定义组件参数 603 | original_rect = [160, 200, 320, 80] 604 | original_font_scale = 3.0 605 | 606 | # 4. 使用适配器转换参数 607 | scaled_rect = adapter.scale_rect(original_rect) 608 | scaled_font_size = adapter.scale_value(original_font_scale) 609 | 610 | # 5. 使用缩放后的值创建组件 611 | btn_manager = ButtonManager(ts, disp) 612 | adapted_button = Button( 613 | rect=scaled_rect, 614 | label="Big Button", 615 | text_scale=scaled_font_size, 616 | callback=lambda: print("Adapted button clicked!") 617 | ) 618 | btn_manager.add_button(adapted_button) 619 | 620 | # 6. 主循环 621 | print("ResolutionAdapter example running (640x480 base).") 622 | while not app.need_exit(): 623 | img = cam.read() 624 | btn_manager.handle_events(img) 625 | disp.show(img) 626 | time.sleep(0.02) 627 | ``` 628 | 629 | #### `ResolutionAdapter` 类 630 | 631 | ##### 构造函数: `__init__` 632 | | 参数 | 类型 | 描述 | 默认值 | 633 | | :--------------: | :---: | :--------------------------: | :----: | 634 | | `display_width` | `int` | 目标显示屏的宽度。**必需**。 | - | 635 | | `display_height` | `int` | 目标显示屏的高度。**必需**。 | - | 636 | | `base_width` | `int` | UI设计的基准宽度。 | `320` | 637 | | `base_height` | `int` | UI设计的基准高度。 | `240` | 638 | 639 | ##### 方法 (Methods) 640 | | 方法 | 参数 | 描述 | 返回值 | 641 | | :-------------------------: | :---------------------------------------------------------: | :--------------------------------: | :-------------: | 642 | | `scale_position(x, y)` | `x` (`int`): 原始 X 坐标。
`y` (`int`): 原始 Y 坐标。 | 缩放一个坐标点 (x, y)。 | `Sequence[int]` | 643 | | `scale_size(width, height)` | `width` (`int`): 原始宽度。
`height` (`int`): 原始高度。 | 缩放一个尺寸 (width, height)。 | `Sequence[int]` | 644 | | `scale_rect(rect)` | `rect` (`list[int]`): 原始矩形 `[x, y, w, h]`。 | 缩放一个矩形。 | `Sequence[int]` | 645 | | `scale_value(value)` | `value` (`int\|float`): 原始数值。 | 缩放一个通用数值,如半径、厚度等。 | `float` | 646 | 647 | ### 7. 页面与 UI 管理器 (Page and UIManager) 648 | 649 | 用于构建多页面应用,并管理页面间的树型导航。 650 | 651 | #### 使用方式 652 | 1. 创建一个全局的 `UIManager` 实例。 653 | 2. 定义继承自 `Page` 的自定义页面类,并在构造函数中为页面命名。 654 | 3. 在父页面中,创建子页面的实例,并使用 `parent.add_child()` 方法来构建页面树。 655 | 4. 使用 `ui_manager.set_root_page()` 设置应用的根页面。 656 | 5. 在页面内部,通过 `self.ui_manager` 调用导航方法,如 `navigate_to_child()`、`navigate_to_parent()`、`navigate_to_root()` 等。 657 | 6. 在主循环中,持续调用 `ui_manager.update(img)` 来驱动当前活动页面的更新和绘制。 658 | 659 | #### 示例 660 | 661 | ```python 662 | from maix import display, camera, app, touchscreen, image 663 | from maixpy_ui import Page, UIManager, Button, ButtonManager 664 | import time 665 | 666 | # -------------------------------------------------------------------------- 667 | # 1. 初始化硬件 & 全局资源 668 | # -------------------------------------------------------------------------- 669 | disp = display.Display() 670 | ts = touchscreen.TouchScreen() 671 | screen_w, screen_h = disp.width(), disp.height() 672 | cam = camera.Camera(screen_w, screen_h) 673 | 674 | # 预创建颜色对象 675 | COLOR_WHITE = image.Color(255, 255, 255) 676 | COLOR_GREY = image.Color(150, 150, 150) 677 | COLOR_GREEN = image.Color(30, 200, 30) 678 | COLOR_BLUE = image.Color(0, 120, 220) 679 | 680 | def get_background(): 681 | if cam: 682 | img = cam.read() 683 | if img: return img 684 | return image.new(size=(screen_w, screen_h), color=(10, 20, 30)) 685 | 686 | # -------------------------------------------------------------------------- 687 | # 2. 定义页面类 688 | # -------------------------------------------------------------------------- 689 | 690 | class BasePage(Page): 691 | """一个包含通用功能的页面基类,例如绘制调试信息""" 692 | def draw_path_info(self, img: image.Image): 693 | """在屏幕右下角绘制当前的导航路径""" 694 | info = self.ui_manager.get_navigation_info() 695 | path_str = " > ".join(info['current_path']) 696 | 697 | # 计算文本尺寸 698 | text_scale = 1.0 699 | text_size = image.string_size(path_str, scale=text_scale) 700 | 701 | # 计算绘制位置(右下角,留出一些边距) 702 | padding = 10 703 | text_x = screen_w - text_size.width() - padding 704 | text_y = screen_h - text_size.height() - padding 705 | 706 | # 绘制文本 707 | img.draw_string(text_x, text_y, path_str, scale=text_scale, color=COLOR_GREY) 708 | 709 | def update(self, img: image.Image): 710 | """子类应该重写此方法,并在末尾调用 super().update(img) 来绘制调试信息""" 711 | self.draw_path_info(img) 712 | 713 | class PageA1(BasePage): 714 | """最深层的页面""" 715 | def __init__(self, ui_manager): 716 | super().__init__(ui_manager, name="page_a1") 717 | self.btn_manager = ButtonManager(ts, disp) 718 | self.btn_manager.add_button(Button([40, 150, 400, 80], "Back to Parent (-> Page A)", lambda: self.ui_manager.navigate_to_parent())) 719 | self.btn_manager.add_button(Button([40, 250, 400, 80], "Go Back in History", lambda: self.ui_manager.go_back())) 720 | self.btn_manager.add_button(Button([40, 350, 400, 80], "Go to Root (Home)", lambda: self.ui_manager.navigate_to_root(), bg_color=COLOR_GREEN)) 721 | 722 | def update(self, img): 723 | img.draw_string(20, 20, "Page A.1 (Deepest)", scale=2.0, color=COLOR_WHITE) 724 | history = self.ui_manager.navigation_history 725 | prev_page_name = history[-1].name if history else "None" 726 | img.draw_string(20, 80, f"'Go Back' will return to '{prev_page_name}'.", scale=1.2, color=COLOR_GREY) 727 | self.btn_manager.handle_events(img) 728 | super().update(img) # 调用基类的方法来绘制路径信息 729 | 730 | class PageA(BasePage): 731 | """中间层页面 A""" 732 | def __init__(self, ui_manager): 733 | super().__init__(ui_manager, name="page_a") 734 | self.btn_manager = ButtonManager(ts, disp) 735 | self.btn_manager.add_button(Button([80, 150, 350, 80], "Go to Page A.1", lambda: self.ui_manager.navigate_to_child("page_a1"))) 736 | self.btn_manager.add_button(Button([20, 400, 250, 80], "Back to Parent", lambda: self.ui_manager.navigate_to_parent())) 737 | self.add_child(PageA1(self.ui_manager)) 738 | 739 | def update(self, img): 740 | img.draw_string(20, 20, "Page A", scale=2.5, color=COLOR_WHITE) 741 | self.btn_manager.handle_events(img) 742 | super().update(img) 743 | 744 | class PageB(BasePage): 745 | """中间层页面 B""" 746 | def __init__(self, ui_manager): 747 | super().__init__(ui_manager, name="page_b") 748 | self.btn_manager = ButtonManager(ts, disp) 749 | self.btn_manager.add_button(Button([80, 150, 350, 80], "Jump to Page A.1 by Path", lambda: self.ui_manager.navigate_to_path(["page_a", "page_a1"]))) 750 | self.btn_manager.add_button(Button([20, 400, 250, 80], "Back to Parent", lambda: self.ui_manager.navigate_to_parent())) 751 | 752 | def update(self, img): 753 | img.draw_string(20, 20, "Page B", scale=2.5, color=COLOR_WHITE) 754 | img.draw_string(20, 80, "From here, we'll jump to A.1.", scale=1.2, color=COLOR_GREY) 755 | img.draw_string(20, 110, "This will make 'Go Back' and 'Back to Parent' different on the next page.", scale=1.2, color=COLOR_GREY) 756 | self.btn_manager.handle_events(img) 757 | super().update(img) 758 | 759 | class RootPage(BasePage): 760 | """根页面""" 761 | def __init__(self, ui_manager): 762 | super().__init__(ui_manager, name="root") 763 | self.btn_manager = ButtonManager(ts, disp) 764 | self.btn_manager.add_button(Button([80, 150, 350, 80], "Path 1: Go to Page A", lambda: self.ui_manager.navigate_to_child("page_a"))) 765 | self.btn_manager.add_button(Button([80, 300, 350, 80], "Path 2: Go to Page B", lambda: self.ui_manager.navigate_to_child("page_b"))) 766 | self.add_child(PageA(self.ui_manager)) 767 | self.add_child(PageB(self.ui_manager)) 768 | 769 | def update(self, img): 770 | img.draw_string(20, 20, "Root Page (Home)", scale=2.5, color=COLOR_WHITE) 771 | img.draw_string(20, 80, "Try both paths to see how 'Go Back' behaves differently.", scale=1.2, color=COLOR_GREY) 772 | self.btn_manager.handle_events(img) 773 | super().update(img) # 调用基类的方法来绘制路径信息 774 | 775 | # -------------------------------------------------------------------------- 776 | # 3. 主程序逻辑 777 | # -------------------------------------------------------------------------- 778 | if __name__ == "__main__": 779 | ui_manager = UIManager() 780 | root_page = RootPage(ui_manager) 781 | ui_manager.set_root_page(root_page) 782 | 783 | print("Navigation demo with persistent path display running.") 784 | 785 | while not app.need_exit(): 786 | img = get_background() 787 | ui_manager.update(img) 788 | disp.show(img) 789 | time.sleep(0.02) 790 | ``` 791 | 792 | #### `Page` 类 793 | 页面(Page)的基类,支持树型父子节点结构。所有具体的UI页面都应继承此类。 794 | 795 | ##### 构造函数: `__init__` 796 | | 参数 | 类型 | 描述 | 默认值 | 797 | | :--------: | :---------: | :-------------------------------------------: | :----: | 798 | | `ui_manager` | `UIManager` | 用于页面导航的 UIManager 实例。**必需**。 | - | 799 | | `name` | `str` | 页面的唯一名称标识符,用于在父页面中查找。 | `""` | 800 | 801 | ##### 方法 (Methods) 802 | | 方法 | 参数 | 描述 | 返回值 | 803 | | :-----------------: | :--------------------------------------------: | :------------------------------------------------------: | :--------------: | 804 | | `add_child(page)` | `page` (`Page`): 要添加的子页面实例。 | 将一个页面添加为当前页面的子节点,以构建页面树。 | - | 805 | | `remove_child(page)` | `page` (`Page`): 要移除的子页面实例。 | 从当前页面移除一个子节点。 | `bool` | 806 | | `get_child(name)` | `name` (`str`): 子页面的名称。 | 根据名称获取子页面,用于自定义导航逻辑。 | `Page \| None` | 807 | | `on_enter()` | - | 当页面进入视图时调用。子类可重写以实现初始化逻辑。 | - | 808 | | `on_exit()` | - | 当页面离开视图时调用。子类可重写以实现清理逻辑。 | - | 809 | | `on_child_enter()` | `child` (`Page`): 进入视图的子页面。 | 当此页面的一个子页面进入视图时调用。父页面可重写。 | - | 810 | | `on_child_exit()` | `child` (`Page`): 离开视图的子页面。 | 当此页面的一个子页面离开视图时调用。父页面可重写。 | - | 811 | | `update(img)` | `img` (`maix.image.Image`): 用于绘制的图像缓冲区。 | 每帧调用的更新和绘制方法。**子类必须重写此方法**。 | - | 812 | 813 | #### `UIManager` 类 814 | UI 管理器,基于树型页面结构提供灵活的导航功能。 815 | 816 | ##### 构造函数: `__init__` 817 | | 参数 | 类型 | 描述 | 默认值 | 818 | | :-------: | :---------: | :--------------------------: | :----: | 819 | | `root_page` | `Page \| None` | 根页面实例,如果为None则需要后续设置。 | `None` | 820 | 821 | ##### 方法 (Methods) 822 | 823 | | 方法 | 参数 | 描述 | 返回值 | 824 | |:-----------------------------:|:------------------------------------------:|:------------------------------------------------------------:|:--------------------:| 825 | | `set_root_page(page)` | `page` (`Page`): 新的根页面实例。 | 设置或重置UI管理器的根页面,并清空历史。 | `None` | 826 | | `get_current_page()` | - | 获取当前活动的页面。 | `Page \| None` | 827 | | `navigate_to_child(name)` | `name` (`str`): 子页面的名称。 | 导航到当前页面的指定名称的子页面。 | `bool` | 828 | | `navigate_to_parent()` | - | 导航到当前页面的父页面。 | `bool` | 829 | | `navigate_to_root()` | - | 直接导航到树的根页面。 | `bool` | 830 | | `navigate_to_path(path)` | `path` (`List[str]`): 从根页面开始的绝对路径。 | 根据绝对路径导航到指定页面。 | `bool` | 831 | | `navigate_to_relative_path(path)` | `path` (`List[str]`): 从当前页面开始的相对路径。 | 根据相对路径导航到指定页面。 | `bool` | 832 | | `navigate_to_page(target_page)` | `target_page` (`Page`): 目标页面实例。 | 直接导航到指定页面。 | `bool` | 833 | | `go_back()` | - | 返回到导航历史记录中的前一个页面。 | `bool` | 834 | | `remove_page(page)` | `page` (`Page`): 要移除的页面实例。 | 移除指定的页面,并从父页面子页面列表中删除。 | `bool` | 835 | | `clear_history()` | - | 清空导航历史记录。 | `None` | 836 | | `get_current_path()` | - | 获取当前页面的完整路径。 | `List[str]` | 837 | | `get_navigation_info()` | - | 获取包含当前路径、历史深度等信息的字典,用于调试或显示。 | `dict` | 838 | | `update(img)` | `img` (`maix.image.Image`): 用于绘制的图像缓冲区。 | 更新当前活动页面的状态。此方法应在主循环中每帧调用。 | `None` | 839 | 840 | --- 841 | 842 | ## ⚖️许可协议 843 | 844 | 本项目基于 **Apache License, Version 2.0** 许可。详细信息请参阅代码文件中的许可证说明。 845 | -------------------------------------------------------------------------------- /examples/color threshold_demo.py: -------------------------------------------------------------------------------- 1 | from maix import camera, image, touchscreen, display 2 | import cv2 3 | import numpy as np 4 | from maixpy_ui import Page, UIManager, Button, ButtonManager, Slider, SliderManager, Switch, SwitchManager, ResolutionAdapter 5 | 6 | class ColorMode: 7 | HSV = 0 8 | LAB = 1 9 | 10 | class ValueMode: 11 | Min = 0 12 | Max = 1 13 | 14 | class MainMenuPage(Page): 15 | """主菜单页面""" 16 | 17 | def __init__(self, ui_manager, ts, disp, name="main_menu"): 18 | super().__init__(ui_manager, name) 19 | 20 | # 使用传入的硬件设备实例 21 | self.ts = ts 22 | self.disp = disp 23 | 24 | # 分辨率适配器 25 | self.adapter = ResolutionAdapter( 26 | self.disp.width(), self.disp.height(), 640, 480) 27 | 28 | # 创建组件管理器 29 | self.button_manager = ButtonManager(self.ts, self.disp) 30 | 31 | # UI组件 32 | self.buttons = {} 33 | 34 | # 初始化UI 35 | self._setup_ui() 36 | 37 | def _setup_ui(self): 38 | """设置UI组件""" 39 | button_height = 80 40 | button_width = 200 41 | start_y = 100 42 | spacing = 20 43 | 44 | # 颜色阈值调整按钮 45 | self.buttons['threshold'] = Button( 46 | rect=self.adapter.scale_rect([220, start_y, button_width, button_height]), 47 | label="Color Threshold", 48 | callback=lambda: self.ui_manager.navigate_to_child("threshold"), 49 | text_scale=1.2 50 | ) 51 | self.button_manager.add_button(self.buttons['threshold']) 52 | 53 | # 设置页面按钮 54 | self.buttons['settings'] = Button( 55 | rect=self.adapter.scale_rect([220, start_y + button_height + spacing, button_width, button_height]), 56 | label="Settings", 57 | callback=lambda: self.ui_manager.navigate_to_child("settings"), 58 | text_scale=1.2 59 | ) 60 | self.button_manager.add_button(self.buttons['settings']) 61 | 62 | # 帮助页面按钮 63 | self.buttons['help'] = Button( 64 | rect=self.adapter.scale_rect([220, start_y + 2*(button_height + spacing), button_width, button_height]), 65 | label="Help", 66 | callback=lambda: self.ui_manager.navigate_to_child("help"), 67 | text_scale=1.2 68 | ) 69 | self.button_manager.add_button(self.buttons['help']) 70 | 71 | def on_enter(self): 72 | """进入主菜单时的处理""" 73 | print("Entered Main Menu") 74 | 75 | def update(self, img_buffer): 76 | """页面更新函数""" 77 | # 创建背景图像 78 | img_buffer.draw_rect(0, 0, 640, 480, image.COLOR_BLACK, -1) 79 | 80 | # 绘制标题 81 | img_buffer.draw_string(200, 30, "Vision Tool Menu", image.COLOR_WHITE, scale=2.0) 82 | 83 | # 显示当前路径信息 84 | path_str = " -> ".join(self.get_path()) if self.get_path() else "Root" 85 | img_buffer.draw_string(10, 10, f"Path: {path_str}", image.COLOR_GRAY, scale=1.0) 86 | 87 | # 处理UI组件 88 | self.button_manager.handle_events(img_buffer) 89 | 90 | # 显示图像 91 | self.disp.show(img_buffer) 92 | 93 | 94 | class SettingsPage(Page): 95 | """设置页面""" 96 | 97 | def __init__(self, ui_manager, ts, disp, name="settings"): 98 | super().__init__(ui_manager, name) 99 | 100 | self.ts = ts 101 | self.disp = disp 102 | 103 | self.adapter = ResolutionAdapter( 104 | self.disp.width(), self.disp.height(), 640, 480) 105 | 106 | self.button_manager = ButtonManager(self.ts, self.disp) 107 | self.slider_manager = SliderManager(self.ts, self.disp) 108 | self.switch_manager = SwitchManager(self.ts, self.disp) 109 | 110 | # 设置状态 111 | self.settings = { 112 | 'brightness': 50, 113 | 'contrast': 50, 114 | 'auto_save': False, 115 | 'debug_mode': False 116 | } 117 | 118 | self.buttons = {} 119 | self.sliders = {} 120 | self.switches = {} 121 | 122 | self._setup_ui() 123 | 124 | def _setup_ui(self): 125 | """设置UI组件""" 126 | # 返回按钮 127 | self.buttons['back'] = Button( 128 | rect=self.adapter.scale_rect([10, 10, 80, 40]), 129 | label="Back", 130 | callback=lambda: self.ui_manager.navigate_to_parent(), 131 | text_scale=1.0 132 | ) 133 | self.button_manager.add_button(self.buttons['back']) 134 | 135 | # 亮度滑动条 136 | self.sliders['brightness'] = Slider( 137 | rect=self.adapter.scale_rect([200, 100, 300, 30]), 138 | scale=self.adapter.scale_value(1.0), 139 | min_val=0, 140 | max_val=100, 141 | default_val=self.settings['brightness'], 142 | callback=lambda value: self._on_brightness_changed(value), 143 | label="Brightness" 144 | ) 145 | self.slider_manager.add_slider(self.sliders['brightness']) 146 | 147 | # 对比度滑动条 148 | self.sliders['contrast'] = Slider( 149 | rect=self.adapter.scale_rect([200, 150, 300, 30]), 150 | scale=self.adapter.scale_value(1.0), 151 | min_val=0, 152 | max_val=100, 153 | default_val=self.settings['contrast'], 154 | callback=lambda value: self._on_contrast_changed(value), 155 | label="Contrast" 156 | ) 157 | self.slider_manager.add_slider(self.sliders['contrast']) 158 | 159 | # 自动保存开关 160 | self.switches['auto_save'] = Switch( 161 | position=self.adapter.scale_position(150, 200), 162 | scale=self.adapter.scale_value(1.0), 163 | is_on=self.settings['auto_save'], 164 | callback=lambda state: self._on_auto_save_changed(state) 165 | ) 166 | self.switch_manager.add_switch(self.switches['auto_save']) 167 | 168 | # 调试模式开关 169 | self.switches['debug'] = Switch( 170 | position=self.adapter.scale_position(150, 250), 171 | scale=self.adapter.scale_value(1.0), 172 | is_on=self.settings['debug_mode'], 173 | callback=lambda state: self._on_debug_changed(state) 174 | ) 175 | self.switch_manager.add_switch(self.switches['debug']) 176 | 177 | def _on_brightness_changed(self, value): 178 | self.settings['brightness'] = value 179 | 180 | def _on_contrast_changed(self, value): 181 | self.settings['contrast'] = value 182 | 183 | def _on_auto_save_changed(self, state): 184 | self.settings['auto_save'] = state 185 | 186 | def _on_debug_changed(self, state): 187 | self.settings['debug_mode'] = state 188 | 189 | def on_enter(self): 190 | print("Entered Settings Page") 191 | 192 | def update(self, img_buffer): 193 | """页面更新函数""" 194 | img_buffer.draw_rect(0, 0, 640, 480, image.COLOR_BLACK, -1) 195 | 196 | # 绘制标题 197 | img_buffer.draw_string(250, 30, "Settings", image.COLOR_WHITE, scale=1.8) 198 | 199 | # 显示当前路径 200 | path_str = " -> ".join(self.get_path()) 201 | img_buffer.draw_string(10, 450, f"Path: {path_str}", image.COLOR_GRAY, scale=0.8) 202 | 203 | # 绘制标签 204 | img_buffer.draw_string(50, 210, "Auto Save:", image.COLOR_WHITE, scale=1.2) 205 | img_buffer.draw_string(50, 260, "Debug Mode:", image.COLOR_WHITE, scale=1.2) 206 | 207 | # 显示当前设置值 208 | img_buffer.draw_string(400, 350, f"Brightness: {self.settings['brightness']}", 209 | image.COLOR_GREEN, scale=1.0) 210 | img_buffer.draw_string(400, 370, f"Contrast: {self.settings['contrast']}", 211 | image.COLOR_GREEN, scale=1.0) 212 | img_buffer.draw_string(400, 390, f"Auto Save: {'ON' if self.settings['auto_save'] else 'OFF'}", 213 | image.COLOR_GREEN, scale=1.0) 214 | img_buffer.draw_string(400, 410, f"Debug: {'ON' if self.settings['debug_mode'] else 'OFF'}", 215 | image.COLOR_GREEN, scale=1.0) 216 | 217 | # 处理UI组件 218 | self.button_manager.handle_events(img_buffer) 219 | self.slider_manager.handle_events(img_buffer) 220 | self.switch_manager.handle_events(img_buffer) 221 | 222 | self.disp.show(img_buffer) 223 | 224 | 225 | class HelpPage(Page): 226 | """帮助页面""" 227 | 228 | def __init__(self, ui_manager, ts, disp, name="help"): 229 | super().__init__(ui_manager, name) 230 | 231 | self.ts = ts 232 | self.disp = disp 233 | 234 | self.adapter = ResolutionAdapter( 235 | self.disp.width(), self.disp.height(), 640, 480) 236 | 237 | self.button_manager = ButtonManager(self.ts, self.disp) 238 | self.buttons = {} 239 | 240 | self._setup_ui() 241 | 242 | def _setup_ui(self): 243 | """设置UI组件""" 244 | # 返回按钮 245 | self.buttons['back'] = Button( 246 | rect=self.adapter.scale_rect([10, 10, 80, 40]), 247 | label="Back", 248 | callback=lambda: self.ui_manager.navigate_to_parent(), 249 | text_scale=1.0 250 | ) 251 | self.button_manager.add_button(self.buttons['back']) 252 | 253 | # 返回主菜单按钮 254 | self.buttons['home'] = Button( 255 | rect=self.adapter.scale_rect([100, 10, 80, 40]), 256 | label="Home", 257 | callback=lambda: self.ui_manager.navigate_to_root(), 258 | text_scale=1.0 259 | ) 260 | self.button_manager.add_button(self.buttons['home']) 261 | 262 | def on_enter(self): 263 | print("Entered Help Page") 264 | 265 | def update(self, img_buffer): 266 | """页面更新函数""" 267 | img_buffer.draw_rect(0, 0, 640, 480, image.COLOR_BLACK, -1) 268 | 269 | # 绘制标题 270 | img_buffer.draw_string(280, 30, "Help", image.COLOR_WHITE, scale=1.8) 271 | 272 | # 显示当前路径 273 | path_str = " -> ".join(self.get_path()) 274 | img_buffer.draw_string(10, 450, f"Path: {path_str}", image.COLOR_GRAY, scale=0.8) 275 | 276 | # 显示深度信息 277 | img_buffer.draw_string(10, 430, f"Depth: {self.get_depth()}", image.COLOR_GRAY, scale=0.8) 278 | 279 | # 帮助内容 280 | help_text = [ 281 | "Vision Tool Help", 282 | "", 283 | "Color Threshold:", 284 | "- Switch between LAB/HSV modes", 285 | "- Adjust min/max values for each channel", 286 | "- Toggle binary view", 287 | "", 288 | "Settings:", 289 | "- Adjust brightness and contrast", 290 | "- Enable/disable auto save", 291 | "- Toggle debug mode", 292 | "", 293 | "Navigation:", 294 | "- Use Back button to return", 295 | "- Use Home button to go to main menu" 296 | ] 297 | 298 | y_pos = 80 299 | for line in help_text: 300 | if line == "": 301 | y_pos += 10 302 | else: 303 | scale = 1.2 if line.endswith(":") else 1.0 304 | color = image.COLOR_YELLOW if line.endswith(":") else image.COLOR_WHITE 305 | img_buffer.draw_string(50, y_pos, line, color, scale=scale) 306 | y_pos += 20 307 | 308 | # 处理UI组件 309 | self.button_manager.handle_events(img_buffer) 310 | 311 | self.disp.show(img_buffer) 312 | 313 | 314 | class ColorThresholdPage(Page): 315 | """颜色阈值调整页面""" 316 | 317 | def __init__(self, ui_manager, ts, disp, name="threshold"): 318 | super().__init__(ui_manager, name) 319 | 320 | # 初始化硬件设备 - 只有摄像头需要在这个页面单独创建 321 | self.cam = camera.Camera(640, 480) 322 | self.ts = ts 323 | self.disp = disp 324 | 325 | # 分辨率适配器 326 | self.adapter = ResolutionAdapter( 327 | self.disp.width(), self.disp.height(), 640, 480) 328 | 329 | # 创建组件管理器 330 | self.button_manager = ButtonManager(self.ts, self.disp) 331 | self.slider_manager = SliderManager(self.ts, self.disp) 332 | self.switch_manager = SwitchManager(self.ts, self.disp) 333 | 334 | # 应用状态 335 | self.context = { 336 | 'color_mode': ColorMode.LAB, 337 | 'value_mode': ValueMode.Min, 338 | 'current_ch': 1, 339 | 'disp_binary': False, 340 | 'threshold_lab': [0, 100, -128, 127, -128, 127], 341 | 'threshold_hsv': [0, 180, 0, 255, 0, 255] 342 | } 343 | 344 | # UI组件 345 | self.buttons = {} 346 | self.sliders = {} 347 | self.switches = {} 348 | 349 | # 初始化UI 350 | self._setup_ui() 351 | 352 | def _setup_ui(self): 353 | """设置UI组件""" 354 | # 按钮尺寸计算 355 | button_height = 480 // 7 # 为返回按钮留出空间 356 | button_width = 80 357 | 358 | # 返回按钮 359 | self.buttons['back'] = Button( 360 | rect=self.adapter.scale_rect([0, 0, button_width, button_height//2]), 361 | label="Back", 362 | callback=lambda: self.ui_manager.navigate_to_parent(), 363 | text_scale=0.8 364 | ) 365 | self.button_manager.add_button(self.buttons['back']) 366 | 367 | # 模式切换按钮 368 | self.buttons['mode'] = Button( 369 | rect=self.adapter.scale_rect([0, button_height//2, button_width, button_height]), 370 | label="LAB", 371 | callback=lambda: self._on_mode_button_pressed(), 372 | text_scale=1.0 373 | ) 374 | self.button_manager.add_button(self.buttons['mode']) 375 | 376 | # 通道按钮 377 | self.buttons['ch1'] = Button( 378 | rect=self.adapter.scale_rect([0, button_height//2 + button_height, button_width, button_height]), 379 | label="L Min", 380 | callback=lambda: self._on_ch1_button_pressed(), 381 | text_scale=1.0 382 | ) 383 | self.button_manager.add_button(self.buttons['ch1']) 384 | 385 | self.buttons['ch2'] = Button( 386 | rect=self.adapter.scale_rect([0, button_height//2 + 2*button_height, button_width, button_height]), 387 | label="A Min", 388 | callback=lambda: self._on_ch2_button_pressed(), 389 | text_scale=1.0 390 | ) 391 | self.button_manager.add_button(self.buttons['ch2']) 392 | 393 | self.buttons['ch3'] = Button( 394 | rect=self.adapter.scale_rect([0, button_height//2 + 3*button_height, button_width, button_height]), 395 | label="B Min", 396 | callback=lambda: self._on_ch3_button_pressed(), 397 | text_scale=1.0 398 | ) 399 | self.button_manager.add_button(self.buttons['ch3']) 400 | 401 | # 二值化开关 402 | self.switches['binary'] = Switch( 403 | position=self.adapter.scale_position(5, button_height//2 + 4*button_height + 15), 404 | scale=self.adapter.scale_value(1.0), 405 | callback=lambda state: self._on_binary_switch_changed(state) 406 | ) 407 | self.switch_manager.add_switch(self.switches['binary']) 408 | 409 | # 滑动条 410 | self.sliders['threshold'] = Slider( 411 | rect=self.adapter.scale_rect([100, 420, 400, 40]), 412 | scale=self.adapter.scale_value(1.0), 413 | min_val=0, 414 | max_val=100, 415 | default_val=0, 416 | callback=lambda value: self._on_slider_changed(value), 417 | label="L Min" 418 | ) 419 | self.slider_manager.add_slider(self.sliders['threshold']) 420 | 421 | def _on_mode_button_pressed(self): 422 | """模式按钮回调""" 423 | if self.context['color_mode'] == ColorMode.LAB: 424 | self.context['color_mode'] = ColorMode.HSV 425 | self.buttons['mode'].label = 'HSV' 426 | self.buttons['ch1'].label = 'H Min' 427 | self.buttons['ch2'].label = 'S Min' 428 | self.buttons['ch3'].label = 'V Min' 429 | self.sliders['threshold'].label = 'H Min' 430 | else: 431 | self.context['color_mode'] = ColorMode.LAB 432 | self.buttons['mode'].label = 'LAB' 433 | self.buttons['ch1'].label = 'L Min' 434 | self.buttons['ch2'].label = 'A Min' 435 | self.buttons['ch3'].label = 'B Min' 436 | self.sliders['threshold'].label = 'L Min' 437 | 438 | self.context['current_ch'] = 1 439 | self.context['value_mode'] = ValueMode.Min 440 | self._update_slider_value() 441 | 442 | def _on_ch1_button_pressed(self): 443 | """通道1按钮回调""" 444 | self.context['current_ch'] = 1 445 | if self.context['value_mode'] == ValueMode.Min: 446 | self.context['value_mode'] = ValueMode.Max 447 | if self.context['color_mode'] == ColorMode.LAB: 448 | self.buttons['ch1'].label = 'L Max' 449 | self.sliders['threshold'].label = 'L Max' 450 | else: 451 | self.buttons['ch1'].label = 'H Max' 452 | self.sliders['threshold'].label = 'H Max' 453 | else: 454 | self.context['value_mode'] = ValueMode.Min 455 | if self.context['color_mode'] == ColorMode.LAB: 456 | self.buttons['ch1'].label = 'L Min' 457 | self.sliders['threshold'].label = 'L Min' 458 | else: 459 | self.buttons['ch1'].label = 'H Min' 460 | self.sliders['threshold'].label = 'H Min' 461 | self._update_slider_value() 462 | 463 | def _on_ch2_button_pressed(self): 464 | """通道2按钮回调""" 465 | self.context['current_ch'] = 2 466 | if self.context['value_mode'] == ValueMode.Min: 467 | self.context['value_mode'] = ValueMode.Max 468 | if self.context['color_mode'] == ColorMode.LAB: 469 | self.buttons['ch2'].label = 'A Max' 470 | self.sliders['threshold'].label = 'A Max' 471 | else: 472 | self.buttons['ch2'].label = 'S Max' 473 | self.sliders['threshold'].label = 'S Max' 474 | else: 475 | self.context['value_mode'] = ValueMode.Min 476 | if self.context['color_mode'] == ColorMode.LAB: 477 | self.buttons['ch2'].label = 'A Min' 478 | self.sliders['threshold'].label = 'A Min' 479 | else: 480 | self.buttons['ch2'].label = 'S Min' 481 | self.sliders['threshold'].label = 'S Min' 482 | self._update_slider_value() 483 | 484 | def _on_ch3_button_pressed(self): 485 | """通道3按钮回调""" 486 | self.context['current_ch'] = 3 487 | if self.context['value_mode'] == ValueMode.Min: 488 | self.context['value_mode'] = ValueMode.Max 489 | if self.context['color_mode'] == ColorMode.LAB: 490 | self.buttons['ch3'].label = 'B Max' 491 | self.sliders['threshold'].label = 'B Max' 492 | else: 493 | self.buttons['ch3'].label = 'V Max' 494 | self.sliders['threshold'].label = 'V Max' 495 | else: 496 | self.context['value_mode'] = ValueMode.Min 497 | if self.context['color_mode'] == ColorMode.LAB: 498 | self.buttons['ch3'].label = 'B Min' 499 | self.sliders['threshold'].label = 'B Min' 500 | else: 501 | self.buttons['ch3'].label = 'V Min' 502 | self.sliders['threshold'].label = 'V Min' 503 | self._update_slider_value() 504 | 505 | def _on_binary_switch_changed(self, state): 506 | """二值化开关回调""" 507 | self.context['disp_binary'] = state 508 | 509 | def _on_slider_changed(self, value): 510 | """滑动条回调""" 511 | if self.context['color_mode'] == ColorMode.LAB: 512 | if self.context['value_mode'] == ValueMode.Min: 513 | if self.context['current_ch'] == 1: 514 | self.context['threshold_lab'][0] = value 515 | elif self.context['current_ch'] == 2: 516 | self.context['threshold_lab'][2] = int(-128 + value * 255 / 100) 517 | elif self.context['current_ch'] == 3: 518 | self.context['threshold_lab'][4] = int(-128 + value * 255 / 100) 519 | else: 520 | if self.context['current_ch'] == 1: 521 | self.context['threshold_lab'][1] = value 522 | elif self.context['current_ch'] == 2: 523 | self.context['threshold_lab'][3] = int(-128 + value * 255 / 100) 524 | elif self.context['current_ch'] == 3: 525 | self.context['threshold_lab'][5] = int(-128 + value * 255 / 100) 526 | else: 527 | if self.context['value_mode'] == ValueMode.Min: 528 | if self.context['current_ch'] == 1: 529 | self.context['threshold_hsv'][0] = int(value / 100 * 180) 530 | elif self.context['current_ch'] == 2: 531 | self.context['threshold_hsv'][2] = int(value / 100 * 255) 532 | elif self.context['current_ch'] == 3: 533 | self.context['threshold_hsv'][4] = int(value / 100 * 255) 534 | else: 535 | if self.context['current_ch'] == 1: 536 | self.context['threshold_hsv'][1] = int(value / 100 * 180) 537 | elif self.context['current_ch'] == 2: 538 | self.context['threshold_hsv'][3] = int(value / 100 * 255) 539 | elif self.context['current_ch'] == 3: 540 | self.context['threshold_hsv'][5] = int(value / 100 * 255) 541 | 542 | def _update_slider_value(self): 543 | """更新滑动条的值""" 544 | if self.context['color_mode'] == ColorMode.LAB: 545 | if self.context['value_mode'] == ValueMode.Min: 546 | if self.context['current_ch'] == 1: 547 | self.sliders['threshold'].value = self.context['threshold_lab'][0] 548 | elif self.context['current_ch'] == 2: 549 | self.sliders['threshold'].value = int((self.context['threshold_lab'][2] + 128) / 255 * 100) 550 | elif self.context['current_ch'] == 3: 551 | self.sliders['threshold'].value = int((self.context['threshold_lab'][4] + 128) / 255 * 100) 552 | else: 553 | if self.context['current_ch'] == 1: 554 | self.sliders['threshold'].value = self.context['threshold_lab'][1] 555 | elif self.context['current_ch'] == 2: 556 | self.sliders['threshold'].value = int((self.context['threshold_lab'][3] + 128) / 255 * 100) 557 | elif self.context['current_ch'] == 3: 558 | self.sliders['threshold'].value = int((self.context['threshold_lab'][5] + 128) / 255 * 100) 559 | else: 560 | if self.context['value_mode'] == ValueMode.Min: 561 | if self.context['current_ch'] == 1: 562 | self.sliders['threshold'].value = int(self.context['threshold_hsv'][0] / 180 * 100) 563 | elif self.context['current_ch'] == 2: 564 | self.sliders['threshold'].value = int(self.context['threshold_hsv'][2] / 255 * 100) 565 | elif self.context['current_ch'] == 3: 566 | self.sliders['threshold'].value = int(self.context['threshold_hsv'][4] / 255 * 100) 567 | else: 568 | if self.context['current_ch'] == 1: 569 | self.sliders['threshold'].value = int(self.context['threshold_hsv'][1] / 180 * 100) 570 | elif self.context['current_ch'] == 2: 571 | self.sliders['threshold'].value = int(self.context['threshold_hsv'][3] / 255 * 100) 572 | elif self.context['current_ch'] == 3: 573 | self.sliders['threshold'].value = int(self.context['threshold_hsv'][5] / 255 * 100) 574 | 575 | def rgb_to_hsv(self, r, g, b): 576 | """RGB转HSV""" 577 | bgr_pixel = np.uint8([[[b, g, r]]]) 578 | hsv_pixel = cv2.cvtColor(bgr_pixel, cv2.COLOR_BGR2HSV) 579 | h, s, v = hsv_pixel[0][0] 580 | return h, s, v 581 | 582 | def rgb_to_lab(self, r, g, b): 583 | """RGB转LAB""" 584 | bgr_pixel = np.uint8([[[b, g, r]]]) 585 | lab_pixel = cv2.cvtColor(bgr_pixel, cv2.COLOR_BGR2LAB) 586 | l, a, b = lab_pixel[0][0] 587 | l = int(l * (100 / 255)) 588 | a = a - 128 589 | b = b - 128 590 | return l, a, b 591 | 592 | def on_enter(self): 593 | """进入页面时的处理""" 594 | print("Entered Color Threshold Page") 595 | 596 | def on_exit(self): 597 | """退出页面时的处理""" 598 | print("Exited Color Threshold Page") 599 | 600 | def update(self, img_buffer): 601 | """页面更新函数""" 602 | # 1. 获取摄像头图像 603 | img = self.cam.read() 604 | 605 | # 2. 根据颜色模式进行阈值处理 606 | if self.context['color_mode'] == ColorMode.LAB: 607 | # LAB模式处理 608 | blobs = img.find_blobs( 609 | thresholds=[self.context['threshold_lab']], 610 | pixels_threshold=500 611 | ) 612 | for blob in blobs: 613 | img.draw_rect(blob[0], blob[1], blob[2], blob[3], image.COLOR_BLUE) 614 | 615 | if self.context['disp_binary']: 616 | img = img.binary([self.context['threshold_lab']]) 617 | else: 618 | # HSV模式处理 619 | frame = image.image2cv(img, ensure_bgr=False, copy=False) 620 | hsv = cv2.cvtColor(frame, cv2.COLOR_RGB2HSV) 621 | 622 | lower = np.array([ 623 | self.context['threshold_hsv'][0], 624 | self.context['threshold_hsv'][2], 625 | self.context['threshold_hsv'][4] 626 | ]) 627 | upper = np.array([ 628 | self.context['threshold_hsv'][1], 629 | self.context['threshold_hsv'][3], 630 | self.context['threshold_hsv'][5] 631 | ]) 632 | mask = cv2.inRange(hsv, lower, upper) 633 | 634 | contours = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[-2] 635 | if len(contours) > 0: 636 | c = max(contours, key=cv2.contourArea) 637 | x, y, w, h = cv2.boundingRect(c) 638 | cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 0, 0), 2) 639 | 640 | if self.context['disp_binary']: 641 | img = image.cv2image(mask, bgr=False, copy=False) 642 | else: 643 | img = image.cv2image(frame, bgr=False, copy=False) 644 | 645 | # 3. 显示颜色信息(如果不是二值化模式) 646 | if not self.context['disp_binary']: 647 | pixel_x = 640 // 2 648 | pixel_y = 480 // 2 649 | pixel = img.get_pixel(pixel_x, pixel_y, True) 650 | 651 | if self.context['color_mode'] == ColorMode.LAB: 652 | value_l, value_a, value_b = self.rgb_to_lab(pixel[0], pixel[1], pixel[2]) 653 | img.draw_string( 654 | 100, 10, 655 | 'Color:{:4d},{:4d},{:4d}'.format(value_l, value_a, value_b), 656 | image.COLOR_BLUE, 657 | scale=1.2 658 | ) 659 | img.draw_string( 660 | 100, 30, 661 | 'Thresh:{:3d},{:3d},{:3d},{:3d},{:3d},{:3d}'.format( 662 | self.context['threshold_lab'][0], 663 | self.context['threshold_lab'][1], 664 | self.context['threshold_lab'][2], 665 | self.context['threshold_lab'][3], 666 | self.context['threshold_lab'][4], 667 | self.context['threshold_lab'][5] 668 | ), 669 | image.COLOR_BLUE, 670 | scale=1.0 671 | ) 672 | else: 673 | value_h, value_s, value_v = self.rgb_to_hsv(pixel[0], pixel[1], pixel[2]) 674 | img.draw_string( 675 | 100, 10, 676 | 'Color:{:4d},{:4d},{:4d}'.format(value_h, value_s, value_v), 677 | image.COLOR_BLUE, 678 | scale=1.2 679 | ) 680 | img.draw_string( 681 | 100, 30, 682 | 'Thresh:{:3d},{:3d},{:3d},{:3d},{:3d},{:3d}'.format( 683 | self.context['threshold_hsv'][0], 684 | self.context['threshold_hsv'][1], 685 | self.context['threshold_hsv'][2], 686 | self.context['threshold_hsv'][3], 687 | self.context['threshold_hsv'][4], 688 | self.context['threshold_hsv'][5] 689 | ), 690 | image.COLOR_BLUE, 691 | scale=1.0 692 | ) 693 | 694 | img.draw_cross(pixel_x, pixel_y, image.COLOR_BLUE, 8) 695 | 696 | # 4. 绘制导航信息 697 | path_str = " -> ".join(self.get_path()) 698 | img.draw_string(100, 460, f"Path: {path_str}", image.COLOR_GRAY, scale=0.8) 699 | 700 | # 5. 处理UI组件事件和绘制 701 | self.button_manager.handle_events(img) 702 | self.slider_manager.handle_events(img) 703 | self.switch_manager.handle_events(img) 704 | 705 | # 6. 显示图像 706 | self.disp.show(img) 707 | 708 | 709 | def main(): 710 | """主函数""" 711 | print("=== Vision Tool Started ===") 712 | print("Initializing hardware...") 713 | 714 | try: 715 | # 首先初始化硬件设备(单例模式) 716 | ts = touchscreen.TouchScreen() 717 | disp = display.Display() 718 | print(f"Display initialized: {disp.width()}x{disp.height()}") 719 | 720 | # 创建UI管理器 721 | ui_manager = UIManager() 722 | 723 | # 创建主菜单页面(根页面) 724 | main_menu = MainMenuPage(ui_manager, ts, disp, "main_menu") 725 | 726 | # 创建子页面 727 | threshold_page = ColorThresholdPage(ui_manager, ts, disp, "threshold") 728 | settings_page = SettingsPage(ui_manager, ts, disp, "settings") 729 | help_page = HelpPage(ui_manager, ts, disp, "help") 730 | 731 | # 建立页面树型结构 732 | main_menu.add_child(threshold_page) 733 | main_menu.add_child(settings_page) 734 | main_menu.add_child(help_page) 735 | 736 | # 设置根页面 737 | ui_manager.set_root_page(main_menu) 738 | 739 | print("Tree Structure:") 740 | print("Main Menu (Root)") 741 | print("├── Color Threshold") 742 | print("├── Settings") 743 | print("└── Help") 744 | print("\nStarting main loop...") 745 | 746 | # 主循环 747 | while True: 748 | img_buffer = image.Image(640, 480) 749 | ui_manager.update(img_buffer) 750 | 751 | except Exception as e: 752 | print(f"Error: {e}") 753 | print("Hardware initialization failed, check device connections") 754 | except KeyboardInterrupt: 755 | print("\nProgram exited") 756 | print("Navigation demo completed") 757 | 758 | 759 | if __name__ == '__main__': 760 | main() 761 | -------------------------------------------------------------------------------- /examples/demo.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Aristore' 3 | 4 | from maix import display, camera, app, image, touchscreen 5 | from maixpy_ui import (Button, ButtonManager, Slider, SliderManager, 6 | Switch, SwitchManager, Checkbox, CheckboxManager, 7 | RadioButton, RadioManager, ResolutionAdapter, 8 | Page, UIManager) 9 | 10 | # ========================================================== 11 | # 1. 全局设置和状态 12 | # ========================================================== 13 | print("Starting comprehensive UI demo...") 14 | 15 | disp = display.Display() 16 | ts = touchscreen.TouchScreen() 17 | cam = camera.Camera(640, 480) 18 | # cam = camera.Camera(320,240) # demo 是为 640*480 分辨率准备的,将上一行注释并取消本行注释即可体验 ResolutionAdapter 的效果 19 | 20 | # 创建分辨率适配器 21 | # 它可以将我们在一个固定设计分辨率(如此处的640x480)下定义的UI坐标和尺寸 22 | # 自动缩放到摄像头的实际输出分辨率,确保UI在不同分辨率下都能正确显示。 23 | disp_w, disp_h = cam.width(), cam.height() 24 | adapter = ResolutionAdapter(disp_w, disp_h) 25 | 26 | # 全局颜色常量 27 | C_WHITE = (255, 255, 255); C_BLACK = (0, 0, 0); C_RED = (200, 30, 30) 28 | C_BLUE = (0, 120, 220); C_GREEN = (30, 200, 30); C_YELLOW = (220, 220, 30) 29 | C_GRAY = (100, 100, 100); C_STATUS_ON = (30, 200, 30); C_STATUS_OFF = (80, 80, 80) 30 | 31 | # 全局应用状态字典 32 | app_state = { 33 | 'slider_color': 128, 'radio_choice': 'B', 34 | 'checkboxes': {'A': False, 'B': True, 'C': False}, 35 | 'switches': {'small': False, 'medium': True, 'large': False} 36 | } 37 | 38 | # ========================================================== 39 | # 2. 回调函数和UI组件初始化 40 | # ========================================================== 41 | # 创建所有UI组件实例和它们被操作时触发的回调函数。 42 | # 注意:此时按钮的回调函数(callback)暂时都设为 None,我们将在第4部分统一设置导航逻辑。 43 | 44 | # --- 定义回调函数 --- 45 | def slider_callback(value): app_state['slider_color'] = value 46 | def radio_callback(value): app_state['radio_choice'] = value 47 | # 使用闭包为每个复选框/开关创建独立的回调函数 48 | def create_checkbox_callback(key): 49 | def inner_callback(is_checked): app_state['checkboxes'][key] = is_checked; print(f"Checkbox '{key}' state: {is_checked}") 50 | return inner_callback 51 | def create_switch_callback(key): 52 | def inner_callback(is_on): app_state['switches'][key] = is_on; print(f"Switch '{key}' state: {is_on}") 53 | return inner_callback 54 | 55 | # --- 创建UI组件和管理器 --- 56 | # 全局返回按钮,所有子页面都会共用这一个实例 57 | back_button = Button(rect=adapter.scale_rect([0, 0, 30, 30]), label='<', callback=None, bg_color=C_BLACK, pressed_color=None, text_color=C_WHITE, border_thickness=0, text_scale=adapter.scale_value(1.0)) 58 | 59 | # 主页的按钮管理器和按钮 60 | home_btn_manager = ButtonManager(ts, disp) 61 | home_btn_manager.add_button(Button(rect=adapter.scale_rect([30, 30, 120, 80]), label="Switch", callback=None, border_color=C_WHITE, text_color=C_WHITE, border_thickness=int(adapter.scale_value(2)), text_scale=adapter.scale_value(0.8))) 62 | home_btn_manager.add_button(Button(rect=adapter.scale_rect([170, 30, 120, 80]), label="Slider", callback=None, bg_color=C_RED, pressed_color=C_BLUE, border_thickness=0, text_scale=adapter.scale_value(1.0))) 63 | home_btn_manager.add_button(Button(rect=adapter.scale_rect([30, 130, 120, 80]), label="Radio", callback=None, bg_color=C_YELLOW, pressed_color=C_GRAY, border_color=C_GREEN, border_thickness=int(adapter.scale_value(2)), text_scale=adapter.scale_value(1.2))) 64 | home_btn_manager.add_button(Button(rect=adapter.scale_rect([170, 130, 120, 80]), label="Checkbox", callback=None, bg_color=C_GREEN, pressed_color=C_GRAY, border_color=C_YELLOW, border_thickness=int(adapter.scale_value(2)), text_scale=adapter.scale_value(1.4))) 65 | 66 | # 各个子页面的UI管理器 67 | switch_page_manager = SwitchManager(ts, disp) 68 | switch_page_manager.add_switch(Switch(position=adapter.scale_position(40, 50), scale=adapter.scale_value(0.8), is_on=app_state['switches']['small'], callback=create_switch_callback('small'))) 69 | switch_page_manager.add_switch(Switch(position=adapter.scale_position(40, 100), scale=adapter.scale_value(1.0), is_on=app_state['switches']['medium'], callback=create_switch_callback('medium'))) 70 | switch_page_manager.add_switch(Switch(position=adapter.scale_position(40, 160), scale=adapter.scale_value(1.5), is_on=app_state['switches']['large'], callback=create_switch_callback('large'))) 71 | 72 | slider_page_manager = SliderManager(ts, disp) 73 | slider_page_manager.add_slider(Slider(rect=adapter.scale_rect([60, 130, 200, 20]), label="Color Value", default_val=app_state['slider_color'], scale=adapter.scale_value(1.0), min_val=0, max_val=255, callback=slider_callback)) 74 | 75 | radio_page_manager = RadioManager(ts, disp, default_value=app_state['radio_choice'], callback=radio_callback) 76 | radio_page_manager.add_radio(RadioButton(position=adapter.scale_position(40, 60), label="Option A", value="A", scale=adapter.scale_value(1.0))) 77 | radio_page_manager.add_radio(RadioButton(position=adapter.scale_position(40, 110), label="Option B", value="B", scale=adapter.scale_value(1.0))) 78 | radio_page_manager.add_radio(RadioButton(position=adapter.scale_position(40, 160), label="Option C", value="C", scale=adapter.scale_value(1.0))) 79 | 80 | checkbox_page_manager = CheckboxManager(ts, disp) 81 | checkbox_page_manager.add_checkbox(Checkbox(position=adapter.scale_position(40, 50), label="Small", scale=adapter.scale_value(0.8), is_checked=app_state['checkboxes']['A'], callback=create_checkbox_callback('A'))) 82 | checkbox_page_manager.add_checkbox(Checkbox(position=adapter.scale_position(40, 100), label="Medium", scale=adapter.scale_value(1.0), is_checked=app_state['checkboxes']['B'], callback=create_checkbox_callback('B'))) 83 | checkbox_page_manager.add_checkbox(Checkbox(position=adapter.scale_position(40, 160), label="Large", scale=adapter.scale_value(1.5), is_checked=app_state['checkboxes']['C'], callback=create_checkbox_callback('C'))) 84 | 85 | # 预创建颜色对象,可以轻微提升绘制性能 86 | title_color = image.Color.from_rgb(*C_WHITE) 87 | status_on_color = image.Color.from_rgb(*C_STATUS_ON) 88 | status_off_color = image.Color.from_rgb(*C_STATUS_OFF) 89 | 90 | # ========================================================== 91 | # 3. 定义页面类 92 | # ========================================================== 93 | # 这里的页面类都继承自 maixpy_ui.Page。 94 | 95 | # --- 创建一个通用的“子页面”基类 --- 96 | # 这个类的目的是为了代码复用, 97 | # 它继承自 maixpy_ui.Page 并添加了处理“返回”按钮的通用逻辑。 98 | # 所有需要“返回”按钮的页面都可以继承它,而无需重复编写代码。 99 | class SubPage(Page): 100 | """一个通用的子页面基类,它自动处理“返回”按钮的逻辑。""" 101 | def __init__(self, ui_manager: UIManager, name: str): 102 | # 必须调用父类的 __init__ 方法,传递 ui_manager 和页面名称 103 | super().__init__(ui_manager, name) 104 | 105 | def handle_back_button(self, img): 106 | """处理和绘制全局的“返回”按钮。""" 107 | x_touch, y_touch, pressed_touch = ts.read() 108 | img_w, img_h = img.width(), img.height() 109 | disp_w_actual, disp_h_actual = disp.width(), disp.height() 110 | back_button.handle_event(x_touch, y_touch, pressed_touch, img_w, img_h, disp_w_actual, disp_h_actual) 111 | back_button.draw(img) 112 | 113 | # --- 主页 --- 114 | class HomePage(Page): 115 | """主页。作为所有其他页面的父节点。""" 116 | def __init__(self, ui_manager: UIManager, name: str): 117 | super().__init__(ui_manager, name) 118 | 119 | def update(self, img): 120 | """主页的绘制和事件处理。""" 121 | x, y = adapter.scale_position(10, 5) 122 | img.draw_string(x, y, "UI Demo Home", scale=adapter.scale_value(1.5), color=title_color) 123 | # 调用主页专属的按钮管理器,让它处理按钮的事件和绘制 124 | home_btn_manager.handle_events(img) 125 | 126 | # --- 开关页面 --- 127 | class SwitchPage(SubPage): 128 | """开关(Switch)组件的演示页面。""" 129 | def __init__(self, ui_manager: UIManager, name: str): 130 | super().__init__(ui_manager, name) 131 | 132 | def update(self, img): 133 | # 首先,调用基类方法来处理通用的返回按钮 134 | self.handle_back_button(img) 135 | # 接着,绘制此页面的特定内容 136 | x, y = adapter.scale_position(40, 5) 137 | img.draw_string(x, y, "Switch Demo", scale=adapter.scale_value(1.5), color=title_color) 138 | x_status, y_status = adapter.scale_position(220, 30) 139 | img.draw_string(x_status, y_status, "Status", scale=adapter.scale_value(1.0), color=title_color) 140 | colors = [status_on_color if app_state['switches'][k] else status_off_color for k in ['small', 'medium', 'large']] 141 | rects = [adapter.scale_rect(r) for r in [[220, 50, 30, 20], [220, 100, 30, 20], [220, 160, 30, 20]]] 142 | for r, c in zip(rects, colors): img.draw_rect(*r, color=c, thickness=-1) 143 | # 最后,调用此页面专属的 SwitchManager 144 | switch_page_manager.handle_events(img) 145 | 146 | # --- 其他子页面定义(与SwitchPage结构类似,保持简洁)--- 147 | class SliderPage(SubPage): 148 | def __init__(self, ui_manager: UIManager, name: str): super().__init__(ui_manager, name) 149 | def update(self, img): 150 | self.handle_back_button(img) 151 | img.draw_string(*adapter.scale_position(40, 5), "Slider Demo", scale=adapter.scale_value(1.5), color=title_color) 152 | color_val = app_state['slider_color'] 153 | preview_color = image.Color.from_rgb(color_val, color_val, color_val) 154 | img.draw_rect(*adapter.scale_rect([140, 40, 40, 40]), color=preview_color, thickness=-1) 155 | slider_page_manager.handle_events(img) 156 | 157 | class RadioPage(SubPage): 158 | def __init__(self, ui_manager: UIManager, name: str): super().__init__(ui_manager, name) 159 | def update(self, img): 160 | self.handle_back_button(img) 161 | img.draw_string(*adapter.scale_position(40, 5), "Radio Button Demo", scale=adapter.scale_value(1.5), color=title_color) 162 | img.draw_string(*adapter.scale_position(200, 110), f"Selected: {app_state['radio_choice']}", color=title_color, scale=adapter.scale_value(1.0)) 163 | radio_page_manager.handle_events(img) 164 | 165 | class CheckboxPage(SubPage): 166 | def __init__(self, ui_manager: UIManager, name: str): super().__init__(ui_manager, name) 167 | def update(self, img): 168 | self.handle_back_button(img) 169 | img.draw_string(*adapter.scale_position(40, 5), "Checkbox Demo", scale=adapter.scale_value(1.5), color=title_color) 170 | status_x = 260 171 | img.draw_string(*adapter.scale_position(status_x, 30), "Status", scale=adapter.scale_value(1.0), color=title_color) 172 | colors = [status_on_color if app_state['checkboxes'][k] else status_off_color for k in ['A', 'B', 'C']] 173 | rects = [adapter.scale_rect(r) for r in [[status_x, 50, 30, 20], [status_x, 105, 30, 20], [status_x, 160, 30, 20]]] 174 | for r, c in zip(rects, colors): img.draw_rect(*r, color=c, thickness=-1) 175 | checkbox_page_manager.handle_events(img) 176 | 177 | # ========================================================== 178 | # 4. 初始化页面管理器和页面 179 | # ========================================================== 180 | # --- 步骤 1: 创建一个全局的页面管理器实例 --- 181 | # UIManager 是整个UI导航系统的大脑,负责跟踪和切换当前活动的页面。 182 | ui_manager = UIManager() 183 | 184 | # --- 步骤 2: 将页面类实例化为具体的页面对象 --- 185 | # 每个页面都需要一个对 ui_manager 的引用和一个在父页面中唯一的 'name'。 186 | # 这个 'name' 是新导航系统用来查找和切换页面的关键标识符。 187 | home_page = HomePage(ui_manager, name="home") 188 | switch_page = SwitchPage(ui_manager, name="switch_demo") 189 | slider_page = SliderPage(ui_manager, name="slider_demo") 190 | radio_page = RadioPage(ui_manager, name="radio_demo") 191 | checkbox_page = CheckboxPage(ui_manager, name="checkbox_demo") 192 | 193 | # --- 步骤 3: 构建页面树形结构 --- 194 | # 我们将所有演示页面添加为 home_page 的子页面,形成一个以 "home" 为根的树状结构。 195 | # 当我们调用 ui_manager.navigate_to_child() 时,它会在此结构中查找对应的子页面。 196 | home_page.add_child(switch_page) 197 | home_page.add_child(slider_page) 198 | home_page.add_child(radio_page) 199 | home_page.add_child(checkbox_page) 200 | 201 | # --- 步骤 4: 设置页面间的导航回调函数 --- 202 | # 这里我们将主页按钮的点击事件(callback)与 UIManager 的导航方法关联起来。 203 | # 我们现在使用的是 ui_manager.navigate_to_child("页面名称")。 204 | home_btn_manager.buttons[0].callback = lambda: ui_manager.navigate_to_child("switch_demo") 205 | home_btn_manager.buttons[1].callback = lambda: ui_manager.navigate_to_child("slider_demo") 206 | home_btn_manager.buttons[2].callback = lambda: ui_manager.navigate_to_child("radio_demo") 207 | home_btn_manager.buttons[3].callback = lambda: ui_manager.navigate_to_child("checkbox_demo") 208 | 209 | # 对于返回按钮,我们使用 go_back() 方法。 210 | # 这个方法会利用 UIManager 内置的导航历史记录返回到上一个访问的页面。 211 | back_button.callback = lambda: ui_manager.go_back() 212 | 213 | # --- 步骤 5: 设置根页面,启动UI管理器 --- 214 | # 在所有页面和导航规则设置完毕后,我们通过 set_root_page() 告诉 UIManager 215 | # 哪个页面是应用程序的入口。管理器将从这个页面开始运行和显示。 216 | ui_manager.set_root_page(home_page) 217 | 218 | # ========================================================== 219 | # 5. 主循环 220 | # ========================================================== 221 | while not app.need_exit(): 222 | img = cam.read() 223 | ui_manager.update(img) 224 | disp.show(img) 225 | 226 | print("UI Demo finished.") -------------------------------------------------------------------------------- /examples/menu_test_demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import maix 3 | from maix import app, camera, display, touchscreen, image, time 4 | 5 | from typing import Dict, Any, List, Callable 6 | 7 | from maixpy_ui import ( 8 | Page, UIManager, Button, ButtonManager, 9 | ResolutionAdapter 10 | ) 11 | 12 | class NavigationTracker: 13 | """Track navigation patterns and statistics""" 14 | 15 | def __init__(self): 16 | self.navigation_log = [] 17 | self.visit_count = {} 18 | self.max_depth_reached = 0 19 | self.total_navigations = 0 20 | self.cross_level_jumps = 0 21 | self.failed_navigations = 0 22 | 23 | def log_navigation(self, from_page: str, to_page: str, nav_type: str, depth: int, success: bool = True): 24 | """Log a navigation event""" 25 | self.total_navigations += 1 26 | if not success: 27 | self.failed_navigations += 1 28 | return 29 | 30 | self.max_depth_reached = max(self.max_depth_reached, depth) 31 | 32 | if nav_type == "cross_level": 33 | self.cross_level_jumps += 1 34 | 35 | self.visit_count[to_page] = self.visit_count.get(to_page, 0) + 1 36 | 37 | log_entry = { 38 | "from": from_page, 39 | "to": to_page, 40 | "type": nav_type, 41 | "depth": depth, 42 | "timestamp": time.time(), 43 | "success": success 44 | } 45 | self.navigation_log.append(log_entry) 46 | 47 | # Keep only last 30 entries for larger screen 48 | if len(self.navigation_log) > 30: 49 | self.navigation_log.pop(0) 50 | 51 | def get_stats(self): 52 | """Get navigation statistics""" 53 | return { 54 | "total_navigations": self.total_navigations, 55 | "max_depth": self.max_depth_reached, 56 | "cross_level_jumps": self.cross_level_jumps, 57 | "failed_navigations": self.failed_navigations, 58 | "unique_pages_visited": len(self.visit_count), 59 | "most_visited": max(self.visit_count.items(), key=lambda x: x[1]) if self.visit_count else ("None", 0) 60 | } 61 | 62 | 63 | # Fixed UIManager with corrected path navigation 64 | class FixedUIManager(UIManager): 65 | """Fixed UI Manager with corrected path navigation logic""" 66 | 67 | def navigate_to_path(self, path: List[str]) -> bool: 68 | """Fixed path navigation - handles both absolute and relative paths""" 69 | if not path: 70 | return False 71 | 72 | print(f"UIManager: Navigating to path: {path}") 73 | 74 | # If path starts with root, remove it for searching 75 | search_path = path[1:] if path and path[0] == "root" else path 76 | 77 | if not search_path: 78 | # Path was just ["root"], navigate to root 79 | return self.navigate_to_root() 80 | 81 | # Find target page from root 82 | if self.root_page: 83 | target_page = self.root_page.find_page_by_path(search_path) 84 | print(f"UIManager: Found target page: {target_page.name if target_page else 'None'}") 85 | 86 | if target_page: 87 | return self.navigate_to_page(target_page) 88 | 89 | print(f"UIManager: Failed to find path: {path}") 90 | return False 91 | 92 | def debug_tree_structure(self, page=None, level=0): 93 | """Debug method to print tree structure""" 94 | if page is None: 95 | page = self.root_page 96 | 97 | if page: 98 | indent = " " * level 99 | print(f"{indent}{page.name} (Level {level})") 100 | for child in page.children: 101 | self.debug_tree_structure(child, level + 1) 102 | 103 | 104 | class TestPage(Page): 105 | """Base test page for navigation testing - with fixed navigation""" 106 | 107 | def __init__(self, ui_manager, name: str, ts, disp, level_info: str = ""): 108 | super().__init__(ui_manager, name) 109 | self.ts = ts 110 | self.disp = disp 111 | self.button_manager = ButtonManager(ts, disp) 112 | self.level_info = level_info 113 | self.visit_count = 0 114 | self.last_nav_result = "" 115 | self.tracker = ui_manager.tracker if hasattr(ui_manager, 'tracker') else None 116 | 117 | def log_navigation(self, to_page: str, nav_type: str, success: bool = True): 118 | """Log navigation for tracking""" 119 | if self.tracker: 120 | self.tracker.log_navigation(self.name, to_page, nav_type, self.get_depth(), success) 121 | 122 | # Update UI feedback 123 | if success: 124 | self.last_nav_result = f"OK: {nav_type} to {to_page}" 125 | else: 126 | self.last_nav_result = f"FAIL: {nav_type} to {to_page}" 127 | 128 | print(f"Navigation: {self.last_nav_result}") 129 | 130 | def on_enter(self): 131 | self.visit_count += 1 132 | print(f"Entered {self.name} (Level {self.get_depth()}) - Visit #{self.visit_count}") 133 | if self.level_info: 134 | print(f" Info: {self.level_info}") 135 | 136 | def create_navigation_buttons(self, custom_buttons: List = None): 137 | """Create navigation buttons optimized for 640x480""" 138 | button_y = 10 139 | 140 | # Standard back button 141 | if self.parent: 142 | self.button_manager.add_button(Button( 143 | [10, button_y, 80, 30], "Back", 144 | lambda: self.navigate_with_log("parent", "back"), 145 | bg_color=(100, 100, 100) 146 | )) 147 | 148 | # Root button (if not already at root) 149 | if self.get_depth() > 0: 150 | self.button_manager.add_button(Button( 151 | [100, button_y, 70, 30], "Root", 152 | lambda: self.navigate_with_log("root", "to_root"), 153 | bg_color=(50, 100, 150) 154 | )) 155 | 156 | # Level navigation buttons for deep pages 157 | current_depth = self.get_depth() 158 | if current_depth > 1: 159 | self.button_manager.add_button(Button( 160 | [180, button_y, 60, 30], "L1", 161 | lambda: self.navigate_to_absolute_level(1), 162 | bg_color=(150, 100, 50) 163 | )) 164 | 165 | if current_depth > 2: 166 | self.button_manager.add_button(Button( 167 | [250, button_y, 60, 30], "L2", 168 | lambda: self.navigate_to_absolute_level(2), 169 | bg_color=(100, 150, 50) 170 | )) 171 | 172 | if current_depth > 3: 173 | self.button_manager.add_button(Button( 174 | [320, button_y, 60, 30], "L3", 175 | lambda: self.navigate_to_absolute_level(3), 176 | bg_color=(150, 150, 50) 177 | )) 178 | 179 | # History back button 180 | self.button_manager.add_button(Button( 181 | [390, button_y, 100, 30], "History Back", 182 | lambda: self.navigate_with_history(), 183 | bg_color=(100, 50, 150) 184 | )) 185 | 186 | # Debug button 187 | self.button_manager.add_button(Button( 188 | [500, button_y, 80, 30], "Debug Tree", 189 | lambda: self.debug_tree_structure(), 190 | bg_color=(150, 50, 50) 191 | )) 192 | 193 | # Custom buttons 194 | if custom_buttons: 195 | for btn_data in custom_buttons: 196 | self.button_manager.add_button(btn_data) 197 | 198 | def debug_tree_structure(self): 199 | """Debug current tree structure""" 200 | print(f"\n=== Tree Structure from {self.name} ===") 201 | if hasattr(self.ui_manager, 'debug_tree_structure'): 202 | self.ui_manager.debug_tree_structure() 203 | else: 204 | root = self.get_root() 205 | self._print_tree(root, 0) 206 | print("================================\n") 207 | 208 | def _print_tree(self, page, level): 209 | """Helper to print tree structure""" 210 | indent = " " * level 211 | print(f"{indent}{page.name} (Level {level})") 212 | for child in page.children: 213 | self._print_tree(child, level + 1) 214 | 215 | def navigate_with_log(self, target: str, nav_type: str): 216 | """Navigate and log the action""" 217 | success = False 218 | 219 | try: 220 | if target == "parent": 221 | success = self.ui_manager.navigate_to_parent() 222 | elif target == "root": 223 | success = self.ui_manager.navigate_to_root() 224 | elif target.startswith("level_"): 225 | level = int(target.split("_")[1]) 226 | success = self.navigate_to_absolute_level(level) 227 | else: 228 | # Try child first 229 | success = self.ui_manager.navigate_to_child(target) 230 | except Exception as e: 231 | print(f"Navigation error: {e}") 232 | success = False 233 | 234 | self.log_navigation(target, nav_type, success) 235 | return success 236 | 237 | def navigate_to_absolute_level(self, target_level: int): 238 | """Navigate to a specific absolute level in the tree""" 239 | try: 240 | current_path = self.get_path() 241 | print(f"Current path: {current_path}, target level: {target_level}") 242 | 243 | if target_level >= len(current_path): 244 | print(f"Cannot navigate to level {target_level}, current depth is {len(current_path)-1}") 245 | return False 246 | 247 | # Build path to target level (0-indexed, but level display is 1-indexed) 248 | if target_level == 0: 249 | return self.ui_manager.navigate_to_root() 250 | else: 251 | target_path = current_path[:target_level + 1] 252 | print(f"Navigating to path: {target_path}") 253 | success = self.ui_manager.navigate_to_path(target_path) 254 | if success: 255 | self.log_navigation(f"level_{target_level}", "to_level", True) 256 | else: 257 | self.log_navigation(f"level_{target_level}", "to_level", False) 258 | return success 259 | 260 | except Exception as e: 261 | print(f"Error in navigate_to_absolute_level: {e}") 262 | self.log_navigation(f"level_{target_level}", "to_level", False) 263 | return False 264 | 265 | def navigate_with_history(self): 266 | """Use history-based navigation""" 267 | success = self.ui_manager.go_back() 268 | self.log_navigation("history_back", "history", success) 269 | return success 270 | 271 | def cross_level_jump(self, target_path: List[str], jump_name: str = "cross_jump"): 272 | """Perform cross-level jump with error handling""" 273 | try: 274 | print(f"Attempting cross-level jump to: {target_path}") 275 | 276 | # Validate path exists by checking each level 277 | current = self.get_root() 278 | search_path = target_path[1:] if target_path[0] == "root" else target_path 279 | 280 | print(f"Search path: {search_path}") 281 | 282 | # Verify path exists 283 | for i, page_name in enumerate(search_path): 284 | child = current.get_child(page_name) 285 | if child is None: 286 | print(f"Path validation failed at level {i}: '{page_name}' not found in {current.name}") 287 | print(f"Available children: {[c.name for c in current.children]}") 288 | self.log_navigation("failed_jump", "cross_level", False) 289 | return False 290 | current = child 291 | print(f"Found {page_name} at level {i}") 292 | 293 | # Path exists, now navigate 294 | success = self.ui_manager.navigate_to_path(target_path) 295 | target_name = target_path[-1] if target_path else "unknown" 296 | self.log_navigation(target_name, "cross_level", success) 297 | return success 298 | 299 | except Exception as e: 300 | print(f"Cross-level jump failed: {e}") 301 | self.log_navigation("failed_jump", "cross_level", False) 302 | return False 303 | 304 | def update(self, img): 305 | # Page title - larger for 640x480 306 | title = f"{self.name.replace('_', ' ').title()}" 307 | img.draw_string(30, 60, title, image.Color.from_rgb(255, 255, 255), scale=2.0) 308 | 309 | # Level info 310 | level_text = f"Level {self.get_depth()}" 311 | if self.level_info: 312 | level_text += f" - {self.level_info}" 313 | img.draw_string(30, 90, level_text, image.Color.from_rgb(200, 200, 200), scale=1.3) 314 | 315 | # Visit count and children info 316 | info_line = f"Visits: {self.visit_count}" 317 | if self.children: 318 | info_line += f" | Children: {len(self.children)}" 319 | img.draw_string(30, 110, info_line, image.Color.from_rgb(150, 255, 150), scale=1.1) 320 | 321 | # Last navigation result 322 | if self.last_nav_result: 323 | color = image.Color.from_rgb(150, 255, 150) if "OK:" in self.last_nav_result else image.Color.from_rgb(255, 150, 150) 324 | img.draw_string(30, 130, f"Last Nav: {self.last_nav_result}", color, scale=0.9) 325 | 326 | # Path display at bottom 327 | path_text = " > ".join(self.get_path()) 328 | img.draw_string(10, 450, f"Path: {path_text}", 329 | image.Color.from_rgb(180, 180, 180), scale=0.9) 330 | 331 | # Navigation info 332 | nav_info = self.ui_manager.get_navigation_info() 333 | info_text = f"Depth: {nav_info['page_depth']} | History: {nav_info['history_depth']} | Can go back: {nav_info['can_go_back']}" 334 | img.draw_string(10, 470, info_text, image.Color.from_rgb(150, 150, 150), scale=0.8) 335 | 336 | # Handle button events 337 | self.button_manager.handle_events(img) 338 | 339 | 340 | class RootTestPage(TestPage): 341 | """Root level page - Level 0""" 342 | 343 | def __init__(self, ui_manager, ts, disp): 344 | super().__init__(ui_manager, "root", ts, disp, "Navigation Test Root") 345 | self.setup_ui() 346 | 347 | def setup_ui(self): 348 | # Main menu buttons - larger for 640x480 349 | button_width = 200 350 | button_height = 40 351 | start_x = 50 352 | start_y = 160 353 | spacing = 50 354 | 355 | menu_buttons = [ 356 | Button([start_x, start_y, button_width, button_height], "Menu Branch A", 357 | lambda: self.navigate_with_log("branch_a", "child")), 358 | Button([start_x, start_y + spacing, button_width, button_height], "Menu Branch B", 359 | lambda: self.navigate_with_log("branch_b", "child")), 360 | Button([start_x, start_y + spacing*2, button_width, button_height], "Deep Test Branch", 361 | lambda: self.navigate_with_log("deep_branch", "child")), 362 | Button([start_x, start_y + spacing*3, button_width, button_height], "Navigation Stats", 363 | lambda: self.navigate_with_log("nav_stats", "child")), 364 | 365 | # Cross-level jump buttons - these should now work! 366 | Button([start_x + 250, start_y, button_width, button_height], "Jump to Level 3", 367 | lambda: self.cross_level_jump(["root", "deep_branch", "level2", "level3"], "deep_jump"), 368 | bg_color=(150, 100, 100)), 369 | Button([start_x + 250, start_y + spacing, button_width, button_height], "Jump to Level 5", 370 | lambda: self.cross_level_jump(["root", "deep_branch", "level2", "level3", "level4", "level5"], "deepest_jump"), 371 | bg_color=(200, 100, 100)), 372 | Button([start_x + 250, start_y + spacing*2, button_width, button_height], "Jump to Sub A1", 373 | lambda: self.cross_level_jump(["root", "branch_a", "sub_a1"], "branch_jump"), 374 | bg_color=(100, 150, 100)), 375 | 376 | Button([start_x + 100, start_y + spacing*3 + 20, button_width, button_height], "Exit Program", 377 | lambda: app.set_exit_flag(True), 378 | bg_color=(200, 50, 50)) 379 | ] 380 | 381 | self.create_navigation_buttons(menu_buttons) 382 | 383 | 384 | class BranchAPage(TestPage): 385 | """Branch A - Level 1""" 386 | 387 | def __init__(self, ui_manager, ts, disp): 388 | super().__init__(ui_manager, "branch_a", ts, disp, "Branch A Menu") 389 | self.setup_ui() 390 | 391 | def setup_ui(self): 392 | start_x = 50 393 | start_y = 160 394 | button_width = 180 395 | button_height = 40 396 | spacing = 50 397 | 398 | menu_buttons = [ 399 | Button([start_x, start_y, button_width, button_height], "Sub Menu A1", 400 | lambda: self.navigate_with_log("sub_a1", "child")), 401 | Button([start_x, start_y + spacing, button_width, button_height], "Sub Menu A2", 402 | lambda: self.navigate_with_log("sub_a2", "child")), 403 | Button([start_x, start_y + spacing*2, button_width, button_height], "Jump to Branch B", 404 | lambda: self.cross_level_jump(["root", "branch_b"], "cross_branch"), 405 | bg_color=(100, 150, 100)), 406 | Button([start_x, start_y + spacing*3, button_width, button_height], "Jump to Deep Level 4", 407 | lambda: self.cross_level_jump(["root", "deep_branch", "level2", "level3", "level4"], "deep_jump"), 408 | bg_color=(150, 100, 150)) 409 | ] 410 | 411 | self.create_navigation_buttons(menu_buttons) 412 | 413 | 414 | class BranchBPage(TestPage): 415 | """Branch B - Level 1""" 416 | 417 | def __init__(self, ui_manager, ts, disp): 418 | super().__init__(ui_manager, "branch_b", ts, disp, "Branch B Menu") 419 | self.setup_ui() 420 | 421 | def setup_ui(self): 422 | start_x = 50 423 | start_y = 160 424 | button_width = 180 425 | button_height = 40 426 | spacing = 50 427 | 428 | menu_buttons = [ 429 | Button([start_x, start_y, button_width, button_height], "Sub Menu B1", 430 | lambda: self.navigate_with_log("sub_b1", "child")), 431 | Button([start_x, start_y + spacing, button_width, button_height], "Sub Menu B2", 432 | lambda: self.navigate_with_log("sub_b2", "child")), 433 | Button([start_x, start_y + spacing*2, button_width, button_height], "Jump to Branch A", 434 | lambda: self.cross_level_jump(["root", "branch_a"], "cross_branch"), 435 | bg_color=(100, 150, 100)), 436 | Button([start_x, start_y + spacing*3, button_width, button_height], "Jump to Deepest", 437 | lambda: self.cross_level_jump(["root", "deep_branch", "level2", "level3", "level4", "level5"], "deepest_jump"), 438 | bg_color=(200, 100, 100)) 439 | ] 440 | 441 | self.create_navigation_buttons(menu_buttons) 442 | 443 | 444 | class SubMenuPage(TestPage): 445 | """Generic sub menu page - Level 2""" 446 | 447 | def __init__(self, ui_manager, name: str, ts, disp, branch_info: str): 448 | super().__init__(ui_manager, name, ts, disp, f"Sub Menu - {branch_info}") 449 | self.branch_info = branch_info 450 | self.action_count = 0 451 | self.setup_ui() 452 | 453 | def setup_ui(self): 454 | start_x = 50 455 | start_y = 160 456 | button_width = 180 457 | button_height = 40 458 | spacing = 50 459 | 460 | menu_buttons = [ 461 | Button([start_x, start_y, button_width, button_height], "Test Action 1", 462 | lambda: self.perform_action("Action 1")), 463 | Button([start_x, start_y + spacing, button_width, button_height], "Test Action 2", 464 | lambda: self.perform_action("Action 2")), 465 | Button([start_x, start_y + spacing*2, button_width, button_height], "Jump to Other Branch", 466 | lambda: self.jump_to_other_branch(), 467 | bg_color=(100, 100, 150)), 468 | Button([start_x, start_y + spacing*3, button_width, button_height], "Deep Jump Test", 469 | lambda: self.cross_level_jump(["root", "deep_branch", "level2", "level3"], "deep_jump"), 470 | bg_color=(150, 100, 150)) 471 | ] 472 | 473 | self.create_navigation_buttons(menu_buttons) 474 | 475 | def perform_action(self, action): 476 | self.action_count += 1 477 | print(f"Performed {action} in {self.name} (count: {self.action_count})") 478 | self.last_nav_result = f"Action: {action} (#{self.action_count})" 479 | 480 | def jump_to_other_branch(self): 481 | """Jump to the other branch's sub menu""" 482 | if "sub_a" in self.name: 483 | # Jump to branch B 484 | target_path = ["root", "branch_b", "sub_b1"] 485 | else: 486 | # Jump to branch A 487 | target_path = ["root", "branch_a", "sub_a1"] 488 | 489 | self.cross_level_jump(target_path, "branch_cross_jump") 490 | 491 | 492 | class DeepBranchPage(TestPage): 493 | """Start of deep branch - Level 1""" 494 | 495 | def __init__(self, ui_manager, ts, disp): 496 | super().__init__(ui_manager, "deep_branch", ts, disp, "Deep Navigation Test") 497 | self.setup_ui() 498 | 499 | def setup_ui(self): 500 | start_x = 50 501 | start_y = 160 502 | button_width = 180 503 | button_height = 40 504 | spacing = 50 505 | 506 | menu_buttons = [ 507 | Button([start_x, start_y, button_width, button_height], "Go to Level 2", 508 | lambda: self.navigate_with_log("level2", "child")), 509 | Button([start_x, start_y + spacing, button_width, button_height], "Jump to Level 4", 510 | lambda: self.cross_level_jump(["root", "deep_branch", "level2", "level3", "level4"], "skip_jump"), 511 | bg_color=(200, 100, 100)), 512 | Button([start_x, start_y + spacing*2, button_width, button_height], "Jump to Level 5", 513 | lambda: self.cross_level_jump(["root", "deep_branch", "level2", "level3", "level4", "level5"], "deepest_skip"), 514 | bg_color=(255, 100, 100)), 515 | Button([start_x, start_y + spacing*3, button_width, button_height], "Back to Branch A", 516 | lambda: self.cross_level_jump(["root", "branch_a"], "branch_jump"), 517 | bg_color=(100, 200, 100)) 518 | ] 519 | 520 | self.create_navigation_buttons(menu_buttons) 521 | 522 | 523 | class Level2Page(TestPage): 524 | """Deep level 2""" 525 | 526 | def __init__(self, ui_manager, ts, disp): 527 | super().__init__(ui_manager, "level2", ts, disp, "Deep Level 2") 528 | self.setup_ui() 529 | 530 | def setup_ui(self): 531 | start_x = 50 532 | start_y = 160 533 | button_width = 180 534 | button_height = 40 535 | spacing = 50 536 | 537 | menu_buttons = [ 538 | Button([start_x, start_y, button_width, button_height], "Go to Level 3", 539 | lambda: self.navigate_with_log("level3", "child")), 540 | Button([start_x, start_y + spacing, button_width, button_height], "Skip to Level 5", 541 | lambda: self.cross_level_jump(["root", "deep_branch", "level2", "level3", "level4", "level5"], "skip_to_end"), 542 | bg_color=(150, 150, 100)), 543 | Button([start_x, start_y + spacing*2, button_width, button_height], "Jump to Sub Menu", 544 | lambda: self.cross_level_jump(["root", "branch_a", "sub_a2"], "to_submenu"), 545 | bg_color=(100, 150, 150)) 546 | ] 547 | 548 | self.create_navigation_buttons(menu_buttons) 549 | 550 | 551 | class Level3Page(TestPage): 552 | """Deep level 3""" 553 | 554 | def __init__(self, ui_manager, ts, disp): 555 | super().__init__(ui_manager, "level3", ts, disp, "Deep Level 3") 556 | self.setup_ui() 557 | 558 | def setup_ui(self): 559 | start_x = 50 560 | start_y = 160 561 | button_width = 180 562 | button_height = 40 563 | spacing = 50 564 | 565 | menu_buttons = [ 566 | Button([start_x, start_y, button_width, button_height], "Go to Level 4", 567 | lambda: self.navigate_with_log("level4", "child")), 568 | Button([start_x, start_y + spacing, button_width, button_height], "Jump to Level 1", 569 | lambda: self.navigate_to_absolute_level(1), 570 | bg_color=(100, 200, 100)), 571 | Button([start_x, start_y + spacing*2, button_width, button_height], "Emergency to Root", 572 | lambda: self.navigate_with_log("root", "emergency"), 573 | bg_color=(200, 50, 50)) 574 | ] 575 | 576 | self.create_navigation_buttons(menu_buttons) 577 | 578 | 579 | class Level4Page(TestPage): 580 | """Deep level 4""" 581 | 582 | def __init__(self, ui_manager, ts, disp): 583 | super().__init__(ui_manager, "level4", ts, disp, "Deep Level 4") 584 | self.setup_ui() 585 | 586 | def setup_ui(self): 587 | start_x = 50 588 | start_y = 160 589 | button_width = 180 590 | button_height = 40 591 | spacing = 50 592 | 593 | menu_buttons = [ 594 | Button([start_x, start_y, button_width, button_height], "Go to Level 5", 595 | lambda: self.navigate_with_log("level5", "child")), 596 | Button([start_x, start_y + spacing, button_width, button_height], "Multi-Jump Test", 597 | lambda: self.multi_jump_sequence(), 598 | bg_color=(255, 150, 100)), 599 | Button([start_x, start_y + spacing*2, button_width, button_height], "Direct to Branch B", 600 | lambda: self.cross_level_jump(["root", "branch_b"], "deep_to_branch"), 601 | bg_color=(150, 100, 200)) 602 | ] 603 | 604 | self.create_navigation_buttons(menu_buttons) 605 | 606 | def multi_jump_sequence(self): 607 | """Test complex multi-jump sequence""" 608 | print("Starting multi-jump sequence from Level 4") 609 | 610 | # First jump to level 5 611 | if self.ui_manager.navigate_to_child("level5"): 612 | self.log_navigation("level5", "multi_jump_step1", True) 613 | else: 614 | self.log_navigation("level5", "multi_jump_step1", False) 615 | 616 | 617 | class Level5Page(TestPage): 618 | """Deepest level - Level 5""" 619 | 620 | def __init__(self, ui_manager, ts, disp): 621 | super().__init__(ui_manager, "level5", ts, disp, "Deepest Level (5)") 622 | self.test_count = 0 623 | self.setup_ui() 624 | 625 | def setup_ui(self): 626 | start_x = 50 627 | start_y = 160 628 | button_width = 200 629 | button_height = 35 630 | spacing = 45 631 | 632 | menu_buttons = [ 633 | Button([start_x, start_y, button_width, button_height], "Complex Jump Test", 634 | lambda: self.complex_navigation_test(), 635 | bg_color=(200, 150, 100)), 636 | Button([start_x, start_y + spacing, button_width, button_height], "Level Navigation Test", 637 | lambda: self.level_navigation_test(), 638 | bg_color=(150, 200, 100)), 639 | Button([start_x, start_y + spacing*2, button_width, button_height], "Branch Jump Test", 640 | lambda: self.branch_jump_test(), 641 | bg_color=(100, 150, 200)), 642 | Button([start_x, start_y + spacing*3, button_width, button_height], "Emergency Exit", 643 | lambda: self.emergency_exit(), 644 | bg_color=(255, 50, 50)), 645 | 646 | # Additional test buttons 647 | Button([start_x + 250, start_y, button_width-50, button_height], "To Level 2", 648 | lambda: self.navigate_to_absolute_level(2), 649 | bg_color=(100, 100, 150)), 650 | Button([start_x + 250, start_y + spacing, button_width-50, button_height], "To Level 1", 651 | lambda: self.navigate_to_absolute_level(1), 652 | bg_color=(150, 100, 150)), 653 | Button([start_x + 250, start_y + spacing*2, button_width-50, button_height], "Clear History", 654 | lambda: self.clear_and_jump(), 655 | bg_color=(150, 150, 100)) 656 | ] 657 | 658 | self.create_navigation_buttons(menu_buttons) 659 | 660 | def complex_navigation_test(self): 661 | """Test complex navigation from deepest level""" 662 | self.test_count += 1 663 | print(f"Complex navigation test #{self.test_count} from level {self.get_depth()}") 664 | 665 | # Try to jump to branch A sub menu 666 | success = self.cross_level_jump(["root", "branch_a", "sub_a1"], "complex_jump") 667 | if success: 668 | print("Complex jump successful") 669 | else: 670 | print("Complex jump failed") 671 | 672 | def level_navigation_test(self): 673 | """Test level-specific navigation""" 674 | # Try to go to level 2 675 | success = self.navigate_to_absolute_level(2) 676 | if not success: 677 | print("Level navigation failed, trying alternative") 678 | # Try direct path navigation 679 | self.cross_level_jump(["root", "deep_branch", "level2"], "level_alt") 680 | 681 | def branch_jump_test(self): 682 | """Test jumping to different branches""" 683 | branches = [ 684 | (["root", "branch_a"], "branch_a"), 685 | (["root", "branch_b"], "branch_b"), 686 | (["root", "nav_stats"], "nav_stats") 687 | ] 688 | 689 | import random 690 | target_path, target_name = random.choice(branches) 691 | print(f"Random branch jump to: {target_name}") 692 | self.cross_level_jump(target_path, "random_branch_jump") 693 | 694 | def clear_and_jump(self): 695 | """Clear history and jump""" 696 | self.ui_manager.clear_history() 697 | self.last_nav_result = "History cleared" 698 | print("Navigation history cleared from deepest level") 699 | 700 | def emergency_exit(self): 701 | """Emergency exit to root""" 702 | success = self.ui_manager.navigate_to_root() 703 | self.log_navigation("root", "emergency_exit", success) 704 | if success: 705 | print("Emergency exit to root completed") 706 | else: 707 | print("Emergency exit failed!") 708 | 709 | 710 | class NavigationStatsPage(TestPage): 711 | """Page to display navigation statistics""" 712 | 713 | def __init__(self, ui_manager, ts, disp): 714 | super().__init__(ui_manager, "nav_stats", ts, disp, "Navigation Statistics") 715 | self.setup_ui() 716 | 717 | def setup_ui(self): 718 | start_x = 50 719 | start_y = 350 720 | button_width = 150 721 | button_height = 35 722 | 723 | menu_buttons = [ 724 | Button([start_x, start_y, button_width, button_height], "Reset Stats", 725 | lambda: self.reset_stats(), 726 | bg_color=(200, 100, 100)), 727 | Button([start_x + 170, start_y, button_width, button_height], "Print Log", 728 | lambda: self.print_navigation_log(), 729 | bg_color=(100, 200, 100)), 730 | Button([start_x + 340, start_y, button_width, button_height], "Test All Jumps", 731 | lambda: self.test_all_navigation(), 732 | bg_color=(100, 100, 200)) 733 | ] 734 | 735 | self.create_navigation_buttons(menu_buttons) 736 | 737 | def reset_stats(self): 738 | """Reset all navigation statistics""" 739 | if self.tracker: 740 | self.tracker.__init__() 741 | self.last_nav_result = "All stats reset" 742 | print("All navigation statistics reset") 743 | 744 | def print_navigation_log(self): 745 | """Print recent navigation log""" 746 | if self.tracker and self.tracker.navigation_log: 747 | print("\n=== Recent Navigation Log ===") 748 | for entry in self.tracker.navigation_log[-10:]: # Last 10 entries 749 | print(f"{entry['from']} -> {entry['to']} ({entry['type']}) {'✓' if entry['success'] else '✗'}") 750 | print("=============================\n") 751 | 752 | def test_all_navigation(self): 753 | """Test various navigation patterns""" 754 | print("Testing all navigation types...") 755 | test_paths = [ 756 | (["root", "branch_a"], "test_branch_a"), 757 | (["root", "branch_b", "sub_b1"], "test_sub_b1"), 758 | (["root", "deep_branch", "level2", "level3"], "test_level3"), 759 | (["root", "deep_branch", "level2", "level3", "level4", "level5"], "test_deepest") 760 | ] 761 | 762 | for path, test_name in test_paths: 763 | print(f"Testing path: {path}") 764 | success = self.ui_manager.navigate_to_path(path) 765 | self.log_navigation(test_name, "navigation_test", success) 766 | if success: 767 | # Come back to stats page 768 | time.sleep_ms(100) # Brief pause 769 | self.ui_manager.navigate_to_path(["root", "nav_stats"]) 770 | 771 | def update(self, img): 772 | super().update(img) 773 | 774 | # Display current stats 775 | if self.tracker: 776 | stats = self.tracker.get_stats() 777 | 778 | y_offset = 160 779 | line_height = 20 780 | 781 | stats_lines = [ 782 | f"Total Navigations: {stats['total_navigations']}", 783 | f"Failed Navigations: {stats['failed_navigations']}", 784 | f"Max Depth Reached: {stats['max_depth']}", 785 | f"Cross-Level Jumps: {stats['cross_level_jumps']}", 786 | f"Unique Pages Visited: {stats['unique_pages_visited']}", 787 | f"Most Visited: {stats['most_visited'][0]} ({stats['most_visited'][1]}x)" 788 | ] 789 | 790 | for i, line in enumerate(stats_lines): 791 | img.draw_string(30, y_offset + i * line_height, line, 792 | image.Color.from_rgb(200, 255, 200), scale=1.1) 793 | 794 | # Show recent navigation history 795 | if self.tracker.navigation_log: 796 | img.draw_string(30, y_offset + len(stats_lines) * line_height + 20, 797 | "Recent Navigations:", 798 | image.Color.from_rgb(255, 255, 200), scale=1.0) 799 | 800 | recent_y = y_offset + len(stats_lines) * line_height + 45 801 | for i, entry in enumerate(self.tracker.navigation_log[-5:]): # Last 5 802 | color = image.Color.from_rgb(150, 255, 150) if entry['success'] else image.Color.from_rgb(255, 150, 150) 803 | nav_text = f"{entry['from']} -> {entry['to']} ({'✓' if entry['success'] else '✗'})" 804 | img.draw_string(30, recent_y + i * 15, nav_text, color, scale=0.8) 805 | 806 | 807 | def create_test_menu_tree(ui_manager, ts, disp): 808 | """Create the complete test menu tree with proper structure""" 809 | 810 | print("Creating menu tree structure...") 811 | 812 | # Create all pages 813 | root = RootTestPage(ui_manager, ts, disp) 814 | 815 | # Level 1 pages 816 | branch_a = BranchAPage(ui_manager, ts, disp) 817 | branch_b = BranchBPage(ui_manager, ts, disp) 818 | deep_branch = DeepBranchPage(ui_manager, ts, disp) 819 | nav_stats = NavigationStatsPage(ui_manager, ts, disp) 820 | 821 | # Level 2 pages 822 | sub_a1 = SubMenuPage(ui_manager, "sub_a1", ts, disp, "Branch A1") 823 | sub_a2 = SubMenuPage(ui_manager, "sub_a2", ts, disp, "Branch A2") 824 | sub_b1 = SubMenuPage(ui_manager, "sub_b1", ts, disp, "Branch B1") 825 | sub_b2 = SubMenuPage(ui_manager, "sub_b2", ts, disp, "Branch B2") 826 | level2 = Level2Page(ui_manager, ts, disp) 827 | 828 | # Level 3+ pages (deep branch) 829 | level3 = Level3Page(ui_manager, ts, disp) 830 | level4 = Level4Page(ui_manager, ts, disp) 831 | level5 = Level5Page(ui_manager, ts, disp) 832 | 833 | # Build tree structure step by step 834 | print("Building tree structure...") 835 | 836 | # Root children (Level 1) 837 | root.add_child(branch_a) 838 | root.add_child(branch_b) 839 | root.add_child(deep_branch) 840 | root.add_child(nav_stats) 841 | print(f"Root now has {len(root.children)} children: {[c.name for c in root.children]}") 842 | 843 | # Branch A children (Level 2) 844 | branch_a.add_child(sub_a1) 845 | branch_a.add_child(sub_a2) 846 | print(f"Branch A has {len(branch_a.children)} children: {[c.name for c in branch_a.children]}") 847 | 848 | # Branch B children (Level 2) 849 | branch_b.add_child(sub_b1) 850 | branch_b.add_child(sub_b2) 851 | print(f"Branch B has {len(branch_b.children)} children: {[c.name for c in branch_b.children]}") 852 | 853 | # Deep branch children (creating a deep hierarchy) 854 | deep_branch.add_child(level2) 855 | level2.add_child(level3) 856 | level3.add_child(level4) 857 | level4.add_child(level5) 858 | print(f"Deep branch structure: deep_branch -> level2 -> level3 -> level4 -> level5") 859 | 860 | # Verify tree structure 861 | print("\n=== Final Tree Structure ===") 862 | def print_tree(page, indent=0): 863 | spaces = " " * indent 864 | print(f"{spaces}{page.name} (children: {len(page.children)})") 865 | for child in page.children: 866 | print_tree(child, indent + 1) 867 | 868 | print_tree(root) 869 | print("============================\n") 870 | 871 | return root 872 | 873 | 874 | def main(): 875 | """Main function for navigation testing - FIXED VERSION""" 876 | print("FIXED Multi-Level Menu Navigation Test (640x480)") 877 | print("===============================================") 878 | 879 | # Initialize hardware for 640x480 880 | cam = camera.Camera(640, 480) 881 | disp = display.Display() 882 | ts = touchscreen.TouchScreen() 883 | 884 | # Create navigation tracker 885 | tracker = NavigationTracker() 886 | 887 | # Create FIXED UI manager with tracker 888 | ui_manager = FixedUIManager() 889 | ui_manager.tracker = tracker 890 | 891 | # Create menu tree 892 | root_page = create_test_menu_tree(ui_manager, ts, disp) 893 | ui_manager.set_root_page(root_page) 894 | 895 | # Debug: Print the final structure and test a path 896 | print("Testing path resolution...") 897 | ui_manager.debug_tree_structure() 898 | 899 | test_paths = [ 900 | ["root", "branch_a", "sub_a1"], 901 | ["root", "deep_branch", "level2", "level3"], 902 | ["root", "deep_branch", "level2", "level3", "level4", "level5"] 903 | ] 904 | 905 | for path in test_paths: 906 | target = root_page.find_page_by_path(path[1:]) # Skip 'root' 907 | print(f"Path {path} -> Found: {target.name if target else 'None'}") 908 | 909 | print("\n=== FIXED Features ===") 910 | print("- Fixed path navigation logic") 911 | print("- Path validation with detailed error messages") 912 | print("- Cross-level jumps now working correctly") 913 | print("- Tree structure verification") 914 | print("- Debug buttons for troubleshooting") 915 | print("=====================\n") 916 | 917 | # Main loop 918 | frame_count = 0 919 | while not app.need_exit(): 920 | img = cam.read() 921 | 922 | # Clear background 923 | img.draw_rect(0, 0, img.width(), img.height(), 924 | image.Color.from_rgb(25, 30, 35), thickness=-1) 925 | 926 | # Update current page 927 | ui_manager.update(img) 928 | 929 | # Show frame counter and navigation info (top-left corner) 930 | nav_info = ui_manager.get_navigation_info() 931 | frame_info = f"Frame: {frame_count} | History: {nav_info['history_depth']}" 932 | img.draw_string(5, 5, frame_info, image.Color.from_rgb(120, 120, 120), scale=0.7) 933 | 934 | # Display 935 | disp.show(img) 936 | frame_count += 1 937 | 938 | # Final statistics 939 | print("\n=== Final Navigation Statistics ===") 940 | if tracker: 941 | stats = tracker.get_stats() 942 | print(f"Total navigations: {stats['total_navigations']}") 943 | print(f"Failed navigations: {stats['failed_navigations']}") 944 | success_rate = ((stats['total_navigations'] - stats['failed_navigations']) / max(1, stats['total_navigations']) * 100) 945 | print(f"Success rate: {success_rate:.1f}%") 946 | print(f"Maximum depth reached: {stats['max_depth']}") 947 | print(f"Cross-level jumps: {stats['cross_level_jumps']}") 948 | print(f"Unique pages visited: {stats['unique_pages_visited']}") 949 | print(f"Most visited page: {stats['most_visited'][0]} ({stats['most_visited'][1]} times)") 950 | print("===================================") 951 | 952 | print("FIXED navigation test completed successfully!") 953 | 954 | 955 | if __name__ == "__main__": 956 | try: 957 | main() 958 | except Exception as e: 959 | print(f"Error: {e}") 960 | import traceback 961 | traceback.print_exc() 962 | -------------------------------------------------------------------------------- /pics/button.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aristorechina/MaixPy-UI-Lib/0bc603cc803d0224792dd0943f9629e6158f9633/pics/button.jpg -------------------------------------------------------------------------------- /pics/checkbox.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aristorechina/MaixPy-UI-Lib/0bc603cc803d0224792dd0943f9629e6158f9633/pics/checkbox.jpg -------------------------------------------------------------------------------- /pics/install_tutorial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aristorechina/MaixPy-UI-Lib/0bc603cc803d0224792dd0943f9629e6158f9633/pics/install_tutorial.png -------------------------------------------------------------------------------- /pics/radiobutton.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aristorechina/MaixPy-UI-Lib/0bc603cc803d0224792dd0943f9629e6158f9633/pics/radiobutton.jpg -------------------------------------------------------------------------------- /pics/slider.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aristorechina/MaixPy-UI-Lib/0bc603cc803d0224792dd0943f9629e6158f9633/pics/slider.jpg -------------------------------------------------------------------------------- /pics/switch.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aristorechina/MaixPy-UI-Lib/0bc603cc803d0224792dd0943f9629e6158f9633/pics/switch.jpg -------------------------------------------------------------------------------- /src/maixpy_ui/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | MaixPy-UI-Lib: A lightweight UI component library for MaixPy 3 | 4 | Copyright 2025 Aristore 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | """ 18 | 19 | from .components import ( 20 | Button, ButtonManager, 21 | Slider, SliderManager, 22 | Switch, SwitchManager, 23 | Checkbox, CheckboxManager, 24 | RadioButton, RadioManager 25 | ) 26 | 27 | from .core import ( 28 | Page, 29 | UIManager, 30 | ResolutionAdapter 31 | ) 32 | 33 | __version__ = "2.4" 34 | __author__ = "Aristore, levi_jia, HYKMAX" 35 | __license__ = "Apache-2.0" 36 | 37 | __all__ = [ 38 | "Button", "Slider", "Switch", "Checkbox", "RadioButton", 39 | "ButtonManager", "SliderManager", "SwitchManager", "CheckboxManager", "RadioManager", 40 | "Page", "UIManager", "ResolutionAdapter" 41 | ] 42 | -------------------------------------------------------------------------------- /src/maixpy_ui/components/__init__.py: -------------------------------------------------------------------------------- 1 | from .button import Button, ButtonManager 2 | from .slider import Slider, SliderManager 3 | from .switch import Switch, SwitchManager 4 | from .checkbox import Checkbox, CheckboxManager 5 | from .radio import RadioButton, RadioManager -------------------------------------------------------------------------------- /src/maixpy_ui/components/button.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Aristore' 3 | 4 | import maix.image as image 5 | import maix.touchscreen as touchscreen 6 | import maix.display as display 7 | from typing import Callable, Sequence 8 | 9 | class Button: 10 | """创建一个可交互的按钮组件。 11 | 12 | 该组件可以响应触摸事件,并在按下时改变外观,释放时执行回调函数。 13 | """ 14 | 15 | def _normalize_color(self, color: Sequence[int] | None): 16 | """将元组颜色转换为 maix.image.Color 对象。""" 17 | if color is None: 18 | return None 19 | if isinstance(color, tuple): 20 | if len(color) == 3: 21 | return image.Color.from_rgb(color[0], color[1], color[2]) 22 | else: 23 | raise ValueError("颜色元组必须是 3 个元素的 RGB 格式。") 24 | return color 25 | 26 | def __init__(self, rect: Sequence[int], label: str, callback: Callable | None, bg_color: Sequence[int] | None=(50, 50, 50), 27 | pressed_color: Sequence[int] | None=(0, 120, 220), text_color: Sequence[int]=(255, 255, 255), 28 | border_color: Sequence[int]=(200, 200, 200), border_thickness: int=2, 29 | text_scale: float=1.5, font: str | None=None, align_h: str='center', 30 | align_v: str='center'): 31 | """初始化一个按钮。 32 | 33 | Args: 34 | rect (Sequence[int]): 按钮的位置和尺寸 `[x, y, w, h]`。 35 | label (str): 按钮上显示的文本。 36 | callback (callable | None): 当按钮被点击时调用的函数。 37 | bg_color (Sequence[int] | None): 背景颜色 (R, G, B)。 38 | pressed_color (Sequence[int] | None): 按下状态的背景颜色 (R, G, B)。 39 | text_color (Sequence[int]): 文本颜色 (R, G, B)。 40 | border_color (Sequence[int]): 边框颜色 (R, G, B)。 41 | border_thickness (int): 边框厚度(像素)。 42 | text_scale (float): 文本的缩放比例。 43 | font (str | None, optional): 使用的字体文件路径。默认为 None。 44 | align_h (str): 水平对齐方式 ('left', 'center', 'right')。 45 | align_v (str): 垂直对齐方式 ('top', 'center', 'bottom')。 46 | 47 | Raises: 48 | ValueError: 如果 `rect` 不是包含四个整数的列表。 49 | TypeError: 如果 `callback` 不是一个可调用对象。 50 | """ 51 | if not all(isinstance(i, int) for i in rect) or len(rect) != 4: 52 | raise ValueError("rect 必须是包含四个整数 [x, y, w, h] 的列表") 53 | if callback is not None and not callable(callback): 54 | raise TypeError("callback 必须是一个可调用的函数") 55 | self.rect, self.label, self.callback = rect, label, callback 56 | self.text_scale = text_scale 57 | self.font = font 58 | self.border_thickness = border_thickness 59 | self.align_h, self.align_v = align_h, align_v 60 | self.bg_color = self._normalize_color(bg_color) 61 | self.pressed_color = self._normalize_color(pressed_color) 62 | self.text_color = self._normalize_color(text_color) 63 | self.border_color = self._normalize_color(border_color) 64 | self.is_pressed = False 65 | self.click_armed = False 66 | self.disp_rect = [0, 0, 0, 0] 67 | 68 | def _is_in_rect(self, x: int, y: int, rect: list[int]): 69 | """检查坐标 (x, y) 是否在指定的矩形区域内。""" 70 | return rect[0] < x < rect[0] + rect[2] and \ 71 | rect[1] < y < rect[1] + rect[3] 72 | 73 | def draw(self, img: image.Image): 74 | """在指定的图像上绘制按钮。 75 | 76 | Args: 77 | img (maix.image.Image): 将要绘制按钮的目标图像。 78 | """ 79 | current_bg_color = self.pressed_color if self.is_pressed else self.bg_color 80 | if current_bg_color is not None: 81 | img.draw_rect(*self.rect, color=current_bg_color, thickness=-1) 82 | if self.border_thickness > 0: 83 | img.draw_rect( 84 | *self.rect, 85 | color=self.border_color, 86 | thickness=self.border_thickness) 87 | 88 | font_arg = self.font if self.font is not None else "" 89 | text_size = image.string_size( 90 | self.label, scale=self.text_scale, font=font_arg) 91 | 92 | if self.align_h == 'center': 93 | text_x = self.rect[0] + (self.rect[2] - text_size[0]) // 2 94 | elif self.align_h == 'left': 95 | text_x = self.rect[0] + self.border_thickness + 5 96 | else: 97 | text_x = self.rect[0] + self.rect[2] - text_size[0] - \ 98 | self.border_thickness - 5 99 | 100 | if self.align_v == 'center': 101 | text_y = self.rect[1] + (self.rect[3] - text_size[1]) // 2 102 | elif self.align_v == 'top': 103 | text_y = self.rect[1] + self.border_thickness + 5 104 | else: 105 | text_y = self.rect[1] + self.rect[3] - text_size[1] - \ 106 | self.border_thickness - 5 107 | 108 | img.draw_string( 109 | text_x, text_y, self.label, color=self.text_color, 110 | scale=self.text_scale, font=font_arg) 111 | 112 | def handle_event(self, x: int, y: int, pressed: bool | int, img_w: int, img_h: int, disp_w: int, disp_h: int): 113 | """处理触摸事件并更新按钮状态。 114 | 115 | Args: 116 | x (int): 触摸点的 X 坐标。 117 | y (int): 触摸点的 Y 坐标。 118 | pressed (bool | int): 触摸屏是否被按下。 119 | img_w (int): 图像缓冲区的宽度。 120 | img_h (int): 图像缓冲区的高度。 121 | disp_w (int): 显示屏的宽度。 122 | disp_h (int): 显示屏的高度。 123 | """ 124 | self.disp_rect = image.resize_map_pos( 125 | img_w, img_h, disp_w, disp_h, image.Fit.FIT_CONTAIN, *self.rect) 126 | is_hit = self._is_in_rect(x, y, self.disp_rect) 127 | if pressed: 128 | if is_hit: 129 | if not self.click_armed: 130 | self.click_armed = True 131 | self.is_pressed = True 132 | else: 133 | self.is_pressed = False 134 | self.click_armed = False 135 | else: 136 | if self.click_armed and is_hit: 137 | if self.callback is not None: 138 | self.callback() 139 | self.is_pressed = False 140 | self.click_armed = False 141 | 142 | 143 | class ButtonManager: 144 | """管理一组按钮的事件处理和绘制。""" 145 | 146 | def __init__(self, ts: touchscreen.TouchScreen, disp: display.Display): 147 | """初始化按钮管理器。 148 | 149 | Args: 150 | ts (maix.touchscreen.TouchScreen): 触摸屏设备实例。 151 | disp (maix.display.Display): 显示设备实例。 152 | """ 153 | self.ts = ts 154 | self.disp = disp 155 | self.buttons = [] 156 | 157 | def add_button(self, button: Button): 158 | """向管理器中添加一个按钮。 159 | 160 | Args: 161 | button (Button): 要添加的 Button 实例。 162 | 163 | Raises: 164 | TypeError: 如果添加的对象不是 Button 类的实例。 165 | """ 166 | if isinstance(button, Button): 167 | self.buttons.append(button) 168 | else: 169 | raise TypeError("只能添加 Button 类的实例") 170 | 171 | def handle_events(self, img: image.Image): 172 | """处理所有受管按钮的事件并进行绘制。 173 | 174 | Args: 175 | img (maix.image.Image): 绘制按钮的目标图像。 176 | """ 177 | x, y, pressed = self.ts.read() 178 | img_w, img_h = img.width(), img.height() 179 | disp_w, disp_h = self.disp.width(), self.disp.height() 180 | for btn in self.buttons: 181 | btn.handle_event(x, y, pressed, img_w, img_h, disp_w, disp_h) 182 | btn.draw(img) -------------------------------------------------------------------------------- /src/maixpy_ui/components/checkbox.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Aristore' 3 | 4 | import maix.image as image 5 | import maix.touchscreen as touchscreen 6 | import maix.display as display 7 | from typing import Callable, Sequence 8 | 9 | class Checkbox: 10 | """创建一个复选框(Checkbox)组件,可独立选中或取消。""" 11 | BASE_BOX_SIZE, BASE_TEXT_SCALE, BASE_SPACING = 25, 1.2, 10 12 | 13 | def _normalize_color(self, color: Sequence[int] | None): 14 | """将元组颜色转换为 maix.image.Color 对象。""" 15 | if color is None: 16 | return None 17 | if isinstance(color, tuple): 18 | if len(color) == 3: 19 | return image.Color.from_rgb(color[0], color[1], color[2]) 20 | else: 21 | raise ValueError("颜色元组必须是 3 个元素的 RGB 格式") 22 | return color 23 | 24 | def __init__(self, position: Sequence[int], label: str, scale: float=1.0, is_checked: bool | int=False, 25 | callback: Callable | None=None, box_color: Sequence[int]=(200, 200, 200), 26 | box_checked_color: Sequence[int]=(0, 120, 220), 27 | check_color: Sequence[int]=(255, 255, 255), 28 | text_color: Sequence[int]=(200, 200, 200), box_thickness: int=2): 29 | """初始化一个复选框。 30 | 31 | Args: 32 | position (Sequence[int]): 复选框的左上角坐标 `[x, y]`。 33 | label (str): 复选框旁边的标签文本。 34 | scale (float): 复选框的整体缩放比例。 35 | is_checked (bool | int): 复选框的初始状态,True 为选中。 36 | callback (callable | None, optional): 状态切换时调用的函数, 37 | 接收一个布尔值参数表示新状态。 38 | box_color (Sequence[int]): 未选中时方框的颜色 (R, G, B)。 39 | box_checked_color (Sequence[int]): 选中时方框的颜色 (R, G, B)。 40 | check_color (Sequence[int]): 选中标记(对勾)的颜色 (R, G, B)。 41 | text_color (Sequence[int]): 标签文本的颜色 (R, G, B)。 42 | box_thickness (int): 方框边框的厚度。 43 | 44 | Raises: 45 | ValueError: 如果 `position` 无效。 46 | TypeError: 如果 `callback` 不是可调用对象或 None。 47 | """ 48 | if not isinstance(position, (list, tuple)) or len(position) != 2: 49 | raise ValueError("position 必须是包含两个整数 [x, y] 的列表或元组") 50 | if callback is not None and not callable(callback): 51 | raise TypeError("callback 必须是一个可调用的函数或 None") 52 | self.pos, self.label, self.scale = position, label, scale 53 | self.is_checked, self.callback = is_checked, callback 54 | self.box_size = int(self.BASE_BOX_SIZE * scale) 55 | self.text_scale = self.BASE_TEXT_SCALE * scale 56 | self.spacing = int(self.BASE_SPACING * scale) 57 | self.box_thickness = int(box_thickness * scale) 58 | touch_padding_y = 5 59 | # The touchable area for the box 60 | self.rect = [ 61 | self.pos[0], self.pos[1] - touch_padding_y, 62 | self.box_size, self.box_size + 2 * touch_padding_y 63 | ] 64 | self.box_color = self._normalize_color(box_color) 65 | self.box_checked_color = self._normalize_color(box_checked_color) 66 | self.check_color = self._normalize_color(check_color) 67 | self.text_color = self._normalize_color(text_color) 68 | self.click_armed = False 69 | self.disp_rect = [0, 0, 0, 0] 70 | 71 | def _is_in_rect(self, x: int, y: int, rect: Sequence[int]): 72 | """检查坐标 (x, y) 是否在指定的矩形区域内。""" 73 | return rect[0] < x < rect[0] + rect[2] and \ 74 | rect[1] < y < rect[1] + rect[3] 75 | 76 | def toggle(self): 77 | """切换复选框的选中状态,并执行回调。""" 78 | self.is_checked = not self.is_checked 79 | if self.callback: 80 | self.callback(self.is_checked) 81 | 82 | def draw(self, img: image.Image): 83 | """在指定的图像上绘制复选框。 84 | 85 | Args: 86 | img (maix.image.Image): 将要绘制复选框的目标图像。 87 | """ 88 | box_x, box_y = self.pos 89 | text_size = image.string_size(self.label, scale=self.text_scale) 90 | total_h = max(self.box_size, text_size.height()) 91 | box_offset_y = (total_h - self.box_size) // 2 92 | text_offset_y = (total_h - text_size.height()) // 2 93 | box_draw_y = box_y + box_offset_y 94 | text_draw_y = box_y + text_offset_y 95 | text_draw_x = box_x + self.box_size + self.spacing 96 | 97 | current_box_color = self.box_checked_color if self.is_checked else self.box_color 98 | if self.is_checked: 99 | img.draw_rect(box_x, box_draw_y, self.box_size, self.box_size, color=current_box_color, thickness=-1) 100 | img.draw_rect(box_x, box_draw_y, self.box_size, self.box_size, color=current_box_color, thickness=self.box_thickness) 101 | 102 | if self.is_checked: 103 | # Draw a check mark 104 | p1 = (box_x + int(self.box_size * 0.2), 105 | box_draw_y + int(self.box_size * 0.5)) 106 | p2 = (box_x + int(self.box_size * 0.45), 107 | box_draw_y + int(self.box_size * 0.75)) 108 | p3 = (box_x + int(self.box_size * 0.8), 109 | box_draw_y + int(self.box_size * 0.25)) 110 | check_thickness = max(1, int(2 * self.scale)) 111 | img.draw_line(p1[0], p1[1], p2[0], p2[1], color=self.check_color, thickness=check_thickness) 112 | img.draw_line(p2[0], p2[1], p3[0], p3[1], color=self.check_color, thickness=check_thickness) 113 | 114 | img.draw_string(text_draw_x, text_draw_y, self.label, color=self.text_color, scale=self.text_scale) 115 | 116 | def handle_event(self, x: int, y: int, pressed: bool | int, img_w: int, img_h: int, disp_w: int, disp_h: int): 117 | """处理触摸事件并更新复选框状态。 118 | 119 | Args: 120 | x (int): 触摸点的 X 坐标。 121 | y (int): 触摸点的 Y 坐标。 122 | pressed (bool | int): 触摸屏是否被按下。 123 | img_w (int): 图像缓冲区的宽度。 124 | img_h (int): 图像缓冲区的高度。 125 | disp_w (int): 显示屏的宽度。 126 | disp_h (int): 显示屏的高度。 127 | """ 128 | self.disp_rect = image.resize_map_pos(img_w, img_h, disp_w, disp_h, image.Fit.FIT_CONTAIN, *self.rect) 129 | is_hit = self._is_in_rect(x, y, self.disp_rect) 130 | if pressed: 131 | if is_hit and not self.click_armed: 132 | self.click_armed = True 133 | else: 134 | if self.click_armed and is_hit: 135 | self.toggle() 136 | self.click_armed = False 137 | 138 | 139 | class CheckboxManager: 140 | """管理一组复选框的事件处理和绘制。""" 141 | 142 | def __init__(self, ts: touchscreen.TouchScreen, disp: display.Display): 143 | """初始化复选框管理器。 144 | 145 | Args: 146 | ts (maix.touchscreen.TouchScreen): 触摸屏设备实例。 147 | disp (maix.display.Display): 显示设备实例。 148 | """ 149 | self.ts = ts 150 | self.disp = disp 151 | self.checkboxes = [] 152 | 153 | def add_checkbox(self, checkbox: Checkbox): 154 | """向管理器中添加一个复选框。 155 | 156 | Args: 157 | checkbox (Checkbox): 要添加的 Checkbox 实例。 158 | 159 | Raises: 160 | TypeError: 如果添加的对象不是 Checkbox 类的实例。 161 | """ 162 | if isinstance(checkbox, Checkbox): 163 | self.checkboxes.append(checkbox) 164 | else: 165 | raise TypeError("只能添加 Checkbox 类的实例") 166 | 167 | def handle_events(self, img: image.Image): 168 | """处理所有受管复选框的事件并进行绘制。 169 | 170 | Args: 171 | img (maix.image.Image): 绘制复选框的目标图像。 172 | """ 173 | x, y, pressed = self.ts.read() 174 | img_w, img_h = img.width(), img.height() 175 | disp_w, disp_h = self.disp.width(), self.disp.height() 176 | for cb in self.checkboxes: 177 | cb.handle_event(x, y, pressed, img_w, img_h, disp_w, disp_h) 178 | cb.draw(img) -------------------------------------------------------------------------------- /src/maixpy_ui/components/radio.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Aristore' 3 | 4 | import maix.image as image 5 | import maix.touchscreen as touchscreen 6 | import maix.display as display 7 | from typing import Callable, Sequence 8 | 9 | class RadioButton: 10 | """创建一个单选按钮(RadioButton)项。 11 | 12 | 通常与 RadioManager 结合使用,以形成一个单选按钮组。 13 | """ 14 | BASE_CIRCLE_RADIUS, BASE_TEXT_SCALE, BASE_SPACING = 12, 1.2, 10 15 | 16 | def _normalize_color(self, color: Sequence[int] | None): 17 | """将元组颜色转换为 maix.image.Color 对象。""" 18 | if color is None: 19 | return None 20 | if isinstance(color, tuple): 21 | if len(color) == 3: 22 | return image.Color.from_rgb(color[0], color[1], color[2]) 23 | else: 24 | raise ValueError("颜色元组必须是 3 个元素的 RGB 格式") 25 | return color 26 | 27 | def __init__(self, position: Sequence[int], label: str, value, scale: float=1.0, 28 | circle_color: Sequence[int]=(200, 200, 200), 29 | circle_selected_color: Sequence[int]=(0, 120, 220), 30 | dot_color: Sequence[int]=(255, 255, 255), 31 | text_color: Sequence[int]=(200, 200, 200), circle_thickness: int=2): 32 | """初始化一个单选按钮项。 33 | 34 | Args: 35 | position (Sequence[int]): 单选按钮圆圈的左上角坐标 `[x, y]`。 36 | label (str): 按钮旁边的标签文本。 37 | value (any): 与此单选按钮关联的唯一值。 38 | scale (float): 组件的整体缩放比例。 39 | circle_color (Sequence[int]): 未选中时圆圈的颜色 (R, G, B)。 40 | circle_selected_color (Sequence[int]): 选中时圆圈的颜色 (R, G, B)。 41 | dot_color (Sequence[int]): 选中时中心圆点的颜色 (R, G, B)。 42 | text_color (Sequence[int]): 标签文本的颜色 (R, G, B)。 43 | circle_thickness (int): 圆圈边框的厚度。 44 | """ 45 | self.pos, self.label, self.value, self.scale = position, label, value, scale 46 | self.is_selected = False 47 | self.radius = int(self.BASE_CIRCLE_RADIUS * scale) 48 | self.text_scale = self.BASE_TEXT_SCALE * scale 49 | self.spacing = int(self.BASE_SPACING * scale) 50 | self.circle_thickness = int(circle_thickness * scale) 51 | # Centered touch area around the circle 52 | self.rect = [self.pos[0], self.pos[1], 2 * self.radius, 2 * self.radius] 53 | self.circle_color = self._normalize_color(circle_color) 54 | self.circle_selected_color = self._normalize_color(circle_selected_color) 55 | self.dot_color = self._normalize_color(dot_color) 56 | self.text_color = self._normalize_color(text_color) 57 | self.click_armed = False 58 | 59 | def draw(self, img: image.Image): 60 | """在指定的图像上绘制单选按钮。 61 | 62 | Args: 63 | img (maix.image.Image): 将要绘制单选按钮的目标图像。 64 | """ 65 | center_x, center_y = self.pos[0] + self.radius, self.pos[1] + self.radius 66 | current_circle_color = self.circle_selected_color if self.is_selected else self.circle_color 67 | 68 | img.draw_circle(center_x, center_y, self.radius, color=current_circle_color, thickness=self.circle_thickness) 69 | 70 | if self.is_selected: 71 | dot_radius = max(2, self.radius // 2) 72 | img.draw_circle(center_x, center_y, dot_radius, color=self.dot_color, thickness=-1) 73 | 74 | text_size = image.string_size(self.label, scale=self.text_scale) 75 | text_x = self.pos[0] + 2 * self.radius + self.spacing 76 | text_y = center_y - text_size.height() // 2 77 | img.draw_string(text_x, text_y, self.label, color=self.text_color, scale=self.text_scale) 78 | 79 | 80 | class RadioManager: 81 | """管理一个单选按钮组,确保只有一个按钮能被选中。""" 82 | 83 | def __init__(self, ts: touchscreen.TouchScreen, disp: display.Display, default_value=None, callback: Callable | None=None): 84 | """初始化单选按钮管理器。 85 | 86 | Args: 87 | ts (maix.touchscreen.TouchScreen): 触摸屏设备实例。 88 | disp (maix.display.Display): 显示设备实例。 89 | default_value (any, optional): 默认选中的按钮的值。 90 | callback (callable | None, optional): 选中项改变时调用的函数, 91 | 接收新选中项的值作为参数。 92 | """ 93 | self.ts = ts 94 | self.disp = disp 95 | self.radios = [] 96 | self.selected_value = default_value 97 | self.callback = callback 98 | self.disp_rects = {} 99 | 100 | def add_radio(self, radio: RadioButton): 101 | """向管理器中添加一个单选按钮。 102 | 103 | Args: 104 | radio (RadioButton): 要添加的 RadioButton 实例。 105 | 106 | Raises: 107 | TypeError: 如果添加的对象不是 RadioButton 类的实例。 108 | """ 109 | if isinstance(radio, RadioButton): 110 | self.radios.append(radio) 111 | if radio.value == self.selected_value: 112 | radio.is_selected = True 113 | else: 114 | raise TypeError("只能添加 RadioButton 类的实例") 115 | 116 | def _select_radio(self, value): 117 | """选中指定的单选按钮,并取消其他按钮的选中状态。""" 118 | if self.selected_value != value: 119 | self.selected_value = value 120 | for r in self.radios: 121 | r.is_selected = (r.value == self.selected_value) 122 | if self.callback: 123 | self.callback(self.selected_value) 124 | 125 | def _is_in_rect(self, x: int, y: int, rect: Sequence[int]): 126 | """检查坐标 (x, y) 是否在指定的矩形区域内。""" 127 | return rect[0] < x < rect[0] + rect[2] and \ 128 | rect[1] < y < rect[1] + rect[3] 129 | 130 | def handle_events(self, img: image.Image): 131 | """处理所有单选按钮的事件并进行绘制。 132 | 133 | Args: 134 | img (maix.image.Image): 绘制单选按钮的目标图像。 135 | """ 136 | x, y, pressed = self.ts.read() 137 | img_w, img_h = img.width(), img.height() 138 | disp_w, disp_h = self.disp.width(), self.disp.height() 139 | 140 | for r in self.radios: 141 | self.disp_rects[r.value] = image.resize_map_pos(img_w, img_h, disp_w, disp_h, image.Fit.FIT_CONTAIN, *r.rect) 142 | 143 | if pressed: 144 | for r in self.radios: 145 | if self._is_in_rect(x, y, self.disp_rects[r.value]) and not r.click_armed: 146 | r.click_armed = True 147 | else: 148 | for r in self.radios: 149 | if r.click_armed and self._is_in_rect(x, y, self.disp_rects[r.value]): 150 | self._select_radio(r.value) 151 | r.click_armed = False 152 | 153 | for r in self.radios: 154 | r.draw(img) -------------------------------------------------------------------------------- /src/maixpy_ui/components/slider.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Aristore' 3 | 4 | import maix.image as image 5 | import maix.touchscreen as touchscreen 6 | import maix.display as display 7 | from typing import Callable, Sequence 8 | 9 | class Slider: 10 | """创建一个可拖动的滑块组件,用于在一定范围内选择一个值。""" 11 | BASE_HANDLE_RADIUS = 10 12 | BASE_HANDLE_BORDER_THICKNESS = 2 13 | BASE_HANDLE_PRESSED_RADIUS_INCREASE = 3 14 | BASE_TRACK_HEIGHT = 6 15 | BASE_LABEL_SCALE = 1.2 16 | BASE_TOOLTIP_SCALE = 1.2 17 | BASE_TOUCH_PADDING_Y = 10 18 | 19 | def _normalize_color(self, color: Sequence[int] | None): 20 | """将元组颜色转换为 maix.image.Color 对象。""" 21 | if color is None: 22 | return None 23 | if isinstance(color, tuple): 24 | if len(color) == 3: 25 | return image.Color.from_rgb(color[0], color[1], color[2]) 26 | else: 27 | raise ValueError("颜色元组必须是 3 个元素的 RGB 格式") 28 | return color 29 | 30 | def __init__(self, rect: Sequence[int], scale: float=1.0, min_val: int=0, max_val: int=100, default_val: int=50, 31 | callback: Callable | None=None, label: str="", track_color: Sequence[int]=(60, 60, 60), 32 | progress_color: Sequence[int]=(0, 120, 220), handle_color: Sequence[int]=(255, 255, 255), 33 | handle_border_color: Sequence[int]=(100, 100, 100), 34 | handle_pressed_color: Sequence[int]=(220, 220, 255), 35 | label_color: Sequence[int]=(200, 200, 200), 36 | tooltip_bg_color: Sequence[int]=(0, 0, 0), 37 | tooltip_text_color: Sequence[int]=(255, 255, 255), 38 | show_tooltip_on_drag: bool | int=True): 39 | """初始化一个滑块。 40 | 41 | Args: 42 | rect (Sequence[int]): 滑块的位置和尺寸 `[x, y, w, h]`。 43 | scale (float): 滑块的整体缩放比例。 44 | min_val (int): 滑块的最小值。 45 | max_val (int): 滑块的最大值。 46 | default_val (int): 滑块的默认值。 47 | callback (callable | None, optional): 值改变时调用的函数。 48 | label (str): 滑块上方的标签文本。 49 | track_color (Sequence[int]): 滑轨背景颜色 (R, G, B)。 50 | progress_color (Sequence[int]): 滑轨进度条颜色 (R, G, B)。 51 | handle_color (Sequence[int]): 滑块手柄颜色 (R, G, B)。 52 | handle_border_color (Sequence[int]): 滑块手柄边框颜色 (R, G, B)。 53 | handle_pressed_color (Sequence[int]): 按下时手柄颜色 (R, G, B)。 54 | label_color (Sequence[int]): 标签文本颜色 (R, G, B)。 55 | tooltip_bg_color (Sequence[int]): 拖动时提示框背景色 (R, G, B)。 56 | tooltip_text_color (Sequence[int]): 拖动时提示框文本颜色 (R, G, B)。 57 | show_tooltip_on_drag (bool | int): 是否在拖动时显示数值提示框。 58 | 59 | Raises: 60 | ValueError: 如果 `rect` 无效,或 `min_val` 不小于 `max_val`, 61 | 或 `default_val` 不在范围内。 62 | TypeError: 如果 `callback` 不是可调用对象或 None。 63 | """ 64 | if not all(isinstance(i, int) for i in rect) or len(rect) != 4: 65 | raise ValueError("rect 必须是包含四个整数 [x, y, w, h] 的列表") 66 | if not min_val < max_val: 67 | raise ValueError("min_val 必须小于 max_val") 68 | if not min_val <= default_val <= max_val: 69 | raise ValueError("default_val 必须在 min_val 和 max_val 之间") 70 | if callback is not None and not callable(callback): 71 | raise TypeError("callback 必须是一个可调用的函数或 None") 72 | 73 | self.rect = rect 74 | self.min_val, self.max_val, self.value = min_val, max_val, default_val 75 | self.callback, self.label, self.scale = callback, label, scale 76 | self.show_tooltip_on_drag = show_tooltip_on_drag 77 | 78 | # Scale UI elements based on the scale factor 79 | self.handle_radius = int(self.BASE_HANDLE_RADIUS * scale) 80 | self.handle_border_thickness = int(self.BASE_HANDLE_BORDER_THICKNESS * scale) 81 | self.handle_pressed_radius_increase = int(self.BASE_HANDLE_PRESSED_RADIUS_INCREASE * scale) 82 | self.track_height = int(self.BASE_TRACK_HEIGHT * scale) 83 | self.label_scale = self.BASE_LABEL_SCALE * scale 84 | self.tooltip_scale = self.BASE_TOOLTIP_SCALE * scale 85 | self.touch_padding_y = int(self.BASE_TOUCH_PADDING_Y * scale) 86 | 87 | # Normalize colors 88 | self.track_color = self._normalize_color(track_color) 89 | self.progress_color = self._normalize_color(progress_color) 90 | self.handle_color = self._normalize_color(handle_color) 91 | self.handle_border_color = self._normalize_color(handle_border_color) 92 | self.handle_pressed_color = self._normalize_color(handle_pressed_color) 93 | self.label_color = self._normalize_color(label_color) 94 | self.tooltip_bg_color = self._normalize_color(tooltip_bg_color) 95 | self.tooltip_text_color = self._normalize_color(tooltip_text_color) 96 | 97 | self.is_pressed = False 98 | self.disp_rect = [0, 0, 0, 0] 99 | 100 | def _is_in_rect(self, x: int, y: int, rect: Sequence[int]): 101 | """检查坐标 (x, y) 是否在指定的矩形区域内。""" 102 | return rect[0] < x < rect[0] + rect[2] and \ 103 | rect[1] < y < rect[1] + rect[3] 104 | 105 | def draw(self, img: image.Image): 106 | """在指定的图像上绘制滑块。 107 | 108 | Args: 109 | img (maix.image.Image): 将要绘制滑块的目标图像。 110 | """ 111 | track_start_x, track_width, track_center_y = self.rect[0], self.rect[2], self.rect[1] + self.rect[3] // 2 112 | if track_width <= 0: 113 | return 114 | 115 | value_fraction = (self.value - self.min_val) / (self.max_val - self.min_val) 116 | handle_center_x = track_start_x + value_fraction * track_width 117 | 118 | if self.label: 119 | label_size = image.string_size(self.label, scale=self.label_scale) 120 | label_y = self.rect[1] - label_size.height() - int(5 * self.scale) 121 | img.draw_string(track_start_x, label_y, self.label, color=self.label_color, scale=self.label_scale) 122 | 123 | track_y = track_center_y - self.track_height // 2 124 | img.draw_rect(track_start_x, track_y, track_width, self.track_height, color=self.track_color, thickness=-1) 125 | 126 | progress_width = int(value_fraction * track_width) 127 | if progress_width > 0: 128 | img.draw_rect(track_start_x, track_y, progress_width, self.track_height, color=self.progress_color, thickness=-1) 129 | 130 | current_radius = self.handle_radius + (self.handle_pressed_radius_increase if self.is_pressed else 0) 131 | current_handle_color = self.handle_pressed_color if self.is_pressed else self.handle_color 132 | 133 | border_thickness = min(self.handle_border_thickness, current_radius) 134 | if border_thickness > 0: 135 | img.draw_circle( 136 | int(handle_center_x), track_center_y, current_radius, 137 | color=self.handle_border_color, thickness=-1) 138 | img.draw_circle( 139 | int(handle_center_x), track_center_y, 140 | current_radius - border_thickness, 141 | color=current_handle_color, thickness=-1) 142 | 143 | if self.is_pressed and self.show_tooltip_on_drag: 144 | value_text = str(int(self.value)) 145 | text_size = image.string_size( 146 | value_text, scale=self.tooltip_scale) 147 | padding = int(5 * self.scale) 148 | box_w = text_size.width() + 2 * padding 149 | box_h = text_size.height() + 2 * padding 150 | box_x = int(handle_center_x - box_w // 2) 151 | box_y = self.rect[1] - box_h - int(10 * self.scale) 152 | img.draw_rect( 153 | box_x, box_y, box_w, box_h, 154 | color=self.tooltip_bg_color, thickness=-1) 155 | img.draw_string( 156 | box_x + padding, box_y + padding, value_text, 157 | color=self.tooltip_text_color, scale=self.tooltip_scale) 158 | 159 | def handle_event(self, x: int, y: int, pressed: bool | int, img_w: int, img_h: int, disp_w: int, disp_h: int): 160 | """处理触摸事件并更新滑块状态。 161 | 162 | Args: 163 | x (int): 触摸点的 X 坐标。 164 | y (int): 触摸点的 Y 坐标。 165 | pressed (bool | int): 触摸屏是否被按下。 166 | img_w (int): 图像缓冲区的宽度。 167 | img_h (int): 图像缓冲区的高度。 168 | disp_w (int): 显示屏的宽度。 169 | disp_h (int): 显示屏的高度。 170 | """ 171 | touch_rect = [ 172 | self.rect[0], self.rect[1] - self.touch_padding_y, 173 | self.rect[2], self.rect[3] + 2 * self.touch_padding_y 174 | ] 175 | self.disp_rect = image.resize_map_pos(img_w, img_h, disp_w, disp_h, image.Fit.FIT_CONTAIN, *touch_rect) 176 | is_hit = self._is_in_rect(x, y, self.disp_rect) 177 | 178 | if self.is_pressed and not pressed: 179 | self.is_pressed = False 180 | return 181 | 182 | if (pressed and is_hit) or self.is_pressed: 183 | self.is_pressed = True 184 | mapped_track_rect = image.resize_map_pos(img_w, img_h, disp_w, disp_h, image.Fit.FIT_CONTAIN, *self.rect) 185 | disp_track_start_x, disp_track_width = mapped_track_rect[0], mapped_track_rect[2] 186 | if disp_track_width <= 0: 187 | return 188 | 189 | clamped_x = max(disp_track_start_x, min(x, disp_track_start_x + disp_track_width)) 190 | pos_fraction = (clamped_x - disp_track_start_x) / disp_track_width 191 | new_value = self.min_val + pos_fraction * (self.max_val - self.min_val) 192 | new_value_int = int(round(new_value)) 193 | 194 | if new_value_int != self.value: 195 | self.value = new_value_int 196 | if self.callback: 197 | self.callback(self.value) 198 | else: 199 | self.is_pressed = False 200 | 201 | 202 | class SliderManager: 203 | """管理一组滑块的事件处理和绘制。""" 204 | 205 | def __init__(self, ts: touchscreen.TouchScreen, disp: display.Display): 206 | """初始化滑块管理器。 207 | 208 | Args: 209 | ts (maix.touchscreen.TouchScreen): 触摸屏设备实例。 210 | disp (maix.display.Display): 显示设备实例。 211 | """ 212 | self.ts = ts 213 | self.disp = disp 214 | self.sliders = [] 215 | 216 | def add_slider(self, slider: Slider): 217 | """向管理器中添加一个滑块。 218 | 219 | Args: 220 | slider (Slider): 要添加的 Slider 实例。 221 | 222 | Raises: 223 | TypeError: 如果添加的对象不是 Slider 类的实例。 224 | """ 225 | if isinstance(slider, Slider): 226 | self.sliders.append(slider) 227 | else: 228 | raise TypeError("只能添加 Slider 类的实例") 229 | 230 | def handle_events(self, img: image.Image): 231 | """处理所有受管滑块的事件并进行绘制。 232 | 233 | Args: 234 | img (maix.image.Image): 绘制滑块的目标图像。 235 | """ 236 | x, y, pressed = self.ts.read() 237 | img_w, img_h = img.width(), img.height() 238 | disp_w, disp_h = self.disp.width(), self.disp.height() 239 | for s in self.sliders: 240 | s.handle_event(x, y, pressed, img_w, img_h, disp_w, disp_h) 241 | s.draw(img) -------------------------------------------------------------------------------- /src/maixpy_ui/components/switch.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'Aristore' 3 | 4 | import maix.image as image 5 | import maix.touchscreen as touchscreen 6 | import maix.display as display 7 | from typing import Callable, Sequence 8 | 9 | class Switch: 10 | """创建一个开关(Switch)组件,用于在开/关两种状态之间切换。""" 11 | BASE_H, BASE_W = 30, int(30 * 1.9) 12 | 13 | def _normalize_color(self, color: Sequence[int] | None): 14 | """将元组颜色转换为 maix.image.Color 对象。""" 15 | if color is None: 16 | return None 17 | if isinstance(color, tuple): 18 | if len(color) == 3: 19 | return image.Color.from_rgb(color[0], color[1], color[2]) 20 | else: 21 | raise ValueError("颜色元组必须是 3 个元素的 RGB 格式") 22 | return color 23 | 24 | def __init__(self, position: Sequence[int], scale: float=1.0, is_on: bool | int=False, callback: Callable | None=None, 25 | on_color: Sequence[int]=(30, 200, 30), off_color: Sequence[int]=(100, 100, 100), 26 | handle_color: Sequence[int]=(255, 255, 255), 27 | handle_pressed_color: Sequence[int]=(220, 220, 255), 28 | handle_radius_increase: int=2): 29 | """初始化一个开关组件。 30 | 31 | Args: 32 | position (Sequence[int]): 开关的左上角坐标 `[x, y]`。 33 | scale (float): 开关的整体缩放比例。 34 | is_on (bool | int): 开关的初始状态,True 为开,False 为关。 35 | callback (callable | None, optional): 状态切换时调用的函数, 36 | 接收一个布尔值参数表示新状态。 37 | on_color (Sequence[int]): 开启状态下的背景颜色 (R, G, B)。 38 | off_color (Sequence[int]): 关闭状态下的背景颜色 (R, G, B)。 39 | handle_color (Sequence[int]): 手柄的颜色 (R, G, B)。 40 | handle_pressed_color (Sequence[int]): 按下时手柄的颜色 (R, G, B)。 41 | handle_radius_increase (int): 按下时手柄半径增加量。 42 | 43 | Raises: 44 | ValueError: 如果 `position` 不是包含两个整数的列表或元组。 45 | TypeError: 如果 `callback` 不是可调用对象或 None。 46 | """ 47 | if not isinstance(position, (list, tuple)) or len(position) != 2: 48 | raise ValueError("position 必须是包含两个整数 [x, y] 的列表或元组") 49 | if callback is not None and not callable(callback): 50 | raise TypeError("callback 必须是一个可调用的函数或 None") 51 | self.pos, self.scale, self.is_on, self.callback = position, scale, is_on, callback 52 | self.width = int(self.BASE_W * scale) 53 | self.height = int(self.BASE_H * scale) 54 | self.rect = [self.pos[0], self.pos[1], self.width, self.height] 55 | self.on_color = self._normalize_color(on_color) 56 | self.off_color = self._normalize_color(off_color) 57 | self.handle_color = self._normalize_color(handle_color) 58 | self.handle_pressed_color = self._normalize_color(handle_pressed_color) 59 | self.handle_radius_increase = int(handle_radius_increase * scale) 60 | self.is_pressed = False 61 | self.click_armed = False 62 | self.disp_rect = [0, 0, 0, 0] 63 | 64 | def _is_in_rect(self, x: int, y: int, rect: Sequence[int]): 65 | """检查坐标 (x, y) 是否在指定的矩形区域内。""" 66 | return rect[0] < x < rect[0] + rect[2] and \ 67 | rect[1] < y < rect[1] + rect[3] 68 | 69 | def toggle(self): 70 | """切换开关的状态,并执行回调函数。""" 71 | self.is_on = not self.is_on 72 | if self.callback: 73 | self.callback(self.is_on) 74 | 75 | def draw(self, img: image.Image): 76 | """在指定的图像上绘制开关。 77 | 78 | Args: 79 | img (maix.image.Image): 将要绘制开关的目标图像。 80 | """ 81 | track_x, track_y, track_w, track_h = self.rect 82 | track_center_y = track_y + track_h // 2 83 | handle_radius = track_h // 2 84 | current_bg_color = self.on_color if self.is_on else self.off_color 85 | 86 | # Draw rounded track 87 | img.draw_circle(track_x + handle_radius, track_center_y, handle_radius, color=current_bg_color, thickness=-1) 88 | img.draw_circle(track_x + track_w - handle_radius, track_center_y, handle_radius, color=current_bg_color, thickness=-1) 89 | img.draw_rect(track_x + handle_radius, track_y, track_w - 2 * handle_radius, track_h, color=current_bg_color, thickness=-1) 90 | 91 | # Draw handle 92 | handle_pos_x = (track_x + track_w - handle_radius) if self.is_on else (track_x + handle_radius) 93 | current_handle_color = self.handle_pressed_color if self.is_pressed else self.handle_color 94 | padding = int(2 * self.scale) 95 | current_handle_radius = handle_radius - padding + (self.handle_radius_increase if self.is_pressed else 0) 96 | img.draw_circle(handle_pos_x, track_center_y, current_handle_radius, color=current_handle_color, thickness=-1) 97 | 98 | def handle_event(self, x: int, y: int, pressed: bool | int, img_w: int, img_h: int, disp_w: int, disp_h: int): 99 | """处理触摸事件并更新开关状态。 100 | 101 | Args: 102 | x (int): 触摸点的 X 坐标。 103 | y (int): 触摸点的 Y 坐标。 104 | pressed (bool | int): 触摸屏是否被按下。 105 | img_w (int): 图像缓冲区的宽度。 106 | img_h (int): 图像缓冲区的高度。 107 | disp_w (int): 显示屏的宽度。 108 | disp_h (int): 显示屏的高度。 109 | """ 110 | self.disp_rect = image.resize_map_pos(img_w, img_h, disp_w, disp_h, image.Fit.FIT_CONTAIN, *self.rect) 111 | is_hit = self._is_in_rect(x, y, self.disp_rect) 112 | 113 | if pressed: 114 | if is_hit and not self.click_armed: 115 | self.is_pressed = True 116 | self.click_armed = True 117 | else: 118 | if self.click_armed and is_hit: 119 | self.toggle() 120 | self.is_pressed, self.click_armed = False, False 121 | 122 | 123 | class SwitchManager: 124 | """管理一组开关的事件处理和绘制。""" 125 | 126 | def __init__(self, ts: touchscreen.TouchScreen, disp: display.Display): 127 | """初始化开关管理器。 128 | 129 | Args: 130 | ts (maix.touchscreen.TouchScreen): 触摸屏设备实例。 131 | disp (maix.display.Display): 显示设备实例。 132 | """ 133 | self.ts = ts 134 | self.disp = disp 135 | self.switches = [] 136 | 137 | def add_switch(self, switch: Switch): 138 | """向管理器中添加一个开关。 139 | 140 | Args: 141 | switch (Switch): 要添加的 Switch 实例。 142 | 143 | Raises: 144 | TypeError: 如果添加的对象不是 Switch 类的实例。 145 | """ 146 | if isinstance(switch, Switch): 147 | self.switches.append(switch) 148 | else: 149 | raise TypeError("只能添加 Switch 类的实例") 150 | 151 | def handle_events(self, img: image.Image): 152 | """处理所有受管开关的事件并进行绘制。 153 | 154 | Args: 155 | img (maix.image.Image): 绘制开关的目标图像。 156 | """ 157 | x, y, pressed = self.ts.read() 158 | img_w, img_h = img.width(), img.height() 159 | disp_w, disp_h = self.disp.width(), self.disp.height() 160 | for s in self.switches: 161 | s.handle_event(x, y, pressed, img_w, img_h, disp_w, disp_h) 162 | s.draw(img) -------------------------------------------------------------------------------- /src/maixpy_ui/core/__init__.py: -------------------------------------------------------------------------------- 1 | from .ui_manager import Page, UIManager 2 | from .resolution_adapter import ResolutionAdapter -------------------------------------------------------------------------------- /src/maixpy_ui/core/resolution_adapter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'levi_jia' 3 | 4 | from typing import Sequence 5 | 6 | class ResolutionAdapter: 7 | """一个工具类,用于在不同分辨率的屏幕上适配UI元素。 8 | 9 | 它将基于一个"基础分辨率"的坐标和尺寸,按比例缩放到"目标显示分辨率"。 10 | """ 11 | 12 | def __init__(self, display_width: int, display_height: int, base_width: int=320, base_height: int=240): 13 | """初始化分辨率适配器。 14 | 15 | Args: 16 | display_width (int): 目标显示屏的宽度。 17 | display_height (int): 目标显示屏的高度。 18 | base_width (int): UI设计的基准宽度。 19 | base_height (int): UI设计的基准高度。 20 | 21 | Raises: 22 | ValueError: 如果基础宽度或高度为零。 23 | """ 24 | self.display_width = display_width 25 | self.display_height = display_height 26 | self.base_width = base_width 27 | self.base_height = base_height 28 | if self.base_width == 0 or self.base_height == 0: 29 | raise ValueError("基础宽度和高度不能为零") 30 | self.scale_x = display_width / self.base_width 31 | self.scale_y = display_height / self.base_height 32 | 33 | def scale_position(self, x: int, y: int): 34 | """缩放一个坐标点 (x, y)。 35 | 36 | Args: 37 | x (int): 原始 X 坐标。 38 | y (int): 原始 Y 坐标。 39 | 40 | Returns: 41 | Sequence[int]: 缩放后的 (x, y) 坐标。 42 | """ 43 | return int(x * self.scale_x), int(y * self.scale_y) 44 | 45 | def scale_size(self, width: int, height: int): 46 | """缩放一个尺寸 (width, height)。 47 | 48 | Args: 49 | width (int): 原始宽度。 50 | height (int): 原始高度。 51 | 52 | Returns: 53 | Sequence[int]: 缩放后的 (width, height) 尺寸。 54 | """ 55 | return int(width * self.scale_x), int(height * self.scale_y) 56 | 57 | def scale_rect(self, rect: Sequence[int]): 58 | """缩放一个矩形 [x, y, w, h]。 59 | 60 | Args: 61 | rect (list[int]): 原始矩形 `[x, y, w, h]`。 62 | 63 | Returns: 64 | Sequence[int]: 缩放后的矩形 (x, y, w, h)。 65 | """ 66 | x, y, w, h = rect 67 | return self.scale_position(x, y) + self.scale_size(w, h) 68 | 69 | def scale_value(self, value: int|float): 70 | """缩放一个通用数值,如半径、厚度等。 71 | 72 | 使用 X 和 Y 缩放因子中较大的一个,以保持视觉比例。 73 | 74 | Args: 75 | value (int|float): 原始数值。 76 | 77 | Returns: 78 | float: 缩放后的数值。 79 | """ 80 | return value * max(self.scale_x, self.scale_y) -------------------------------------------------------------------------------- /src/maixpy_ui/core/ui_manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __author__ = 'HYKMAX' 3 | 4 | import maix.image as image 5 | from typing import List, Optional 6 | 7 | class Page: 8 | """页面(Page)的基类,支持树型父子节点结构。 9 | 10 | 每个页面可以有一个父页面和多个子页面,形成树型结构。 11 | 这种设计允许更灵活的页面组织和导航。 12 | 13 | Attributes: 14 | ui_manager (UIManager): 管理此页面的 UIManager 实例。 15 | name (str): 页面的名称,用于在父页面中唯一标识。 16 | parent (Page | None): 父页面,如果为 None 则表示根页面。 17 | children (List[Page]): 子页面列表。 18 | """ 19 | 20 | def __init__(self, ui_manager: 'UIManager', name: str = ""): 21 | """初始化页面。 22 | 23 | Args: 24 | ui_manager (UIManager): 用于页面导航的 UIManager 实例。 25 | name (str): 页面的唯一名称标识符。 26 | """ 27 | self.ui_manager = ui_manager 28 | self.name = name 29 | self.parent = None 30 | self.children = [] 31 | 32 | def add_child(self, child_page: 'Page'): 33 | """添加一个子页面。 34 | 35 | Args: 36 | child_page (Page): 要添加的子页面实例。 37 | 38 | Raises: 39 | ValueError: 如果子页面的名称已存在或为空。 40 | TypeError: 如果传入的不是 Page 实例。 41 | """ 42 | if not isinstance(child_page, Page): 43 | raise TypeError("只能添加 Page 类的实例") 44 | if not child_page.name: 45 | raise ValueError("子页面必须有一个非空的名称") 46 | if self.get_child(child_page.name) is not None: 47 | raise ValueError(f"名称为 '{child_page.name}' 的子页面已存在") 48 | 49 | child_page.parent = self 50 | self.children.append(child_page) 51 | 52 | def remove_child(self, child_page: 'Page'): 53 | """移除一个子页面。 54 | 55 | Args: 56 | child_page (Page): 要移除的子页面实例。 57 | 58 | Returns: 59 | bool: 如果成功移除则返回 True,否则返回 False。 60 | """ 61 | if child_page in self.children: 62 | child_page.parent = None 63 | self.children.remove(child_page) 64 | return True 65 | return False 66 | 67 | def get_child(self, name: str) -> Optional['Page']: 68 | """根据名称获取子页面。 69 | 70 | Args: 71 | name (str): 子页面的名称。 72 | 73 | Returns: 74 | Page | None: 如果找到则返回子页面实例,否则返回 None。 75 | """ 76 | for child in self.children: 77 | if child.name == name: 78 | return child 79 | return None 80 | 81 | def get_root(self) -> 'Page': 82 | """获取当前页面的根页面。 83 | 84 | Returns: 85 | Page: 树结构的根页面。 86 | """ 87 | current = self 88 | while current.parent: 89 | current = current.parent 90 | return current 91 | 92 | def get_path(self) -> List[str]: 93 | """获取从根页面到当前页面的路径。 94 | 95 | Returns: 96 | List[str]: 页面名称的路径列表。 97 | """ 98 | path = [] 99 | current = self 100 | while current: 101 | if current.name: # 只有非空名称才加入路径 102 | path.insert(0, current.name) 103 | current = current.parent 104 | return path 105 | 106 | def get_depth(self) -> int: 107 | """获取当前页面在树中的深度。 108 | 109 | Returns: 110 | int: 页面深度,根页面深度为0。 111 | """ 112 | depth = 0 113 | current = self.parent 114 | while current: 115 | depth += 1 116 | current = current.parent 117 | return depth 118 | 119 | def find_page_by_path(self, path: List[str]) -> Optional['Page']: 120 | """根据路径查找页面。 121 | 122 | Args: 123 | path (List[str]): 页面路径,从当前页面开始的相对路径。 124 | 125 | Returns: 126 | Page | None: 如果找到则返回页面实例,否则返回 None。 127 | """ 128 | if not path: 129 | return self 130 | 131 | child = self.get_child(path[0]) 132 | if child is None: 133 | return None 134 | 135 | if len(path) == 1: 136 | return child 137 | else: 138 | return child.find_page_by_path(path[1:]) 139 | 140 | def on_enter(self): 141 | """当页面进入视图时调用。 142 | 143 | 子类可以重写此方法来实现页面进入时的初始化逻辑。 144 | """ 145 | pass 146 | 147 | def on_exit(self): 148 | """当页面离开视图时调用。 149 | 150 | 子类可以重写此方法来实现页面退出时的清理逻辑。 151 | """ 152 | pass 153 | 154 | def on_child_enter(self, child: 'Page'): 155 | """当子页面进入视图时调用。 156 | 157 | Args: 158 | child (Page): 进入视图的子页面。 159 | """ 160 | pass 161 | 162 | def on_child_exit(self, child: 'Page'): 163 | """当子页面离开视图时调用。 164 | 165 | Args: 166 | child (Page): 离开视图的子页面。 167 | """ 168 | pass 169 | 170 | def update(self, img: image.Image): 171 | """每帧调用的更新和绘制方法。 172 | 173 | 子类必须重写此方法以实现页面的UI逻辑和绘制。 174 | 175 | Args: 176 | img (maix.image.Image): 用于绘制的图像缓冲区。 177 | 178 | Raises: 179 | NotImplementedError: 如果子类没有实现此方法。 180 | """ 181 | raise NotImplementedError("每个页面都必须实现 update 方法") 182 | 183 | 184 | class UIManager: 185 | """UI 管理器,基于树型页面结构提供灵活的导航功能。 186 | 187 | 该管理器支持树型页面结构的导航,包括导航到子页面、返回父页面、 188 | 按路径导航等功能。 189 | """ 190 | 191 | def __init__(self, root_page: Optional[Page] = None): 192 | """初始化UI管理器。 193 | 194 | Args: 195 | root_page (Page | None): 根页面实例,如果为None则需要后续设置。 196 | """ 197 | self.root_page = root_page 198 | self.current_page = root_page 199 | self.navigation_history = [] # 用于记录导航历史 200 | 201 | if root_page: 202 | root_page.on_enter() 203 | 204 | def set_root_page(self, page: Page): 205 | """设置根页面。 206 | 207 | Args: 208 | page (Page): 新的根页面实例。 209 | """ 210 | if self.current_page: 211 | self.current_page.on_exit() 212 | 213 | self.root_page = page 214 | self.current_page = page 215 | self.navigation_history.clear() 216 | 217 | if page: 218 | page.on_enter() 219 | 220 | def remove_page(self, page: Page) -> bool: 221 | """移除指定的页面。 222 | 223 | Args: 224 | page (Page): 要移除的页面实例。 225 | 226 | Returns: 227 | bool: 如果成功移除则返回 True,否则返回 False。 228 | """ 229 | if page is None or page not in self.navigation_history: 230 | return False 231 | 232 | # 如果当前页面是要移除的页面,首先返回到父页面 233 | if self.current_page == page: 234 | self.navigate_to_parent() 235 | 236 | # 如果页面有父页面,则从父页面的子页面列表中移除该页面 237 | if page.parent: 238 | success = page.parent.remove_child(page) 239 | if success: 240 | return True 241 | 242 | return False 243 | 244 | def get_current_page(self) -> Optional[Page]: 245 | """获取当前活动的页面。 246 | 247 | Returns: 248 | Page | None: 当前页面实例,如果没有则返回 None。 249 | """ 250 | return self.current_page 251 | 252 | def navigate_to_child(self, child_name: str) -> bool: 253 | """导航到当前页面的指定子页面。 254 | 255 | Args: 256 | child_name (str): 子页面的名称。 257 | 258 | Returns: 259 | bool: 如果导航成功则返回 True,否则返回 False。 260 | """ 261 | if not self.current_page: 262 | return False 263 | 264 | child = self.current_page.get_child(child_name) 265 | if child: 266 | # 记录导航历史 267 | self.navigation_history.append(self.current_page) 268 | 269 | # 通知当前页面和父页面 270 | self.current_page.on_exit() 271 | self.current_page.on_child_enter(child) 272 | 273 | # 切换页面 274 | self.current_page = child 275 | child.on_enter() 276 | 277 | return True 278 | return False 279 | 280 | def navigate_to_parent(self) -> bool: 281 | """导航到当前页面的父页面。 282 | 283 | Returns: 284 | bool: 如果导航成功则返回 True,否则返回 False。 285 | """ 286 | if not self.current_page or not self.current_page.parent: 287 | return False 288 | 289 | parent = self.current_page.parent 290 | 291 | # 通知相关页面 292 | parent.on_child_exit(self.current_page) 293 | self.current_page.on_exit() 294 | 295 | # 从历史记录中移除(如果存在) 296 | if self.navigation_history and self.navigation_history[-1] == parent: 297 | self.navigation_history.pop() 298 | 299 | # 切换页面 300 | self.current_page = parent 301 | parent.on_enter() 302 | 303 | return True 304 | 305 | def navigate_to_root(self) -> bool: 306 | """导航到根页面。 307 | 308 | Returns: 309 | bool: 如果导航成功则返回 True,否则返回 False。 310 | """ 311 | if not self.root_page: 312 | return False 313 | 314 | return self.navigate_to_page(self.root_page) 315 | 316 | def navigate_to_path(self, path: List[str]) -> bool: 317 | """根据路径导航到指定页面。 318 | 319 | Args: 320 | path (List[str]): 从根页面开始的绝对路径。 321 | 322 | Returns: 323 | bool: 如果导航成功则返回 True,否则返回 False。 324 | """ 325 | if not self.root_page or not path: 326 | return False 327 | 328 | target_page = self.root_page.find_page_by_path(path) 329 | if target_page: 330 | return self.navigate_to_page(target_page) 331 | return False 332 | 333 | def navigate_to_relative_path(self, path: List[str]) -> bool: 334 | """根据相对路径导航到指定页面。 335 | 336 | Args: 337 | path (List[str]): 从当前页面开始的相对路径。 338 | 339 | Returns: 340 | bool: 如果导航成功则返回 True,否则返回 False。 341 | """ 342 | if not self.current_page: 343 | return False 344 | 345 | target_page = self.current_page.find_page_by_path(path) 346 | if target_page: 347 | return self.navigate_to_page(target_page) 348 | return False 349 | 350 | def navigate_to_page(self, target_page: Page) -> bool: 351 | """直接导航到指定页面。 352 | 353 | Args: 354 | target_page (Page): 目标页面实例。 355 | 356 | Returns: 357 | bool: 如果导航成功则返回 True,否则返回 False。 358 | """ 359 | if not target_page: 360 | return False 361 | 362 | if self.current_page: 363 | # 记录当前页面到历史(如果不是返回操作) 364 | if (not self.navigation_history or 365 | self.navigation_history[-1] != target_page): 366 | self.navigation_history.append(self.current_page) 367 | 368 | self.current_page.on_exit() 369 | 370 | self.current_page = target_page 371 | target_page.on_enter() 372 | 373 | return True 374 | 375 | def go_back(self) -> bool: 376 | """返回到历史记录中的前一个页面。 377 | 378 | Returns: 379 | bool: 如果返回成功则返回 True,否则返回 False。 380 | """ 381 | if not self.navigation_history: 382 | return False 383 | 384 | previous_page = self.navigation_history.pop() 385 | 386 | if self.current_page: 387 | self.current_page.on_exit() 388 | 389 | self.current_page = previous_page 390 | previous_page.on_enter() 391 | 392 | return True 393 | 394 | def clear_history(self): 395 | """清空导航历史记录。""" 396 | self.navigation_history.clear() 397 | 398 | def get_current_path(self) -> List[str]: 399 | """获取当前页面的完整路径。 400 | 401 | Returns: 402 | List[str]: 当前页面的路径。 403 | """ 404 | if self.current_page: 405 | return self.current_page.get_path() 406 | return [] 407 | 408 | def get_navigation_info(self) -> dict: 409 | """获取当前导航状态信息。 410 | 411 | Returns: 412 | dict: 包含当前页面、路径、历史等信息的字典。 413 | """ 414 | return { 415 | 'current_page': self.current_page.name if self.current_page else None, 416 | 'current_path': self.get_current_path(), 417 | 'can_go_back': len(self.navigation_history) > 0, 418 | 'can_go_to_parent': (self.current_page and 419 | self.current_page.parent is not None), 420 | 'history_depth': len(self.navigation_history), 421 | 'page_depth': (self.current_page.get_depth() 422 | if self.current_page else 0) 423 | } 424 | 425 | def update(self, img: image.Image): 426 | """更新当前活动页面的状态。 427 | 428 | 此方法应在主循环中每帧调用,它会调用当前页面的 `update` 方法。 429 | 430 | Args: 431 | img (maix.image.Image): 用于绘制的图像缓冲区。 432 | """ 433 | if self.current_page: 434 | self.current_page.update(img) 435 | --------------------------------------------------------------------------------