displaypad-lib 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- displaypad_lib-1.0.0/PKG-INFO +38 -0
- displaypad_lib-1.0.0/README.md +23 -0
- displaypad_lib-1.0.0/pyproject.toml +22 -0
- displaypad_lib-1.0.0/src/displaypad_lib/__init__.py +22 -0
- displaypad_lib-1.0.0/src/displaypad_lib/displaypad.py +324 -0
- displaypad_lib-1.0.0/src/displaypad_lib/key.py +195 -0
- displaypad_lib-1.0.0/src/displaypad_lib/keycontext.py +175 -0
- displaypad_lib-1.0.0/src/displaypad_lib/page.py +111 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: displaypad-lib
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A library to easily interact with the Mountain Displaypad
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: AnnikenToGo
|
|
7
|
+
Author-email: anniken@annikentogo.de
|
|
8
|
+
Requires-Python: >=3.14
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Requires-Dist: displaypad-driver (>=1.0.0)
|
|
13
|
+
Requires-Dist: pillow (>=12.1.0,<13.0.0)
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# displaypad-lib package
|
|
17
|
+
|
|
18
|
+
`displaypad-lib` provides a high-level, multi-page library for interacting with the Mountain DisplayPad device, built on top of `displaypad-driver`.
|
|
19
|
+
|
|
20
|
+
## Acknowledgments
|
|
21
|
+
Multi-page layout engine design, auto-timeout page transitions, and responsive async rendering were built with inspiration from [ramisotti13-eng/BaseCamp-Linux](https://github.com/ramisotti13-eng/BaseCamp-Linux).
|
|
22
|
+
|
|
23
|
+
## Key Features
|
|
24
|
+
|
|
25
|
+
- **Multi-Page Layout Engine (`Page`, `PageManager`)**: Create named 12-key pages with navigation stacks and auto-timeout transitions (`mode: "after" | "idle"`). See [docs/lib/page.md](../../docs/lib/page.md).
|
|
26
|
+
- **Key Abstractions (`displaypad_lib.key`)**:
|
|
27
|
+
- `Key` (base class) — Implement `render(ctx: KeyContext)` and optional lifecycle hooks (`on_mount`, `on_press`, `on_release`, `on_double_press`, `on_long_press`, `on_tick`).
|
|
28
|
+
- `GifKey` — Play animated GIFs at native frame rates with rotation support.
|
|
29
|
+
- `IconKey` — Static image icons with aspect-ratio scaling and margins.
|
|
30
|
+
- `LabelKey` — Dynamic centered text labels with customizable colors.
|
|
31
|
+
- `FramerateLimitedKey` — Rate-limited key rendering.
|
|
32
|
+
- `LoggerKey` — Diagnostics key logging presses and releases.
|
|
33
|
+
- **Drawing Context (`KeyContext`)**:
|
|
34
|
+
- Key-relative drawing primitives: `center_text`, `text`, `rectangle`, `ellipse`, `line`, `polygon`, `arc`, `fill`, `clear`, `paste_image`. Supports both `color` and `fill` parameter aliases.
|
|
35
|
+
- **Async Queue & Hybrid Batch Rendering**:
|
|
36
|
+
- Background thread drains key updates with frame deduplication. Single-key updates use fast per-button tile uploads; layout changes automatically batch update the full panel.
|
|
37
|
+
|
|
38
|
+
For usage examples, see [lib_example.py](../../examples/lib_example.py) and [clock.py](../../examples/clock.py).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# displaypad-lib package
|
|
2
|
+
|
|
3
|
+
`displaypad-lib` provides a high-level, multi-page library for interacting with the Mountain DisplayPad device, built on top of `displaypad-driver`.
|
|
4
|
+
|
|
5
|
+
## Acknowledgments
|
|
6
|
+
Multi-page layout engine design, auto-timeout page transitions, and responsive async rendering were built with inspiration from [ramisotti13-eng/BaseCamp-Linux](https://github.com/ramisotti13-eng/BaseCamp-Linux).
|
|
7
|
+
|
|
8
|
+
## Key Features
|
|
9
|
+
|
|
10
|
+
- **Multi-Page Layout Engine (`Page`, `PageManager`)**: Create named 12-key pages with navigation stacks and auto-timeout transitions (`mode: "after" | "idle"`). See [docs/lib/page.md](../../docs/lib/page.md).
|
|
11
|
+
- **Key Abstractions (`displaypad_lib.key`)**:
|
|
12
|
+
- `Key` (base class) — Implement `render(ctx: KeyContext)` and optional lifecycle hooks (`on_mount`, `on_press`, `on_release`, `on_double_press`, `on_long_press`, `on_tick`).
|
|
13
|
+
- `GifKey` — Play animated GIFs at native frame rates with rotation support.
|
|
14
|
+
- `IconKey` — Static image icons with aspect-ratio scaling and margins.
|
|
15
|
+
- `LabelKey` — Dynamic centered text labels with customizable colors.
|
|
16
|
+
- `FramerateLimitedKey` — Rate-limited key rendering.
|
|
17
|
+
- `LoggerKey` — Diagnostics key logging presses and releases.
|
|
18
|
+
- **Drawing Context (`KeyContext`)**:
|
|
19
|
+
- Key-relative drawing primitives: `center_text`, `text`, `rectangle`, `ellipse`, `line`, `polygon`, `arc`, `fill`, `clear`, `paste_image`. Supports both `color` and `fill` parameter aliases.
|
|
20
|
+
- **Async Queue & Hybrid Batch Rendering**:
|
|
21
|
+
- Background thread drains key updates with frame deduplication. Single-key updates use fast per-button tile uploads; layout changes automatically batch update the full panel.
|
|
22
|
+
|
|
23
|
+
For usage examples, see [lib_example.py](../../examples/lib_example.py) and [clock.py](../../examples/clock.py).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "displaypad-lib"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "A library to easily interact with the Mountain Displaypad"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "AnnikenToGo",email = "anniken@annikentogo.de"}
|
|
7
|
+
]
|
|
8
|
+
license = {text = "MIT"}
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.14"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"pillow (>=12.1.0,<13.0.0)",
|
|
13
|
+
"displaypad-driver>=1.0.0"
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
22
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""DisplayPad Library Package"""
|
|
2
|
+
|
|
3
|
+
from .displaypad import DisplayPad
|
|
4
|
+
from .key import Key, FramerateLimitedKey, LoggerKey, IconKey, GifKey, LabelKey
|
|
5
|
+
from .page import Page, PageManager
|
|
6
|
+
|
|
7
|
+
__version__ = "1.0.0"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
'__version__',
|
|
12
|
+
'DisplayPad',
|
|
13
|
+
|
|
14
|
+
'Key',
|
|
15
|
+
'FramerateLimitedKey',
|
|
16
|
+
'LoggerKey',
|
|
17
|
+
'IconKey',
|
|
18
|
+
'GifKey',
|
|
19
|
+
'LabelKey',
|
|
20
|
+
'Page',
|
|
21
|
+
'PageManager',
|
|
22
|
+
]
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
from PIL import Image, ImageDraw
|
|
2
|
+
from .key import Key
|
|
3
|
+
from .keycontext import KeyContext
|
|
4
|
+
from displaypad_driver import DisplayPad as Driver
|
|
5
|
+
from logging import getLogger
|
|
6
|
+
|
|
7
|
+
log = getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
class DisplayPad:
|
|
10
|
+
"""Main DisplayPad class managing keys, multi-page layouts, and async display rendering."""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import queue
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from typing import Dict, List, Optional, Union
|
|
17
|
+
from PIL import Image, ImageDraw
|
|
18
|
+
|
|
19
|
+
from displaypad_driver import DisplayPad as Driver, ICON_SIZE, KEYS_PER_ROW, NUM_KEYS
|
|
20
|
+
from displaypad_driver.image import image_to_bgr102, split_image_to_tiles
|
|
21
|
+
from .key import Key
|
|
22
|
+
from .keycontext import KeyContext
|
|
23
|
+
from .page import Page, PageManager
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DisplayPad:
|
|
29
|
+
"""Main DisplayPad high-level manager class.
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
```python
|
|
33
|
+
pad = DisplayPad()
|
|
34
|
+
pad[0] = LoggerKey(0)
|
|
35
|
+
while True:
|
|
36
|
+
pad.update()
|
|
37
|
+
```
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, rotation: int = 0, debounce_sec: float = 0.01, dc_window: float = 0.6):
|
|
41
|
+
self.driver = Driver()
|
|
42
|
+
self.width = 800
|
|
43
|
+
self.height = 240
|
|
44
|
+
self.rotation = rotation
|
|
45
|
+
self.debounce_sec = debounce_sec
|
|
46
|
+
self.dc_window = dc_window
|
|
47
|
+
self.dc_antibounce = 0.02
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
self.image_buffer = Image.new("RGB", (self.width, self.height))
|
|
51
|
+
self.page_manager = PageManager()
|
|
52
|
+
self._synced_keys: List[Optional[object]] = [object()] * NUM_KEYS
|
|
53
|
+
self._key_down_state: List[bool] = [False] * NUM_KEYS
|
|
54
|
+
|
|
55
|
+
# Input timing state
|
|
56
|
+
self._last_fire_time: Dict[int, float] = {}
|
|
57
|
+
self._press_start_time: Dict[int, float] = {}
|
|
58
|
+
self._dc_timers: Dict[int, float] = {} # key_index -> timer_start_time
|
|
59
|
+
self._dc_pending_single: Dict[int, bool] = {}
|
|
60
|
+
|
|
61
|
+
# Async tile render queue & lock
|
|
62
|
+
self._render_queue: queue.Queue = queue.Queue()
|
|
63
|
+
self._queue_worker_stop = threading.Event()
|
|
64
|
+
self._worker_thread = threading.Thread(target=self._async_render_loop, daemon=True)
|
|
65
|
+
self._worker_thread.start()
|
|
66
|
+
|
|
67
|
+
# --- Property Shortcuts ---
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def keys(self) -> List[Optional[Key]]:
|
|
71
|
+
return self.page_manager.get_current_page().keys
|
|
72
|
+
|
|
73
|
+
def __getitem__(self, index: int) -> Optional[Key]:
|
|
74
|
+
return self.page_manager.get_current_page()[index]
|
|
75
|
+
|
|
76
|
+
def __setitem__(self, index: int, key_instance: Optional[Key]):
|
|
77
|
+
self.page_manager.get_current_page()[index] = key_instance
|
|
78
|
+
self._synced_keys[index] = object() # Force re-sync on next update
|
|
79
|
+
|
|
80
|
+
def add_page(self, page_id: Union[str, int], page: Page):
|
|
81
|
+
"""Register a new Page layout. If adding/updating the active page, repaints the panel."""
|
|
82
|
+
self.page_manager.add_page(page_id, page)
|
|
83
|
+
current = self.page_manager.current_page_id
|
|
84
|
+
if page_id == current or page.name == current:
|
|
85
|
+
# Force re-sync of active page keys
|
|
86
|
+
self._synced_keys = [object()] * NUM_KEYS
|
|
87
|
+
current_page = self.page_manager.get_current_page()
|
|
88
|
+
draw = ImageDraw.Draw(self.image_buffer)
|
|
89
|
+
draw.rectangle([0, 0, self.width, self.height], fill=(0, 0, 0))
|
|
90
|
+
for idx in range(NUM_KEYS):
|
|
91
|
+
key = current_page.keys[idx]
|
|
92
|
+
if key:
|
|
93
|
+
key.on_mount(idx)
|
|
94
|
+
self._render_key_to_buffer(idx, key)
|
|
95
|
+
key._needs_redraw = False
|
|
96
|
+
self.push_image()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def switch_to_page(self, page_id: Union[str, int]) -> bool:
|
|
100
|
+
"""Switch active page and request full panel redraw."""
|
|
101
|
+
success = self.page_manager.switch_to(page_id)
|
|
102
|
+
if success:
|
|
103
|
+
current_page = self.page_manager.get_current_page()
|
|
104
|
+
# Render all keys of new page onto image_buffer
|
|
105
|
+
draw = ImageDraw.Draw(self.image_buffer)
|
|
106
|
+
draw.rectangle([0, 0, self.width, self.height], fill=(0, 0, 0))
|
|
107
|
+
for idx in range(NUM_KEYS):
|
|
108
|
+
key = current_page.keys[idx]
|
|
109
|
+
if key:
|
|
110
|
+
self._render_key_to_buffer(idx, key)
|
|
111
|
+
key._needs_redraw = False
|
|
112
|
+
self.push_image()
|
|
113
|
+
return success
|
|
114
|
+
|
|
115
|
+
def set_brightness(self, percent: int):
|
|
116
|
+
"""Set hardware backlight brightness (0-100%)."""
|
|
117
|
+
self.driver.set_brightness(percent)
|
|
118
|
+
|
|
119
|
+
def disable(self):
|
|
120
|
+
"""Close driver interfaces and stop worker threads."""
|
|
121
|
+
self._queue_worker_stop.set()
|
|
122
|
+
self.driver.close()
|
|
123
|
+
|
|
124
|
+
def _get_key_box(self, index: int) -> tuple[int, int, int, int]:
|
|
125
|
+
"""Return (x1, y1, x2, y2) bounding box for key index in 800x240 buffer."""
|
|
126
|
+
row = index // KEYS_PER_ROW
|
|
127
|
+
col = index % KEYS_PER_ROW
|
|
128
|
+
x1 = round(col * self.width / KEYS_PER_ROW)
|
|
129
|
+
x2 = round((col + 1) * self.width / KEYS_PER_ROW)
|
|
130
|
+
y1 = round(row * self.height / 2)
|
|
131
|
+
y2 = round((row + 1) * self.height / 2)
|
|
132
|
+
return (x1, y1, x2, y2)
|
|
133
|
+
|
|
134
|
+
def _get_key_coords(self, index: int) -> tuple[int, int]:
|
|
135
|
+
box = self._get_key_box(index)
|
|
136
|
+
return (box[0], box[1])
|
|
137
|
+
|
|
138
|
+
def clear(self):
|
|
139
|
+
"""Clear the full image buffer to black and update all key tiles on device."""
|
|
140
|
+
self.image_buffer = Image.new("RGB", (self.width, self.height), (0, 0, 0))
|
|
141
|
+
current_page = self.page_manager.get_current_page()
|
|
142
|
+
for idx in range(NUM_KEYS):
|
|
143
|
+
current_page.keys[idx] = None
|
|
144
|
+
self.push_image()
|
|
145
|
+
|
|
146
|
+
def screenshot(self, filename: str):
|
|
147
|
+
"""Save current buffer to a file for debugging."""
|
|
148
|
+
self.image_buffer.save(filename)
|
|
149
|
+
|
|
150
|
+
def update(self, timeout: int = 20):
|
|
151
|
+
"""Poll driver for inputs, process key lifecycles, and update display.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
timeout: Input polling timeout in milliseconds (default 20ms for high responsiveness).
|
|
155
|
+
"""
|
|
156
|
+
now = time.time()
|
|
157
|
+
|
|
158
|
+
# 1. Check page auto-timeouts
|
|
159
|
+
timeout_target = self.page_manager.check_timeout()
|
|
160
|
+
if timeout_target:
|
|
161
|
+
self.switch_to_page(timeout_target)
|
|
162
|
+
|
|
163
|
+
# 2. Poll Driver for key events
|
|
164
|
+
input_state = self.driver.poll_key(timeout=timeout)
|
|
165
|
+
|
|
166
|
+
# 3. Handle key presses
|
|
167
|
+
if input_state['pressed']:
|
|
168
|
+
self.page_manager.note_activity()
|
|
169
|
+
for idx in input_state['pressed']:
|
|
170
|
+
if not self._key_down_state[idx]:
|
|
171
|
+
self._key_down_state[idx] = True
|
|
172
|
+
self._last_fire_time[idx] = now
|
|
173
|
+
self._press_start_time[idx] = now
|
|
174
|
+
|
|
175
|
+
key = self[idx]
|
|
176
|
+
if key:
|
|
177
|
+
key.on_press()
|
|
178
|
+
|
|
179
|
+
# Double click check (additionally trigger on_double_press if within window)
|
|
180
|
+
if idx in self._dc_timers and (now - self._dc_timers[idx] <= self.dc_window):
|
|
181
|
+
del self._dc_timers[idx]
|
|
182
|
+
self._dc_pending_single[idx] = False
|
|
183
|
+
if key:
|
|
184
|
+
key.on_double_press()
|
|
185
|
+
else:
|
|
186
|
+
self._dc_timers[idx] = now
|
|
187
|
+
self._dc_pending_single[idx] = True
|
|
188
|
+
|
|
189
|
+
# 4. Handle key releases
|
|
190
|
+
if input_state['released']:
|
|
191
|
+
for idx in input_state['released']:
|
|
192
|
+
if self._key_down_state[idx]:
|
|
193
|
+
self._key_down_state[idx] = False
|
|
194
|
+
start_t = self._press_start_time.pop(idx, None)
|
|
195
|
+
if start_t and (now - start_t >= 0.8):
|
|
196
|
+
key = self[idx]
|
|
197
|
+
if key:
|
|
198
|
+
key.on_long_press()
|
|
199
|
+
|
|
200
|
+
key = self[idx]
|
|
201
|
+
if key:
|
|
202
|
+
key.on_release()
|
|
203
|
+
else:
|
|
204
|
+
# Released without recorded down event (missed down poll on super fast tap)
|
|
205
|
+
self.page_manager.note_activity()
|
|
206
|
+
self._last_fire_time[idx] = now
|
|
207
|
+
key = self[idx]
|
|
208
|
+
if key:
|
|
209
|
+
key.on_press()
|
|
210
|
+
key.on_release()
|
|
211
|
+
|
|
212
|
+
# 5. Handle pending single presses after double-click window elapses
|
|
213
|
+
for idx in list(self._dc_timers.keys()):
|
|
214
|
+
if now - self._dc_timers[idx] > self.dc_window:
|
|
215
|
+
del self._dc_timers[idx]
|
|
216
|
+
self._dc_pending_single.pop(idx, None)
|
|
217
|
+
|
|
218
|
+
# 6. Render pass for current page keys
|
|
219
|
+
current_page = self.page_manager.get_current_page()
|
|
220
|
+
dirty_indices = []
|
|
221
|
+
|
|
222
|
+
for idx in range(NUM_KEYS):
|
|
223
|
+
key = current_page.keys[idx]
|
|
224
|
+
if key is None:
|
|
225
|
+
if self._synced_keys[idx] is not None:
|
|
226
|
+
# Clear this slot region to black on buffer
|
|
227
|
+
self._render_blank_key_to_buffer(idx)
|
|
228
|
+
dirty_indices.append(idx)
|
|
229
|
+
self._synced_keys[idx] = None
|
|
230
|
+
else:
|
|
231
|
+
if self._synced_keys[idx] is not key:
|
|
232
|
+
key._needs_redraw = True
|
|
233
|
+
self._synced_keys[idx] = key
|
|
234
|
+
|
|
235
|
+
key.on_tick()
|
|
236
|
+
if key._needs_redraw:
|
|
237
|
+
self._render_key_to_buffer(idx, key)
|
|
238
|
+
key._needs_redraw = False
|
|
239
|
+
dirty_indices.append(idx)
|
|
240
|
+
|
|
241
|
+
# 7. Upload pass: if 3 or more keys are dirty, batch update the whole panel!
|
|
242
|
+
if len(dirty_indices) >= 3:
|
|
243
|
+
self.push_image()
|
|
244
|
+
elif dirty_indices:
|
|
245
|
+
for idx in dirty_indices:
|
|
246
|
+
self._request_tile_upload(idx)
|
|
247
|
+
|
|
248
|
+
def _render_key_to_buffer(self, idx: int, key: Key):
|
|
249
|
+
"""Render a single key into the global image buffer."""
|
|
250
|
+
box = self._get_key_box(idx)
|
|
251
|
+
w, h = box[2] - box[0], box[3] - box[1]
|
|
252
|
+
draw = ImageDraw.Draw(self.image_buffer)
|
|
253
|
+
ctx = KeyContext(draw, x_offset=box[0], y_offset=box[1], image=self.image_buffer)
|
|
254
|
+
ctx.width = w
|
|
255
|
+
ctx.height = h
|
|
256
|
+
key.render(ctx)
|
|
257
|
+
|
|
258
|
+
def _render_blank_key_to_buffer(self, idx: int):
|
|
259
|
+
"""Render a solid black tile for an unassigned key slot into global image buffer."""
|
|
260
|
+
box = self._get_key_box(idx)
|
|
261
|
+
draw = ImageDraw.Draw(self.image_buffer)
|
|
262
|
+
draw.rectangle(box, fill=(0, 0, 0))
|
|
263
|
+
|
|
264
|
+
def _request_tile_upload(self, idx: int):
|
|
265
|
+
"""Extract a key's 102x102 tile from image_buffer and queue for USB transmission."""
|
|
266
|
+
box = self._get_key_box(idx)
|
|
267
|
+
tile_crop = self.image_buffer.crop(box)
|
|
268
|
+
bgr_bytes = image_to_bgr102(tile_crop, rotation=self.rotation)
|
|
269
|
+
self._render_queue.put((idx, bgr_bytes))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def push_image(self, image_or_path: Optional[Union[str, Image.Image]] = None):
|
|
273
|
+
"""Slice full image buffer (or given image/path) into 12 key tiles and push to device immediately."""
|
|
274
|
+
if image_or_path is not None:
|
|
275
|
+
if isinstance(image_or_path, str):
|
|
276
|
+
self.image_buffer = Image.open(image_or_path).convert("RGB")
|
|
277
|
+
else:
|
|
278
|
+
self.image_buffer = image_or_path.convert("RGB")
|
|
279
|
+
if self.image_buffer.size != (self.width, self.height):
|
|
280
|
+
self.image_buffer = self.image_buffer.resize((self.width, self.height), Image.LANCZOS)
|
|
281
|
+
for idx in range(NUM_KEYS):
|
|
282
|
+
self._synced_keys[idx] = "CUSTOM_IMAGE"
|
|
283
|
+
|
|
284
|
+
tiles_bgr = split_image_to_tiles(self.image_buffer, rotation=self.rotation)
|
|
285
|
+
try:
|
|
286
|
+
self.driver.upload_panel(tiles_bgr)
|
|
287
|
+
# Mark all slots as in sync
|
|
288
|
+
current_page = self.page_manager.get_current_page()
|
|
289
|
+
for idx in range(NUM_KEYS):
|
|
290
|
+
key = current_page.keys[idx]
|
|
291
|
+
if key:
|
|
292
|
+
key._needs_redraw = False
|
|
293
|
+
self._synced_keys[idx] = key
|
|
294
|
+
else:
|
|
295
|
+
self._synced_keys[idx] = "CUSTOM_IMAGE"
|
|
296
|
+
except Exception as e:
|
|
297
|
+
log.error(f"Failed to push panel image to display: {e}")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _async_render_loop(self):
|
|
304
|
+
"""Background worker thread draining tile updates to keep key loops responsive."""
|
|
305
|
+
while not self._queue_worker_stop.is_set():
|
|
306
|
+
try:
|
|
307
|
+
latest: Dict[int, bytes] = {}
|
|
308
|
+
# Drain queue items and deduplicate per key index
|
|
309
|
+
while True:
|
|
310
|
+
try:
|
|
311
|
+
idx, bgr_bytes = self._render_queue.get(timeout=0.05)
|
|
312
|
+
latest[idx] = bgr_bytes
|
|
313
|
+
except queue.Empty:
|
|
314
|
+
break
|
|
315
|
+
|
|
316
|
+
if latest and self.driver.connected:
|
|
317
|
+
for idx, bgr_bytes in sorted(latest.items()):
|
|
318
|
+
try:
|
|
319
|
+
self.driver.upload_button(idx, bgr_bytes)
|
|
320
|
+
except Exception as e:
|
|
321
|
+
log.debug(f"Async upload failed for key {idx}: {e}")
|
|
322
|
+
except Exception as e:
|
|
323
|
+
log.debug(f"Error in async render loop: {e}")
|
|
324
|
+
time.sleep(0.01)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Base Key class for DisplayPad keys, and specialized key implementations."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Optional, Union, List, Tuple
|
|
6
|
+
from PIL import Image, ImageFont
|
|
7
|
+
|
|
8
|
+
from .keycontext import KeyContext, get_default_font
|
|
9
|
+
from logging import getLogger
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
log = getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Key(ABC):
|
|
16
|
+
"""Base abstract class for a DisplayPad key.
|
|
17
|
+
|
|
18
|
+
Subclass this and override `render(ctx)` and lifecycle hooks like `on_press()`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self._needs_redraw = True
|
|
23
|
+
self.index: Optional[int] = None
|
|
24
|
+
|
|
25
|
+
def request_redraw(self):
|
|
26
|
+
"""Call this when state changes to trigger a screen update."""
|
|
27
|
+
self._needs_redraw = True
|
|
28
|
+
|
|
29
|
+
# --- Lifecycle Hooks ---
|
|
30
|
+
|
|
31
|
+
def on_mount(self, index: int):
|
|
32
|
+
"""Called when the key is assigned to a board slot (0..11)."""
|
|
33
|
+
self.index = index
|
|
34
|
+
|
|
35
|
+
def on_press(self):
|
|
36
|
+
"""Called when the key is pressed down."""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
def on_release(self):
|
|
40
|
+
"""Called when the key is released."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
def on_double_press(self):
|
|
44
|
+
"""Called when the key is double-tapped within the double-click window."""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
def on_long_press(self):
|
|
48
|
+
"""Called when the key is held down longer than long-press duration."""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def on_tick(self):
|
|
52
|
+
"""Called every polling iteration. Useful for animations and timer checks."""
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def render(self, ctx: KeyContext):
|
|
57
|
+
"""Render the key contents into the provided KeyContext."""
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class FramerateLimitedKey(Key):
|
|
62
|
+
"""A Key that limits redraw requests to a target frame rate (fps)."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, fps: float = 10.0):
|
|
65
|
+
super().__init__()
|
|
66
|
+
self.fps = fps
|
|
67
|
+
self._last_render_time = 0.0
|
|
68
|
+
|
|
69
|
+
def on_tick(self):
|
|
70
|
+
current_time = time.time()
|
|
71
|
+
if current_time - self._last_render_time >= 1.0 / self.fps:
|
|
72
|
+
self.request_redraw()
|
|
73
|
+
self._last_render_time = current_time
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LoggerKey(Key):
|
|
77
|
+
"""A Key that logs presses and releases."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, idx: int = 0):
|
|
80
|
+
super().__init__()
|
|
81
|
+
self.idx = idx
|
|
82
|
+
|
|
83
|
+
def on_press(self):
|
|
84
|
+
log.info(f"Key {self.idx} Pressed!")
|
|
85
|
+
|
|
86
|
+
def on_release(self):
|
|
87
|
+
log.info(f"Key {self.idx} Released!")
|
|
88
|
+
|
|
89
|
+
def render(self, ctx: KeyContext):
|
|
90
|
+
ctx.fill("blue")
|
|
91
|
+
ctx.center_text(f"LOG KEY {self.idx}", color="white")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class IconKey(Key):
|
|
95
|
+
"""A Key that displays a static icon image (PIL Image or file path)."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, image_or_path: Union[str, Image.Image], margin: int = 10):
|
|
98
|
+
super().__init__()
|
|
99
|
+
if isinstance(image_or_path, str):
|
|
100
|
+
self.pil_image = Image.open(image_or_path).convert("RGBA")
|
|
101
|
+
else:
|
|
102
|
+
self.pil_image = image_or_path.convert("RGBA")
|
|
103
|
+
self.margin = margin
|
|
104
|
+
|
|
105
|
+
def render(self, ctx: KeyContext):
|
|
106
|
+
ctx.clear()
|
|
107
|
+
available_width = ctx.width - 2 * self.margin
|
|
108
|
+
available_height = ctx.height - 2 * self.margin
|
|
109
|
+
|
|
110
|
+
iw, ih = self.pil_image.size
|
|
111
|
+
aspect_ratio = iw / ih if ih > 0 else 1.0
|
|
112
|
+
|
|
113
|
+
if iw > available_width or ih > available_height:
|
|
114
|
+
if aspect_ratio > 1:
|
|
115
|
+
iw = available_width
|
|
116
|
+
ih = int(iw / aspect_ratio)
|
|
117
|
+
else:
|
|
118
|
+
ih = available_height
|
|
119
|
+
iw = int(ih * aspect_ratio)
|
|
120
|
+
resized = self.pil_image.resize((max(1, iw), max(1, ih)), Image.LANCZOS)
|
|
121
|
+
else:
|
|
122
|
+
resized = self.pil_image
|
|
123
|
+
|
|
124
|
+
x = self.margin + (available_width - iw) // 2
|
|
125
|
+
y = self.margin + (available_height - ih) // 2
|
|
126
|
+
ctx.paste_image(resized, x, y)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class GifKey(Key):
|
|
130
|
+
"""A Key that plays an animated GIF at its native frame rate."""
|
|
131
|
+
|
|
132
|
+
def __init__(self, gif_path_or_image: Union[str, Image.Image], rotation: int = 0):
|
|
133
|
+
super().__init__()
|
|
134
|
+
self.rotation = rotation
|
|
135
|
+
self.frames: List[Tuple[Image.Image, float]] = [] # (frame_image, duration_seconds)
|
|
136
|
+
self.current_frame_idx = 0
|
|
137
|
+
self.last_frame_time = time.time()
|
|
138
|
+
self.is_playing = True
|
|
139
|
+
|
|
140
|
+
self._load_gif(gif_path_or_image)
|
|
141
|
+
|
|
142
|
+
def _load_gif(self, src: Union[str, Image.Image]):
|
|
143
|
+
img = Image.open(src) if isinstance(src, str) else src.copy()
|
|
144
|
+
if not getattr(img, 'is_animated', False) and getattr(img, 'n_frames', 1) <= 1:
|
|
145
|
+
frame = img.convert("RGBA").resize((133, 120), Image.LANCZOS)
|
|
146
|
+
if self.rotation:
|
|
147
|
+
frame = frame.rotate(-self.rotation, expand=False)
|
|
148
|
+
self.frames = [(frame, 1.0)]
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
for i in range(img.n_frames):
|
|
153
|
+
img.seek(i)
|
|
154
|
+
duration = max(img.info.get('duration', 100), 20) / 1000.0
|
|
155
|
+
frame = img.convert("RGBA").resize((133, 120), Image.LANCZOS)
|
|
156
|
+
if self.rotation:
|
|
157
|
+
frame = frame.rotate(-self.rotation, expand=False)
|
|
158
|
+
self.frames.append((frame, duration))
|
|
159
|
+
except EOFError:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
def on_tick(self):
|
|
163
|
+
if not self.is_playing or not self.frames:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
now = time.time()
|
|
167
|
+
_frame_img, duration = self.frames[self.current_frame_idx]
|
|
168
|
+
if now - self.last_frame_time >= duration:
|
|
169
|
+
self.current_frame_idx = (self.current_frame_idx + 1) % len(self.frames)
|
|
170
|
+
self.last_frame_time = now
|
|
171
|
+
self.request_redraw()
|
|
172
|
+
|
|
173
|
+
def render(self, ctx: KeyContext):
|
|
174
|
+
ctx.clear()
|
|
175
|
+
if not self.frames:
|
|
176
|
+
return
|
|
177
|
+
frame_img, _ = self.frames[self.current_frame_idx]
|
|
178
|
+
ctx.paste_image(frame_img, 0, 0)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class LabelKey(Key):
|
|
182
|
+
"""A Key that displays a simple text label with background color."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, label: str, bg_color: str = "navy", text_color: str = "white", font_size: int = 18):
|
|
185
|
+
super().__init__()
|
|
186
|
+
self.label = label
|
|
187
|
+
self.bg_color = bg_color
|
|
188
|
+
self.text_color = text_color
|
|
189
|
+
self.font_size = font_size
|
|
190
|
+
self._custom_font = None
|
|
191
|
+
|
|
192
|
+
def render(self, ctx: KeyContext):
|
|
193
|
+
ctx.fill(self.bg_color)
|
|
194
|
+
font = self._custom_font or get_default_font(self.font_size)
|
|
195
|
+
ctx.center_text(self.label, color=self.text_color, font=font)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import PIL.ImageDraw as ImageDraw
|
|
2
|
+
from PIL import Image, ImageFont
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_default_font(size: int = 18) -> ImageFont.ImageFont:
|
|
6
|
+
"""Load a crisp, bold system font (size 18pt by default) for high-density key displays."""
|
|
7
|
+
font_paths = (
|
|
8
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
9
|
+
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf",
|
|
10
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
11
|
+
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
|
|
12
|
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
|
13
|
+
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
|
14
|
+
)
|
|
15
|
+
for p in font_paths:
|
|
16
|
+
try:
|
|
17
|
+
return ImageFont.truetype(p, size)
|
|
18
|
+
except Exception:
|
|
19
|
+
pass
|
|
20
|
+
try:
|
|
21
|
+
return ImageFont.load_default(size=size)
|
|
22
|
+
except Exception:
|
|
23
|
+
return ImageFont.load_default()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class KeyContext:
|
|
27
|
+
"""A drawing context for a single key on the DisplayPad.
|
|
28
|
+
Automatically offsets drawing commands to the key's position.
|
|
29
|
+
Provides helper methods for common drawing tasks."""
|
|
30
|
+
width = 800 // 6
|
|
31
|
+
height = 240 // 2
|
|
32
|
+
|
|
33
|
+
def __init__(self, pil_draw: ImageDraw.ImageDraw, x_offset, y_offset, font=None, image: Image.Image | None = None):
|
|
34
|
+
self.draw = pil_draw
|
|
35
|
+
self.ox = x_offset
|
|
36
|
+
self.oy = y_offset
|
|
37
|
+
self.font = font or get_default_font(18)
|
|
38
|
+
self.image = image
|
|
39
|
+
|
|
40
|
+
def set_font(self, font):
|
|
41
|
+
self.font = font
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def text(self, x, y, text, fill="white", font=None, **kwargs):
|
|
45
|
+
fill = kwargs.pop('color', fill)
|
|
46
|
+
font = font or self.font
|
|
47
|
+
self.draw.text((self.ox + x, self.oy + y), text,
|
|
48
|
+
fill=fill, font=font, **kwargs)
|
|
49
|
+
|
|
50
|
+
def rectangle(self, x, y, w, h, fill="red", **kwargs):
|
|
51
|
+
fill = kwargs.pop('color', fill)
|
|
52
|
+
self.draw.rectangle(
|
|
53
|
+
[self.ox + x, self.oy + y, self.ox + x + w, self.oy + y + h],
|
|
54
|
+
fill=fill,
|
|
55
|
+
**kwargs
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def ellipse(self, x, y, w, h, fill="red", **kwargs):
|
|
59
|
+
fill = kwargs.pop('color', fill)
|
|
60
|
+
self.draw.ellipse(
|
|
61
|
+
[self.ox + x, self.oy + y, self.ox + x + w, self.oy + y + h],
|
|
62
|
+
fill=fill,
|
|
63
|
+
**kwargs
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def line(self, x1, y1, x2, y2, fill="red", width=1, **kwargs):
|
|
67
|
+
fill = kwargs.pop('color', fill)
|
|
68
|
+
self.draw.line(
|
|
69
|
+
[self.ox + x1, self.oy + y1, self.ox + x2, self.oy + y2],
|
|
70
|
+
fill=fill,
|
|
71
|
+
width=width,
|
|
72
|
+
**kwargs
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def polygon(self, points, fill="red", **kwargs):
|
|
76
|
+
fill = kwargs.pop('color', fill)
|
|
77
|
+
offset_points = [(self.ox + x, self.oy + y) for (x, y) in points]
|
|
78
|
+
self.draw.polygon(offset_points, fill=fill, **kwargs)
|
|
79
|
+
|
|
80
|
+
def pixel(self, x, y, fill="red", **kwargs):
|
|
81
|
+
fill = kwargs.pop('color', fill)
|
|
82
|
+
self.draw.point((self.ox + x, self.oy + y), fill=fill, **kwargs)
|
|
83
|
+
|
|
84
|
+
def point(self, x, y, fill="red", **kwargs):
|
|
85
|
+
fill = kwargs.pop('color', fill)
|
|
86
|
+
self.draw.point((self.ox + x, self.oy + y), fill=fill, **kwargs)
|
|
87
|
+
|
|
88
|
+
def paste_image(self, pil_image: Image.Image, x=0, y=0):
|
|
89
|
+
"""Paste an image into the key bounds, clipping to this key's area."""
|
|
90
|
+
if self.image is None:
|
|
91
|
+
raise ValueError("KeyContext needs a base image to paste onto")
|
|
92
|
+
|
|
93
|
+
src = pil_image.convert("RGBA")
|
|
94
|
+
|
|
95
|
+
dest_left = self.ox + x
|
|
96
|
+
dest_top = self.oy + y
|
|
97
|
+
dest_right = dest_left + src.width
|
|
98
|
+
dest_bottom = dest_top + src.height
|
|
99
|
+
|
|
100
|
+
key_left, key_top = self.ox, self.oy
|
|
101
|
+
key_right, key_bottom = self.ox + self.width, self.oy + self.height
|
|
102
|
+
|
|
103
|
+
clip_left = max(dest_left, key_left)
|
|
104
|
+
clip_top = max(dest_top, key_top)
|
|
105
|
+
clip_right = min(dest_right, key_right)
|
|
106
|
+
clip_bottom = min(dest_bottom, key_bottom)
|
|
107
|
+
|
|
108
|
+
if clip_left >= clip_right or clip_top >= clip_bottom:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
crop_left = clip_left - dest_left
|
|
112
|
+
crop_top = clip_top - dest_top
|
|
113
|
+
cropped = src.crop((crop_left, crop_top, crop_left +
|
|
114
|
+
(clip_right - clip_left), crop_top + (clip_bottom - clip_top)))
|
|
115
|
+
|
|
116
|
+
alpha = cropped.getchannel("A") if "A" in cropped.getbands() else None
|
|
117
|
+
rgb = cropped.convert("RGB")
|
|
118
|
+
self.image.paste(rgb, (clip_left, clip_top), mask=alpha)
|
|
119
|
+
|
|
120
|
+
def arc(self, x1, y1, x2, y2, start, end, fill="red", width=1, **kwargs):
|
|
121
|
+
fill = kwargs.pop('color', fill)
|
|
122
|
+
self.draw.arc(
|
|
123
|
+
[self.ox + x1, self.oy + y1, self.ox + x2, self.oy + y2],
|
|
124
|
+
start,
|
|
125
|
+
end,
|
|
126
|
+
fill=fill,
|
|
127
|
+
width=width, **kwargs
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Layout helpers
|
|
131
|
+
def center_text(self, text, y=None, fill="white", font=None, **kwargs):
|
|
132
|
+
fill = kwargs.pop('color', fill)
|
|
133
|
+
font = font or self.font
|
|
134
|
+
bbox = self.draw.textbbox((0, 0), text, font=font)
|
|
135
|
+
w = bbox[2] - bbox[0]
|
|
136
|
+
h = bbox[3] - bbox[1]
|
|
137
|
+
x = (self.width - w) // 2 - bbox[0]
|
|
138
|
+
calc_y = ((self.height - h) // 2 - bbox[1]) if y is None else (y - bbox[1])
|
|
139
|
+
self.text(x, calc_y, text, fill=fill, font=font, **kwargs)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def fill(self, fill="black", **kwargs):
|
|
143
|
+
fill = kwargs.pop('color', fill)
|
|
144
|
+
self.rectangle(0, 0, self.width, self.height, fill=fill, **kwargs)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def clear(self):
|
|
150
|
+
self.fill("black")
|
|
151
|
+
|
|
152
|
+
def apply_alpha_mask(self, alpha_mask: Image.Image):
|
|
153
|
+
"""Apply an alpha mask to the key's image area."""
|
|
154
|
+
if self.image is None:
|
|
155
|
+
raise ValueError(
|
|
156
|
+
"KeyContext needs a base image to apply alpha mask onto")
|
|
157
|
+
|
|
158
|
+
# Support both key-sized masks and full-panel masks.
|
|
159
|
+
if alpha_mask.size == (self.width, self.height):
|
|
160
|
+
mask_cropped = alpha_mask
|
|
161
|
+
elif alpha_mask.size == self.image.size:
|
|
162
|
+
mask_cropped = alpha_mask.crop(
|
|
163
|
+
(self.ox, self.oy, self.ox + self.width, self.oy + self.height))
|
|
164
|
+
else:
|
|
165
|
+
# Fallback: resize to key area
|
|
166
|
+
mask_cropped = alpha_mask.resize((self.width, self.height))
|
|
167
|
+
|
|
168
|
+
key_area = self.image.crop(
|
|
169
|
+
(self.ox, self.oy, self.ox + self.width, self.oy + self.height)).convert("RGBA")
|
|
170
|
+
key_area.putalpha(mask_cropped)
|
|
171
|
+
self.image.paste(key_area, (self.ox, self.oy))
|
|
172
|
+
|
|
173
|
+
def textbbox(self, text, font=None, **kwargs):
|
|
174
|
+
font = font or self.font
|
|
175
|
+
return self.draw.textbbox((0, 0), text, font=font, **kwargs)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Multi-page layout management and page auto-timeout engine for DisplayPad."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Dict, List, Optional, Union
|
|
5
|
+
from .key import Key
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Page:
|
|
9
|
+
"""Represents a single 12-key page layout with optional auto-timeout behavior."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, name: str = "Main",
|
|
12
|
+
timeout_mode: str = "off",
|
|
13
|
+
timeout_seconds: int = 10,
|
|
14
|
+
timeout_target: Union[str, int] = "prev"):
|
|
15
|
+
self.name = name
|
|
16
|
+
self.keys: List[Optional[Key]] = [None] * 12
|
|
17
|
+
self.timeout_mode = timeout_mode # "off", "after", "idle"
|
|
18
|
+
self.timeout_seconds = timeout_seconds
|
|
19
|
+
self.timeout_target = timeout_target
|
|
20
|
+
|
|
21
|
+
def __getitem__(self, index: int) -> Optional[Key]:
|
|
22
|
+
if 0 <= index < 12:
|
|
23
|
+
return self.keys[index]
|
|
24
|
+
raise IndexError(f"Key index {index} out of range (0..11)")
|
|
25
|
+
|
|
26
|
+
def __setitem__(self, index: int, key_instance: Optional[Key]):
|
|
27
|
+
if 0 <= index < 12:
|
|
28
|
+
self.keys[index] = key_instance
|
|
29
|
+
if key_instance is not None:
|
|
30
|
+
key_instance.on_mount(index)
|
|
31
|
+
key_instance.request_redraw()
|
|
32
|
+
else:
|
|
33
|
+
raise IndexError(f"Key index {index} out of range (0..11)")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PageManager:
|
|
37
|
+
"""Manages page registration, active page switching, and auto-timeout transitions."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, main_page: Optional[Page] = None):
|
|
40
|
+
self.pages: Dict[Union[str, int], Page] = {}
|
|
41
|
+
self.current_page_id: Union[str, int] = "Main"
|
|
42
|
+
self.previous_page_id: Union[str, int] = "Main"
|
|
43
|
+
|
|
44
|
+
self.last_switch_time: float = time.time()
|
|
45
|
+
self.last_activity_time: float = time.time()
|
|
46
|
+
|
|
47
|
+
default_main = main_page or Page(name="Main")
|
|
48
|
+
self.add_page("Main", default_main)
|
|
49
|
+
|
|
50
|
+
def add_page(self, page_id: Union[str, int], page: Page):
|
|
51
|
+
self.pages[page_id] = page
|
|
52
|
+
if page.name and page.name not in self.pages:
|
|
53
|
+
self.pages[page.name] = page
|
|
54
|
+
|
|
55
|
+
def get_current_page(self) -> Page:
|
|
56
|
+
return self.pages.get(self.current_page_id) or self.pages.get("Main") or list(self.pages.values())[0]
|
|
57
|
+
|
|
58
|
+
def switch_to(self, page_id: Union[str, int]) -> bool:
|
|
59
|
+
if page_id not in self.pages:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
if page_id == self.current_page_id:
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
self.previous_page_id = self.current_page_id
|
|
66
|
+
self.current_page_id = page_id
|
|
67
|
+
now = time.time()
|
|
68
|
+
self.last_switch_time = now
|
|
69
|
+
self.last_activity_time = now
|
|
70
|
+
|
|
71
|
+
current_page = self.get_current_page()
|
|
72
|
+
for idx, key in enumerate(current_page.keys):
|
|
73
|
+
if key:
|
|
74
|
+
key.request_redraw()
|
|
75
|
+
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
def back(self) -> bool:
|
|
79
|
+
return self.switch_to(self.previous_page_id)
|
|
80
|
+
|
|
81
|
+
def note_activity(self):
|
|
82
|
+
"""Reset the inactivity timer for 'idle' mode timeouts."""
|
|
83
|
+
self.last_activity_time = time.time()
|
|
84
|
+
|
|
85
|
+
def check_timeout(self) -> Optional[Union[str, int]]:
|
|
86
|
+
"""Check if current page timeout condition has expired.
|
|
87
|
+
|
|
88
|
+
Returns target page_id to switch to, or None.
|
|
89
|
+
"""
|
|
90
|
+
page = self.get_current_page()
|
|
91
|
+
if not page or page.timeout_mode == "off" or page.timeout_seconds <= 0:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
now = time.time()
|
|
95
|
+
target = page.timeout_target
|
|
96
|
+
if target == "prev":
|
|
97
|
+
resolved_target = self.previous_page_id
|
|
98
|
+
else:
|
|
99
|
+
resolved_target = target
|
|
100
|
+
|
|
101
|
+
if resolved_target == self.current_page_id:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
if page.timeout_mode == "after":
|
|
105
|
+
if now - self.last_switch_time >= page.timeout_seconds:
|
|
106
|
+
return resolved_target
|
|
107
|
+
elif page.timeout_mode == "idle":
|
|
108
|
+
if now - self.last_activity_time >= page.timeout_seconds:
|
|
109
|
+
return resolved_target
|
|
110
|
+
|
|
111
|
+
return None
|