├── .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 | [](https://github.com/aristorechina/MaixPy-UI-Lib/blob/main/LICENSE) [](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 | 
16 |
17 | 
18 |
19 | 
20 |
21 | 
22 |
23 | 
24 |
25 | ---
26 |
27 | ## 📦 安装
28 |
29 | `pip install maixpy-ui-lib`
30 |
31 | 
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 |
--------------------------------------------------------------------------------