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.
@@ -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