pixel-widget 0.1.0__py3-none-any.whl
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.
- pixel_widget/__init__.py +19 -0
- pixel_widget/lib.py +1220 -0
- pixel_widget-0.1.0.dist-info/METADATA +1206 -0
- pixel_widget-0.1.0.dist-info/RECORD +5 -0
- pixel_widget-0.1.0.dist-info/WHEEL +4 -0
pixel_widget/lib.py
ADDED
|
@@ -0,0 +1,1220 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import colorsys
|
|
5
|
+
import contextlib
|
|
6
|
+
import ctypes
|
|
7
|
+
import ctypes.wintypes as wt
|
|
8
|
+
import functools
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import textwrap
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import traceback
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import pygame
|
|
19
|
+
|
|
20
|
+
__all__ = ["PixelWidget", "Image", "Layer", "text_width", "hsv", "mix"]
|
|
21
|
+
|
|
22
|
+
Mode = Literal["set", "clear", "xor"]
|
|
23
|
+
Blend = Literal["or", "xor"]
|
|
24
|
+
Anchor = Literal["top_left", "top_right", "bottom_left", "bottom_right"]
|
|
25
|
+
|
|
26
|
+
_MODES = ("set", "clear", "xor")
|
|
27
|
+
_BLENDS = ("or", "xor")
|
|
28
|
+
_ANCHORS = ("top_left", "top_right", "bottom_left", "bottom_right")
|
|
29
|
+
_DIRECTIONS = ("horizontal", "vertical")
|
|
30
|
+
_PIN_INTERVAL = 0.5
|
|
31
|
+
_READY_TIMEOUT = 10.0
|
|
32
|
+
|
|
33
|
+
_WHITE = (255, 255, 255)
|
|
34
|
+
_BLACK = (0, 0, 0)
|
|
35
|
+
_COLORS = {
|
|
36
|
+
"black": _BLACK, "white": _WHITE, "red": (255, 0, 0), "green": (0, 255, 0), "blue": (0, 0, 255),
|
|
37
|
+
"yellow": (255, 255, 0), "cyan": (0, 255, 255), "magenta": (255, 0, 255), "orange": (255, 165, 0),
|
|
38
|
+
"purple": (128, 0, 128), "pink": (255, 105, 180), "gray": (128, 128, 128), "grey": (128, 128, 128),
|
|
39
|
+
"brown": (139, 69, 19), "teal": (0, 128, 128), "navy": (0, 0, 128), "lime": (0, 255, 0),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _Win32:
|
|
44
|
+
ENABLED = os.name == "nt"
|
|
45
|
+
|
|
46
|
+
GWL_EXSTYLE = -20
|
|
47
|
+
WS_EX_TOOLWINDOW = 0x00000080
|
|
48
|
+
WS_EX_APPWINDOW = 0x00040000
|
|
49
|
+
SWP_NOSIZE = 0x0001
|
|
50
|
+
SWP_NOACTIVATE = 0x0010
|
|
51
|
+
SWP_FRAMECHANGED = 0x0020
|
|
52
|
+
SWP_SHOWWINDOW = 0x0040
|
|
53
|
+
HWND_TOPMOST = -1
|
|
54
|
+
HWND_NOTOPMOST = -2
|
|
55
|
+
SW_HIDE = 0
|
|
56
|
+
SW_SHOWNOACTIVATE = 4
|
|
57
|
+
MONITOR_DEFAULTTONULL = 0
|
|
58
|
+
|
|
59
|
+
_user32 = None
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def user32(cls):
|
|
63
|
+
if cls._user32 is None:
|
|
64
|
+
dll = ctypes.WinDLL("user32", use_last_error=True)
|
|
65
|
+
dll.MonitorFromPoint.argtypes = [wt.POINT, wt.DWORD]
|
|
66
|
+
dll.MonitorFromPoint.restype = ctypes.c_void_p
|
|
67
|
+
dll.SetWindowPos.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int, ctypes.c_int,
|
|
68
|
+
ctypes.c_int, ctypes.c_int, wt.UINT]
|
|
69
|
+
dll.SetWindowPos.restype = wt.BOOL
|
|
70
|
+
dll.ShowWindow.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
|
71
|
+
dll.ShowWindow.restype = wt.BOOL
|
|
72
|
+
dll.IsIconic.argtypes = [ctypes.c_void_p]
|
|
73
|
+
dll.IsIconic.restype = wt.BOOL
|
|
74
|
+
dll.GetWindowLongW.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
|
75
|
+
dll.GetWindowLongW.restype = ctypes.c_long
|
|
76
|
+
dll.SetWindowLongW.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_long]
|
|
77
|
+
dll.SetWindowLongW.restype = ctypes.c_long
|
|
78
|
+
cls._user32 = dll
|
|
79
|
+
return cls._user32
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def enable_dpi_awareness(cls) -> None:
|
|
83
|
+
if not cls.ENABLED:
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
ctypes.WinDLL("shcore").SetProcessDpiAwareness(2)
|
|
87
|
+
except Exception:
|
|
88
|
+
with contextlib.suppress(Exception):
|
|
89
|
+
cls.user32().SetProcessDPIAware()
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def point_visible(cls, x: int, y: int) -> bool:
|
|
93
|
+
if not cls.ENABLED:
|
|
94
|
+
return True
|
|
95
|
+
point = wt.POINT(int(x), int(y))
|
|
96
|
+
return bool(cls.user32().MonitorFromPoint(point, cls.MONITOR_DEFAULTTONULL))
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def rect_visible(cls, x: int, y: int, width: int, height: int) -> bool:
|
|
100
|
+
return cls.point_visible(x, y) and cls.point_visible(x + width - 1, y + height - 1)
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def hide_from_taskbar(cls, hwnd: int) -> None:
|
|
104
|
+
if not cls.ENABLED:
|
|
105
|
+
return
|
|
106
|
+
user32 = cls.user32()
|
|
107
|
+
user32.ShowWindow(hwnd, cls.SW_HIDE)
|
|
108
|
+
style = user32.GetWindowLongW(hwnd, cls.GWL_EXSTYLE)
|
|
109
|
+
user32.SetWindowLongW(hwnd, cls.GWL_EXSTYLE, (style | cls.WS_EX_TOOLWINDOW) & ~cls.WS_EX_APPWINDOW)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def pin(cls, hwnd: int, x: int, y: int, topmost: bool, frame_changed: bool = False) -> None:
|
|
113
|
+
if not cls.ENABLED:
|
|
114
|
+
return
|
|
115
|
+
user32 = cls.user32()
|
|
116
|
+
if user32.IsIconic(hwnd):
|
|
117
|
+
user32.ShowWindow(hwnd, cls.SW_SHOWNOACTIVATE)
|
|
118
|
+
flags = cls.SWP_NOSIZE | cls.SWP_NOACTIVATE | cls.SWP_SHOWWINDOW
|
|
119
|
+
if frame_changed:
|
|
120
|
+
flags |= cls.SWP_FRAMECHANGED
|
|
121
|
+
order = cls.HWND_TOPMOST if topmost else cls.HWND_NOTOPMOST
|
|
122
|
+
user32.SetWindowPos(hwnd, order, x, y, 0, 0, flags)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_FONT_SOURCE = {
|
|
126
|
+
"0": "###/#.#/#.#/#.#/###", "1": ".#./##./.#./.#./###", "2": "###/..#/###/#../###",
|
|
127
|
+
"3": "###/..#/###/..#/###", "4": "#.#/#.#/###/..#/..#", "5": "###/#../###/..#/###",
|
|
128
|
+
"6": "###/#../###/#.#/###", "7": "###/..#/.#./.#./.#.", "8": "###/#.#/###/#.#/###",
|
|
129
|
+
"9": "###/#.#/###/..#/###",
|
|
130
|
+
"A": ".#./#.#/###/#.#/#.#", "B": "##./#.#/##./#.#/##.", "C": ".##/#../#../#../.##",
|
|
131
|
+
"D": "##./#.#/#.#/#.#/##.", "E": "###/#../##./#../###", "F": "###/#../##./#../#..",
|
|
132
|
+
"G": ".##/#../#.#/#.#/.##", "H": "#.#/#.#/###/#.#/#.#", "I": "###/.#./.#./.#./###",
|
|
133
|
+
"J": "..#/..#/..#/#.#/.#.", "K": "#.#/#.#/##./#.#/#.#", "L": "#../#../#../#../###",
|
|
134
|
+
"M": "#.#/###/###/#.#/#.#", "N": "##./#.#/#.#/#.#/#.#", "O": ".#./#.#/#.#/#.#/.#.",
|
|
135
|
+
"P": "###/#.#/###/#../#..", "Q": "###/#.#/#.#/###/..#", "R": "###/#.#/##./#.#/#.#",
|
|
136
|
+
"S": ".##/#../.#./..#/##.", "T": "###/.#./.#./.#./.#.", "U": "#.#/#.#/#.#/#.#/###",
|
|
137
|
+
"V": "#.#/#.#/#.#/#.#/.#.", "W": "#.#/#.#/###/###/#.#", "X": "#.#/#.#/.#./#.#/#.#",
|
|
138
|
+
"Y": "#.#/#.#/.#./.#./.#.", "Z": "###/..#/.#./#../###",
|
|
139
|
+
" ": ".../.../.../.../...", ":": ".../.#./.../.#./...", ".": ".../.../.../.../.#.",
|
|
140
|
+
",": ".../.../.../.#./#..", "-": ".../.../###/.../...", "+": ".../.#./###/.#./...",
|
|
141
|
+
"!": ".#./.#./.#./.../.#.", "?": "###/..#/.##/.../.#.", "/": "..#/..#/.#./#../#..",
|
|
142
|
+
"%": "#.#/..#/.#./#../#.#", "=": ".../###/.../###/...", "_": ".../.../.../.../###",
|
|
143
|
+
"(": ".#./#../#../#../.#.", ")": ".#./..#/..#/..#/.#.", "*": ".../#.#/.#./#.#/...",
|
|
144
|
+
"'": ".#./.#./.../.../...",
|
|
145
|
+
}
|
|
146
|
+
_GLYPHS = {
|
|
147
|
+
char: np.array([[cell == "#" for cell in row] for row in source.split("/")], dtype=np.uint8)
|
|
148
|
+
for char, source in _FONT_SOURCE.items()
|
|
149
|
+
}
|
|
150
|
+
_GLYPH_WIDTH = 3
|
|
151
|
+
_GLYPH_HEIGHT = 5
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _as_int(value, name: str) -> int:
|
|
155
|
+
if isinstance(value, bool):
|
|
156
|
+
raise TypeError(f"{name} must be an integer, got {value!r}")
|
|
157
|
+
if isinstance(value, (int, np.integer)):
|
|
158
|
+
return int(value)
|
|
159
|
+
if isinstance(value, (float, np.floating)) and float(value).is_integer():
|
|
160
|
+
return int(value)
|
|
161
|
+
raise TypeError(f"{name} must be an integer, got {value!r}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _non_negative(value, name: str) -> int:
|
|
165
|
+
number = _as_int(value, name)
|
|
166
|
+
if number < 0:
|
|
167
|
+
raise ValueError(f"{name} must be >= 0, got {number}")
|
|
168
|
+
return number
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _positive(value, name: str) -> int:
|
|
172
|
+
number = _as_int(value, name)
|
|
173
|
+
if number <= 0:
|
|
174
|
+
raise ValueError(f"{name} must be > 0, got {number}")
|
|
175
|
+
return number
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _number(value, name: str) -> float:
|
|
179
|
+
if isinstance(value, bool) or not isinstance(value, (int, float, np.integer, np.floating)):
|
|
180
|
+
raise TypeError(f"{name} must be a number, got {value!r}")
|
|
181
|
+
number = float(value)
|
|
182
|
+
if not math.isfinite(number):
|
|
183
|
+
raise ValueError(f"{name} must be finite, got {value!r}")
|
|
184
|
+
return number
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _unit_float(value, name: str) -> float:
|
|
188
|
+
number = _number(value, name)
|
|
189
|
+
if not 0.0 <= number <= 1.0:
|
|
190
|
+
raise ValueError(f"{name} must be between 0 and 1, got {value!r}")
|
|
191
|
+
return number
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _size_pair(value, name: str) -> tuple[int, int]:
|
|
195
|
+
if isinstance(value, (tuple, list)):
|
|
196
|
+
if len(value) != 2:
|
|
197
|
+
raise ValueError(f"{name} must be an int or a (width, height) pair, got {value!r}")
|
|
198
|
+
return _positive(value[0], f"{name}[0]"), _positive(value[1], f"{name}[1]")
|
|
199
|
+
size = _positive(value, name)
|
|
200
|
+
return size, size
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _check_mode(mode: str) -> None:
|
|
204
|
+
if mode not in _MODES:
|
|
205
|
+
raise ValueError(f"mode must be one of {_MODES}, got {mode!r}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _check_blend(blend: str) -> None:
|
|
209
|
+
if blend not in _BLENDS:
|
|
210
|
+
raise ValueError(f"blend must be one of {_BLENDS}, got {blend!r}")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _check_anchor(anchor: str) -> None:
|
|
214
|
+
if anchor not in _ANCHORS:
|
|
215
|
+
raise ValueError(f"anchor must be one of {_ANCHORS}, got {anchor!r}")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _top_left(point, size: tuple[int, int], anchor: str) -> tuple[int, int]:
|
|
219
|
+
if not isinstance(point, (tuple, list)) or len(point) != 2:
|
|
220
|
+
raise ValueError(f"a position must be an (x, y) pair, got {point!r}")
|
|
221
|
+
x, y = _as_int(point[0], "x"), _as_int(point[1], "y")
|
|
222
|
+
if "right" in anchor:
|
|
223
|
+
x -= size[0]
|
|
224
|
+
if "bottom" in anchor:
|
|
225
|
+
y -= size[1]
|
|
226
|
+
return x, y
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _parse_color(value) -> tuple[int, int, int]:
|
|
230
|
+
if isinstance(value, str):
|
|
231
|
+
text = value.strip().lower()
|
|
232
|
+
if text in _COLORS:
|
|
233
|
+
return _COLORS[text]
|
|
234
|
+
digits = text[1:] if text.startswith("#") else text
|
|
235
|
+
if len(digits) in (3, 6) and all(char in "0123456789abcdef" for char in digits):
|
|
236
|
+
if len(digits) == 3:
|
|
237
|
+
digits = "".join(char * 2 for char in digits)
|
|
238
|
+
return tuple(int(digits[i:i + 2], 16) for i in (0, 2, 4))
|
|
239
|
+
raise ValueError(f"unknown color {value!r}: use a name {sorted(_COLORS)}, '#rrggbb' or (r, g, b)")
|
|
240
|
+
if isinstance(value, bool):
|
|
241
|
+
raise TypeError(f"color must be a name, '#rrggbb', (r, g, b) or 0xRRGGBB, got {value!r}")
|
|
242
|
+
if isinstance(value, (int, np.integer)):
|
|
243
|
+
number = int(value)
|
|
244
|
+
if not 0 <= number <= 0xFFFFFF:
|
|
245
|
+
raise ValueError(f"an integer color must be in 0..0xFFFFFF, got {value!r}")
|
|
246
|
+
return (number >> 16) & 255, (number >> 8) & 255, number & 255
|
|
247
|
+
if isinstance(value, (tuple, list)) or (isinstance(value, np.ndarray) and value.ndim == 1):
|
|
248
|
+
if len(value) != 3:
|
|
249
|
+
raise ValueError(f"a color tuple must have 3 channels (r, g, b), got {value!r}")
|
|
250
|
+
channels = tuple(_as_int(channel, "color channel") for channel in value)
|
|
251
|
+
if any(not 0 <= channel <= 255 for channel in channels):
|
|
252
|
+
raise ValueError(f"color channels must be in 0..255, got {value!r}")
|
|
253
|
+
return channels
|
|
254
|
+
raise TypeError(f"color must be a name, '#rrggbb', (r, g, b) or 0xRRGGBB, got {type(value).__name__}")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def hsv(h: float, s: float = 1.0, v: float = 1.0) -> tuple[int, int, int]:
|
|
258
|
+
hue = _number(h, "h")
|
|
259
|
+
r, g, b = colorsys.hsv_to_rgb((hue / 360.0) % 1.0, _unit_float(s, "s"), _unit_float(v, "v"))
|
|
260
|
+
return round(r * 255), round(g * 255), round(b * 255)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def mix(color1, color2, t: float) -> tuple[int, int, int]:
|
|
264
|
+
start, end = _parse_color(color1), _parse_color(color2)
|
|
265
|
+
ratio = _unit_float(t, "t")
|
|
266
|
+
return tuple(round(a + (b - a) * ratio) for a, b in zip(start, end))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _color_array(color: tuple[int, int, int]) -> np.ndarray:
|
|
270
|
+
return np.array(color, dtype=np.uint8)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _normalize(value) -> tuple[np.ndarray, bool]:
|
|
274
|
+
if isinstance(value, Image):
|
|
275
|
+
return value.array, value.rgb
|
|
276
|
+
try:
|
|
277
|
+
array = np.asarray(value)
|
|
278
|
+
except Exception as exc:
|
|
279
|
+
raise TypeError(f"cannot convert {type(value).__name__} to a matrix") from exc
|
|
280
|
+
if array.ndim not in (2, 3):
|
|
281
|
+
raise ValueError(f"expected a 2D matrix (H, W) or an RGB matrix (H, W, 3), got {array.ndim} dimension(s)")
|
|
282
|
+
if array.size == 0:
|
|
283
|
+
raise ValueError("the matrix is empty")
|
|
284
|
+
if array.ndim == 2:
|
|
285
|
+
if array.dtype.kind not in "biuf":
|
|
286
|
+
raise TypeError(f"the matrix must be numeric or boolean, got dtype {array.dtype}")
|
|
287
|
+
if array.dtype.kind == "f" and np.isnan(array).any():
|
|
288
|
+
raise ValueError("the matrix contains NaN")
|
|
289
|
+
return (array != 0).astype(np.uint8), False
|
|
290
|
+
if array.shape[2] != 3:
|
|
291
|
+
raise ValueError(f"an RGB matrix must have shape (H, W, 3), got {array.shape}")
|
|
292
|
+
if array.dtype.kind not in "iu":
|
|
293
|
+
raise TypeError(f"an RGB matrix must contain integers 0..255, got dtype {array.dtype}")
|
|
294
|
+
if array.min() < 0 or array.max() > 255:
|
|
295
|
+
raise ValueError("an RGB matrix must contain values in 0..255")
|
|
296
|
+
return array.astype(np.uint8), True
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _ascii_lines(art: str) -> list[str]:
|
|
300
|
+
if not isinstance(art, str):
|
|
301
|
+
raise TypeError(f"ASCII art must be a string, got {type(art).__name__}")
|
|
302
|
+
lines = textwrap.dedent(art).split("\n")
|
|
303
|
+
while lines and not lines[0].strip():
|
|
304
|
+
lines.pop(0)
|
|
305
|
+
while lines and not lines[-1].strip():
|
|
306
|
+
lines.pop()
|
|
307
|
+
if not lines:
|
|
308
|
+
raise ValueError("ASCII art is empty")
|
|
309
|
+
return lines
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _parse_ascii(art: str, on: str) -> np.ndarray:
|
|
313
|
+
lines = _ascii_lines(art)
|
|
314
|
+
width = max(len(line) for line in lines)
|
|
315
|
+
result = np.zeros((len(lines), width), dtype=np.uint8)
|
|
316
|
+
for row, line in enumerate(lines):
|
|
317
|
+
for col, char in enumerate(line):
|
|
318
|
+
if char in on:
|
|
319
|
+
result[row, col] = 1
|
|
320
|
+
return result
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _source(value) -> tuple[np.ndarray, bool]:
|
|
324
|
+
if isinstance(value, str):
|
|
325
|
+
return _parse_ascii(value, "#X1@"), False
|
|
326
|
+
return _normalize(value)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _lit(array: np.ndarray) -> np.ndarray:
|
|
330
|
+
return (array != 0).any(axis=2)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _public(array: np.ndarray, rgb: bool) -> np.ndarray:
|
|
334
|
+
return array.copy() if rgb else _lit(array).astype(np.uint8)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _to_ascii(lit: np.ndarray, on: str = "#", off: str = ".") -> str:
|
|
338
|
+
return "\n".join("".join(on if cell else off for cell in row) for row in lit)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _save_png(display: np.ndarray, path, scale: int) -> None:
|
|
342
|
+
height, width = display.shape[:2]
|
|
343
|
+
surface = pygame.Surface((width, height), 0, 32)
|
|
344
|
+
pygame.surfarray.blit_array(surface, np.ascontiguousarray(display.transpose(1, 0, 2)))
|
|
345
|
+
if scale != 1:
|
|
346
|
+
surface = pygame.transform.scale(surface, (width * scale, height * scale))
|
|
347
|
+
pygame.image.save(surface, str(path))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def text_width(text: str, spacing: int = 1) -> int:
|
|
351
|
+
if not isinstance(text, str):
|
|
352
|
+
raise TypeError(f"text must be a string, got {type(text).__name__}")
|
|
353
|
+
spacing = _non_negative(spacing, "spacing")
|
|
354
|
+
lines = text.split("\n")
|
|
355
|
+
return max((len(line) * (_GLYPH_WIDTH + spacing) - spacing if line else 0) for line in lines)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _draws(method):
|
|
359
|
+
@functools.wraps(method)
|
|
360
|
+
def wrapper(self, *args, **kwargs):
|
|
361
|
+
self._before()
|
|
362
|
+
with self._guard:
|
|
363
|
+
result = method(self, *args, **kwargs)
|
|
364
|
+
self._touch()
|
|
365
|
+
return result
|
|
366
|
+
|
|
367
|
+
return wrapper
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class _Drawing:
|
|
371
|
+
width: int
|
|
372
|
+
height: int
|
|
373
|
+
rgb: bool
|
|
374
|
+
strict: bool
|
|
375
|
+
_guard = contextlib.nullcontext()
|
|
376
|
+
__iter__ = None
|
|
377
|
+
|
|
378
|
+
def _before(self) -> None:
|
|
379
|
+
pass
|
|
380
|
+
|
|
381
|
+
def _touch(self) -> None:
|
|
382
|
+
pass
|
|
383
|
+
|
|
384
|
+
def _visible_array(self) -> np.ndarray:
|
|
385
|
+
return self._a
|
|
386
|
+
|
|
387
|
+
def _resolve(self, color) -> np.ndarray:
|
|
388
|
+
if color is None:
|
|
389
|
+
return _color_array(self.color)
|
|
390
|
+
if not self.rgb:
|
|
391
|
+
raise ValueError("color= is available only with rgb=True")
|
|
392
|
+
return _color_array(_parse_color(color))
|
|
393
|
+
|
|
394
|
+
def _write(self, y0: int, y1: int, x0: int, x1: int, mode: str, color: np.ndarray) -> None:
|
|
395
|
+
region = self._a[y0:y1, x0:x1]
|
|
396
|
+
if mode == "set":
|
|
397
|
+
region[...] = color
|
|
398
|
+
elif mode == "clear":
|
|
399
|
+
region[...] = 0
|
|
400
|
+
else:
|
|
401
|
+
region ^= color
|
|
402
|
+
|
|
403
|
+
def _fill_rect(self, x: int, y: int, w: int, h: int, mode: str, color: np.ndarray) -> None:
|
|
404
|
+
x0, y0 = max(0, x), max(0, y)
|
|
405
|
+
x1, y1 = min(self.width, x + w), min(self.height, y + h)
|
|
406
|
+
if x1 > x0 and y1 > y0:
|
|
407
|
+
self._write(y0, y1, x0, x1, mode, color)
|
|
408
|
+
|
|
409
|
+
def _plot(self, x: int, y: int, mode: str, color: np.ndarray, strict: bool = False) -> None:
|
|
410
|
+
if 0 <= x < self.width and 0 <= y < self.height:
|
|
411
|
+
self._write(y, y + 1, x, x + 1, mode, color)
|
|
412
|
+
elif strict:
|
|
413
|
+
raise IndexError(f"pixel ({x}, {y}) is outside the {self.width}x{self.height} grid")
|
|
414
|
+
|
|
415
|
+
def _blit(self, source: np.ndarray, is_rgb: bool, x: int, y: int, mode: str, transparent: bool,
|
|
416
|
+
color: np.ndarray) -> None:
|
|
417
|
+
source_h, source_w = source.shape[:2]
|
|
418
|
+
x0, y0 = max(0, x), max(0, y)
|
|
419
|
+
x1, y1 = min(self.width, x + source_w), min(self.height, y + source_h)
|
|
420
|
+
if x1 <= x0 or y1 <= y0:
|
|
421
|
+
return
|
|
422
|
+
part = source[y0 - y:y1 - y, x0 - x:x1 - x]
|
|
423
|
+
region = self._a[y0:y1, x0:x1]
|
|
424
|
+
lit = part.any(axis=2) if is_rgb else part.astype(bool)
|
|
425
|
+
if not transparent:
|
|
426
|
+
region[...] = part if is_rgb else np.where(lit[..., None], color, 0)
|
|
427
|
+
return
|
|
428
|
+
if mode == "clear":
|
|
429
|
+
region[lit] = 0
|
|
430
|
+
return
|
|
431
|
+
values = part[lit] if is_rgb else color
|
|
432
|
+
if mode == "set":
|
|
433
|
+
region[lit] = values
|
|
434
|
+
else:
|
|
435
|
+
region[lit] ^= values
|
|
436
|
+
|
|
437
|
+
def _match(self, value) -> tuple[np.ndarray, bool]:
|
|
438
|
+
array, is_rgb = _normalize(value)
|
|
439
|
+
if is_rgb and not self.rgb:
|
|
440
|
+
raise ValueError("RGB matrices (H, W, 3) need rgb=True")
|
|
441
|
+
if array.shape[:2] != (self.height, self.width):
|
|
442
|
+
expected = f"({self.height}, {self.width}" + (", 3)" if is_rgb else ")")
|
|
443
|
+
raise ValueError(
|
|
444
|
+
f"matrix shape {array.shape} does not match the grid: expected {expected} = (rows, columns)"
|
|
445
|
+
)
|
|
446
|
+
return array, is_rgb
|
|
447
|
+
|
|
448
|
+
def _pixels(self, mode: str, x, y, color) -> None:
|
|
449
|
+
color = self._resolve(color)
|
|
450
|
+
if x is None and y is None:
|
|
451
|
+
self._write(0, self.height, 0, self.width, mode, color)
|
|
452
|
+
elif y is None:
|
|
453
|
+
if isinstance(x, (int, float, np.number)):
|
|
454
|
+
raise TypeError("a single pixel needs both coordinates: (x, y)")
|
|
455
|
+
array, is_rgb = self._match(x)
|
|
456
|
+
self._blit(array, is_rgb, 0, 0, mode, True, color)
|
|
457
|
+
elif x is None:
|
|
458
|
+
raise TypeError("a single pixel needs both coordinates: (x, y)")
|
|
459
|
+
else:
|
|
460
|
+
self._plot(_as_int(x, "x"), _as_int(y, "y"), mode, color, strict=self.strict)
|
|
461
|
+
|
|
462
|
+
@_draws
|
|
463
|
+
def set_pixel(self, x=None, y=None, *, color=None) -> None:
|
|
464
|
+
self._pixels("set", x, y, color)
|
|
465
|
+
|
|
466
|
+
@_draws
|
|
467
|
+
def clear_pixel(self, x=None, y=None) -> None:
|
|
468
|
+
self._pixels("clear", x, y, None)
|
|
469
|
+
|
|
470
|
+
@_draws
|
|
471
|
+
def xor_pixel(self, x=None, y=None, *, color=None) -> None:
|
|
472
|
+
self._pixels("xor", x, y, color)
|
|
473
|
+
|
|
474
|
+
@_draws
|
|
475
|
+
def replace(self, matrix, *, color=None) -> None:
|
|
476
|
+
color = self._resolve(color)
|
|
477
|
+
array, is_rgb = self._match(matrix)
|
|
478
|
+
self._a[...] = array if is_rgb else np.where(array[..., None] != 0, color, 0)
|
|
479
|
+
|
|
480
|
+
def get_pixel(self, x: int, y: int):
|
|
481
|
+
self._before()
|
|
482
|
+
x, y = _as_int(x, "x"), _as_int(y, "y")
|
|
483
|
+
if not (0 <= x < self.width and 0 <= y < self.height):
|
|
484
|
+
raise IndexError(f"pixel ({x}, {y}) is outside the {self.width}x{self.height} grid")
|
|
485
|
+
with self._guard:
|
|
486
|
+
pixel = self._visible_array()[y, x].copy()
|
|
487
|
+
if self.rgb:
|
|
488
|
+
return int(pixel[0]), int(pixel[1]), int(pixel[2])
|
|
489
|
+
return int(pixel.any())
|
|
490
|
+
|
|
491
|
+
def _span(self, key):
|
|
492
|
+
if not isinstance(key, tuple) or len(key) != 2:
|
|
493
|
+
raise TypeError("use two indices: obj[x, y] (x goes first, like everywhere in this library)")
|
|
494
|
+
spans = []
|
|
495
|
+
single = True
|
|
496
|
+
for part, limit, name in ((key[0], self.width, "x"), (key[1], self.height, "y")):
|
|
497
|
+
if isinstance(part, slice):
|
|
498
|
+
if part.step not in (None, 1):
|
|
499
|
+
raise ValueError("slices with a step are not supported")
|
|
500
|
+
start = 0 if part.start is None else _as_int(part.start, name)
|
|
501
|
+
stop = limit if part.stop is None else _as_int(part.stop, name)
|
|
502
|
+
spans.append((start, stop))
|
|
503
|
+
single = False
|
|
504
|
+
else:
|
|
505
|
+
value = _as_int(part, name)
|
|
506
|
+
spans.append((value, value + 1))
|
|
507
|
+
return spans[0], spans[1], single
|
|
508
|
+
|
|
509
|
+
def __getitem__(self, key):
|
|
510
|
+
self._before()
|
|
511
|
+
(x0, x1), (y0, y1), single = self._span(key)
|
|
512
|
+
if single:
|
|
513
|
+
return self.get_pixel(x0, y0)
|
|
514
|
+
x0, x1 = max(0, x0), min(self.width, x1)
|
|
515
|
+
y0, y1 = max(0, y0), min(self.height, y1)
|
|
516
|
+
with self._guard:
|
|
517
|
+
region = self._visible_array()[y0:y1, x0:x1]
|
|
518
|
+
return _public(region, self.rgb)
|
|
519
|
+
|
|
520
|
+
def _index_color(self, value) -> np.ndarray:
|
|
521
|
+
if isinstance(value, (bool, np.bool_)):
|
|
522
|
+
return _color_array(_WHITE if value else _BLACK)
|
|
523
|
+
if not self.rgb:
|
|
524
|
+
if isinstance(value, (int, np.integer)) and value in (0, 1):
|
|
525
|
+
return _color_array(_WHITE if value else _BLACK)
|
|
526
|
+
raise ValueError("in black-and-white mode assign True/False (or 1/0)")
|
|
527
|
+
return _color_array(_parse_color(value))
|
|
528
|
+
|
|
529
|
+
@_draws
|
|
530
|
+
def __setitem__(self, key, value) -> None:
|
|
531
|
+
(x0, x1), (y0, y1), single = self._span(key)
|
|
532
|
+
color = self._index_color(value)
|
|
533
|
+
if single:
|
|
534
|
+
self._plot(x0, y0, "set", color, strict=self.strict)
|
|
535
|
+
else:
|
|
536
|
+
self._fill_rect(x0, y0, x1 - x0, y1 - y0, "set", color)
|
|
537
|
+
|
|
538
|
+
@_draws
|
|
539
|
+
def rect(self, x: int, y: int, w: int, h: int, fill: bool = True, mode: Mode = "set", *, color=None) -> None:
|
|
540
|
+
_check_mode(mode)
|
|
541
|
+
x, y = _as_int(x, "x"), _as_int(y, "y")
|
|
542
|
+
w, h = _non_negative(w, "w"), _non_negative(h, "h")
|
|
543
|
+
color = self._resolve(color)
|
|
544
|
+
if w == 0 or h == 0:
|
|
545
|
+
return
|
|
546
|
+
if fill or w <= 2 or h <= 2:
|
|
547
|
+
self._fill_rect(x, y, w, h, mode, color)
|
|
548
|
+
return
|
|
549
|
+
self._fill_rect(x, y, w, 1, mode, color)
|
|
550
|
+
self._fill_rect(x, y + h - 1, w, 1, mode, color)
|
|
551
|
+
self._fill_rect(x, y + 1, 1, h - 2, mode, color)
|
|
552
|
+
self._fill_rect(x + w - 1, y + 1, 1, h - 2, mode, color)
|
|
553
|
+
|
|
554
|
+
@_draws
|
|
555
|
+
def line(self, x0: int, y0: int, x1: int, y1: int, mode: Mode = "set", *, color=None) -> None:
|
|
556
|
+
_check_mode(mode)
|
|
557
|
+
x0, y0 = _as_int(x0, "x0"), _as_int(y0, "y0")
|
|
558
|
+
x1, y1 = _as_int(x1, "x1"), _as_int(y1, "y1")
|
|
559
|
+
color = self._resolve(color)
|
|
560
|
+
dx, dy = abs(x1 - x0), -abs(y1 - y0)
|
|
561
|
+
step_x = 1 if x0 < x1 else -1
|
|
562
|
+
step_y = 1 if y0 < y1 else -1
|
|
563
|
+
error = dx + dy
|
|
564
|
+
while True:
|
|
565
|
+
self._plot(x0, y0, mode, color)
|
|
566
|
+
if x0 == x1 and y0 == y1:
|
|
567
|
+
break
|
|
568
|
+
doubled = 2 * error
|
|
569
|
+
if doubled >= dy:
|
|
570
|
+
error += dy
|
|
571
|
+
x0 += step_x
|
|
572
|
+
if doubled <= dx:
|
|
573
|
+
error += dx
|
|
574
|
+
y0 += step_y
|
|
575
|
+
|
|
576
|
+
@_draws
|
|
577
|
+
def circle(self, cx: int, cy: int, r: int, fill: bool = False, mode: Mode = "set", *, color=None) -> None:
|
|
578
|
+
_check_mode(mode)
|
|
579
|
+
cx, cy = _as_int(cx, "cx"), _as_int(cy, "cy")
|
|
580
|
+
r = _non_negative(r, "r")
|
|
581
|
+
color = self._resolve(color)
|
|
582
|
+
if fill:
|
|
583
|
+
for dy in range(-r, r + 1):
|
|
584
|
+
dx = math.isqrt(r * r - dy * dy)
|
|
585
|
+
self._fill_rect(cx - dx, cy + dy, 2 * dx + 1, 1, mode, color)
|
|
586
|
+
return
|
|
587
|
+
points = set()
|
|
588
|
+
x, y, error = r, 0, 1 - r
|
|
589
|
+
while x >= y:
|
|
590
|
+
for px, py in ((x, y), (y, x), (-x, y), (-y, x), (x, -y), (y, -x), (-x, -y), (-y, -x)):
|
|
591
|
+
points.add((cx + px, cy + py))
|
|
592
|
+
y += 1
|
|
593
|
+
if error < 0:
|
|
594
|
+
error += 2 * y + 1
|
|
595
|
+
else:
|
|
596
|
+
x -= 1
|
|
597
|
+
error += 2 * (y - x) + 1
|
|
598
|
+
for px, py in points:
|
|
599
|
+
self._plot(px, py, mode, color)
|
|
600
|
+
|
|
601
|
+
@_draws
|
|
602
|
+
def text(self, x: int, y: int, text: str, mode: Mode = "set", spacing: int = 1, *, color=None) -> int:
|
|
603
|
+
_check_mode(mode)
|
|
604
|
+
if not isinstance(text, str):
|
|
605
|
+
raise TypeError(f"text must be a string, got {type(text).__name__}")
|
|
606
|
+
left = cursor_x = _as_int(x, "x")
|
|
607
|
+
cursor_y = _as_int(y, "y")
|
|
608
|
+
spacing = _non_negative(spacing, "spacing")
|
|
609
|
+
color = self._resolve(color)
|
|
610
|
+
for char in text:
|
|
611
|
+
if char == "\n":
|
|
612
|
+
cursor_x, cursor_y = left, cursor_y + _GLYPH_HEIGHT + 1
|
|
613
|
+
continue
|
|
614
|
+
self._blit(_GLYPHS.get(char.upper(), _GLYPHS["?"]), False, cursor_x, cursor_y, mode, True, color)
|
|
615
|
+
cursor_x += _GLYPH_WIDTH + spacing
|
|
616
|
+
return cursor_x
|
|
617
|
+
|
|
618
|
+
@_draws
|
|
619
|
+
def paste(self, source, x: int = 0, y: int = 0, mode: Mode = "set", transparent: bool = True,
|
|
620
|
+
*, color=None) -> None:
|
|
621
|
+
_check_mode(mode)
|
|
622
|
+
if not transparent and mode != "set":
|
|
623
|
+
raise ValueError("transparent=False works only with mode='set'")
|
|
624
|
+
array, is_rgb = _source(source)
|
|
625
|
+
if is_rgb and not self.rgb:
|
|
626
|
+
raise ValueError("RGB pictures need rgb=True")
|
|
627
|
+
self._blit(array, is_rgb, _as_int(x, "x"), _as_int(y, "y"), mode, bool(transparent), self._resolve(color))
|
|
628
|
+
|
|
629
|
+
@_draws
|
|
630
|
+
def gradient(self, x: int, y: int, w: int, h: int, color1, color2, direction: str = "horizontal",
|
|
631
|
+
mode: Mode = "set") -> None:
|
|
632
|
+
if not self.rgb:
|
|
633
|
+
raise ValueError("gradient() needs rgb=True")
|
|
634
|
+
_check_mode(mode)
|
|
635
|
+
if direction not in _DIRECTIONS:
|
|
636
|
+
raise ValueError(f"direction must be one of {_DIRECTIONS}, got {direction!r}")
|
|
637
|
+
x, y = _as_int(x, "x"), _as_int(y, "y")
|
|
638
|
+
w, h = _non_negative(w, "w"), _non_negative(h, "h")
|
|
639
|
+
start = np.array(_parse_color(color1), dtype=float)
|
|
640
|
+
end = np.array(_parse_color(color2), dtype=float)
|
|
641
|
+
if w == 0 or h == 0:
|
|
642
|
+
return
|
|
643
|
+
if mode == "clear":
|
|
644
|
+
self._fill_rect(x, y, w, h, "clear", _color_array(_BLACK))
|
|
645
|
+
return
|
|
646
|
+
horizontal = direction == "horizontal"
|
|
647
|
+
steps = w if horizontal else h
|
|
648
|
+
t = np.linspace(0.0, 1.0, steps) if steps > 1 else np.zeros(1)
|
|
649
|
+
ramp = np.rint(start + (end - start) * t[:, None]).astype(np.uint8)
|
|
650
|
+
patch = np.broadcast_to(ramp[None, :, :], (h, w, 3)) if horizontal else np.broadcast_to(ramp[:, None, :], (h, w, 3))
|
|
651
|
+
self._blit(patch, True, x, y, mode, mode == "xor", _color_array(_BLACK))
|
|
652
|
+
|
|
653
|
+
@_draws
|
|
654
|
+
def scroll(self, dx: int = 0, dy: int = 0, wrap: bool = False) -> None:
|
|
655
|
+
dx, dy = _as_int(dx, "dx"), _as_int(dy, "dy")
|
|
656
|
+
if wrap:
|
|
657
|
+
self._a[...] = np.roll(self._a, (dy, dx), axis=(0, 1))
|
|
658
|
+
return
|
|
659
|
+
height, width = self._a.shape[:2]
|
|
660
|
+
shifted = np.zeros_like(self._a)
|
|
661
|
+
src_y0, src_y1 = max(0, -dy), min(height, height - dy)
|
|
662
|
+
src_x0, src_x1 = max(0, -dx), min(width, width - dx)
|
|
663
|
+
if src_y1 > src_y0 and src_x1 > src_x0:
|
|
664
|
+
shifted[src_y0 + dy:src_y1 + dy, src_x0 + dx:src_x1 + dx] = self._a[src_y0:src_y1, src_x0:src_x1]
|
|
665
|
+
self._a[...] = shifted
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
class Image(_Drawing):
|
|
669
|
+
def __init__(self, width: int, height: int, rgb: bool = False, strict: bool = False):
|
|
670
|
+
self.width = _positive(width, "width")
|
|
671
|
+
self.height = _positive(height, "height")
|
|
672
|
+
self.rgb = bool(rgb)
|
|
673
|
+
self.strict = strict
|
|
674
|
+
self._color = _WHITE
|
|
675
|
+
self._a = np.zeros((self.height, self.width, 3), dtype=np.uint8)
|
|
676
|
+
|
|
677
|
+
@staticmethod
|
|
678
|
+
def from_matrix(matrix, strict: bool = False) -> Image:
|
|
679
|
+
array, is_rgb = _normalize(matrix)
|
|
680
|
+
image = Image(array.shape[1], array.shape[0], rgb=is_rgb, strict=strict)
|
|
681
|
+
image._a[...] = array if is_rgb else (array[..., None] * 255).astype(np.uint8)
|
|
682
|
+
return image
|
|
683
|
+
|
|
684
|
+
@staticmethod
|
|
685
|
+
def from_ascii(art: str, palette: dict | None = None, on: str = "#X1@", strict: bool = False) -> Image:
|
|
686
|
+
if palette is None:
|
|
687
|
+
return Image.from_matrix(_parse_ascii(art, on), strict)
|
|
688
|
+
if not isinstance(palette, dict) or not palette:
|
|
689
|
+
raise ValueError("palette must be a non-empty dict like {'#': 'red', 'o': (0, 0, 255)}")
|
|
690
|
+
colors = {}
|
|
691
|
+
for char, color in palette.items():
|
|
692
|
+
if not isinstance(char, str) or len(char) != 1:
|
|
693
|
+
raise ValueError(f"palette keys must be single characters, got {char!r}")
|
|
694
|
+
colors[char] = _parse_color(color)
|
|
695
|
+
lines = _ascii_lines(art)
|
|
696
|
+
image = Image(max(len(line) for line in lines), len(lines), rgb=True, strict=strict)
|
|
697
|
+
for row, line in enumerate(lines):
|
|
698
|
+
for col, char in enumerate(line):
|
|
699
|
+
if char in colors:
|
|
700
|
+
image._a[row, col] = colors[char]
|
|
701
|
+
return image
|
|
702
|
+
|
|
703
|
+
@property
|
|
704
|
+
def color(self) -> tuple[int, int, int]:
|
|
705
|
+
return self._color
|
|
706
|
+
|
|
707
|
+
@color.setter
|
|
708
|
+
def color(self, value) -> None:
|
|
709
|
+
if not self.rgb:
|
|
710
|
+
raise ValueError("color is available only with rgb=True")
|
|
711
|
+
self._color = _parse_color(value)
|
|
712
|
+
|
|
713
|
+
@property
|
|
714
|
+
def array(self) -> np.ndarray:
|
|
715
|
+
with self._guard:
|
|
716
|
+
return _public(self._a, self.rgb)
|
|
717
|
+
|
|
718
|
+
def to_ascii(self, on: str = "#", off: str = ".") -> str:
|
|
719
|
+
with self._guard:
|
|
720
|
+
lit = _lit(self._a)
|
|
721
|
+
return _to_ascii(lit, on, off)
|
|
722
|
+
|
|
723
|
+
def save_png(self, path, scale: int = 10) -> None:
|
|
724
|
+
with self._guard:
|
|
725
|
+
display = self._a.copy()
|
|
726
|
+
_save_png(display, path, _positive(scale, "scale"))
|
|
727
|
+
|
|
728
|
+
def __str__(self) -> str:
|
|
729
|
+
return self.to_ascii()
|
|
730
|
+
|
|
731
|
+
def __repr__(self) -> str:
|
|
732
|
+
mode = "rgb" if self.rgb else "mono"
|
|
733
|
+
return f"<{type(self).__name__} {self.width}x{self.height} {mode}>"
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
class _FrameImage(Image):
|
|
737
|
+
def __init__(self, width: int, height: int, rgb: bool, strict: bool, color, initial: np.ndarray | None,
|
|
738
|
+
publish):
|
|
739
|
+
super().__init__(width, height, rgb=rgb, strict=strict)
|
|
740
|
+
self._color = color
|
|
741
|
+
if initial is not None:
|
|
742
|
+
self._a[...] = initial
|
|
743
|
+
self._publish = publish
|
|
744
|
+
|
|
745
|
+
def show(self) -> None:
|
|
746
|
+
self._publish(self._a)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
class Layer(Image):
|
|
750
|
+
def __init__(self, widget: PixelWidget, name: str, blend: Blend = "or"):
|
|
751
|
+
super().__init__(widget.width, widget.height, rgb=widget.rgb, strict=widget.strict)
|
|
752
|
+
_check_blend(blend)
|
|
753
|
+
self._widget = widget
|
|
754
|
+
self._guard = widget._lock
|
|
755
|
+
self.name = name
|
|
756
|
+
self.blend = blend
|
|
757
|
+
self._visible = True
|
|
758
|
+
|
|
759
|
+
def _before(self) -> None:
|
|
760
|
+
self._widget._check()
|
|
761
|
+
|
|
762
|
+
def _touch(self) -> None:
|
|
763
|
+
self._widget._version += 1
|
|
764
|
+
|
|
765
|
+
@property
|
|
766
|
+
def visible(self) -> bool:
|
|
767
|
+
return self._visible
|
|
768
|
+
|
|
769
|
+
@visible.setter
|
|
770
|
+
def visible(self, value: bool) -> None:
|
|
771
|
+
with self._guard:
|
|
772
|
+
self._visible = bool(value)
|
|
773
|
+
self._widget._version += 1
|
|
774
|
+
|
|
775
|
+
def __repr__(self) -> str:
|
|
776
|
+
return f"<Layer {self.name!r} blend={self.blend} visible={self._visible}>"
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
class _Frame:
|
|
780
|
+
def __init__(self, widget: PixelWidget, layer: str | Layer, keep: bool):
|
|
781
|
+
self._widget = widget
|
|
782
|
+
self._layer_ref = layer
|
|
783
|
+
self._keep = keep
|
|
784
|
+
self._image: _FrameImage | None = None
|
|
785
|
+
|
|
786
|
+
def open(self) -> _FrameImage:
|
|
787
|
+
widget = self._widget
|
|
788
|
+
widget._check()
|
|
789
|
+
layer = self._layer_ref if isinstance(self._layer_ref, Layer) else widget.layer(self._layer_ref)
|
|
790
|
+
with widget._lock:
|
|
791
|
+
initial = layer._a.copy() if self._keep else None
|
|
792
|
+
color = layer.color
|
|
793
|
+
|
|
794
|
+
def publish(array: np.ndarray) -> None:
|
|
795
|
+
with widget._lock:
|
|
796
|
+
layer._a[...] = array
|
|
797
|
+
widget._version += 1
|
|
798
|
+
|
|
799
|
+
self._image = _FrameImage(widget.width, widget.height, widget.rgb, widget.strict, color, initial, publish)
|
|
800
|
+
return self._image
|
|
801
|
+
|
|
802
|
+
def __enter__(self) -> _FrameImage:
|
|
803
|
+
return self.open()
|
|
804
|
+
|
|
805
|
+
def __exit__(self, exc_type, exc, traceback_) -> bool:
|
|
806
|
+
if exc_type is None and self._image is not None:
|
|
807
|
+
self._image.show()
|
|
808
|
+
return False
|
|
809
|
+
|
|
810
|
+
async def __aenter__(self) -> _FrameImage:
|
|
811
|
+
return self.open()
|
|
812
|
+
|
|
813
|
+
async def __aexit__(self, exc_type, exc, traceback_) -> bool:
|
|
814
|
+
self.__exit__(exc_type, exc, traceback_)
|
|
815
|
+
await asyncio.sleep(0)
|
|
816
|
+
return False
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
class _Pacer:
|
|
820
|
+
def __init__(self, fps: float):
|
|
821
|
+
if isinstance(fps, bool) or not isinstance(fps, (int, float)) or fps <= 0:
|
|
822
|
+
raise ValueError(f"fps must be a positive number, got {fps!r}")
|
|
823
|
+
self._period = 1.0 / fps
|
|
824
|
+
self._next = time.monotonic()
|
|
825
|
+
|
|
826
|
+
def delay(self) -> float:
|
|
827
|
+
self._next += self._period
|
|
828
|
+
now = time.monotonic()
|
|
829
|
+
remaining = self._next - now
|
|
830
|
+
if remaining < 0:
|
|
831
|
+
self._next = now
|
|
832
|
+
return 0.0
|
|
833
|
+
return remaining
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
class PixelWidget(_Drawing):
|
|
837
|
+
def __init__(
|
|
838
|
+
self,
|
|
839
|
+
width: int = 32,
|
|
840
|
+
height: int = 32,
|
|
841
|
+
window_size: int | tuple[int, int] = 320,
|
|
842
|
+
title: str = "Pinned Widget",
|
|
843
|
+
position: tuple[int, int] = (3840, 1032),
|
|
844
|
+
fallback_position: tuple[int, int] = (1920, 1008),
|
|
845
|
+
anchor: Anchor = "bottom_right",
|
|
846
|
+
fps: int = 30,
|
|
847
|
+
always_on_top: bool = True,
|
|
848
|
+
hide_from_taskbar: bool = True,
|
|
849
|
+
strict: bool = False,
|
|
850
|
+
rgb: bool = False,
|
|
851
|
+
on_color=None,
|
|
852
|
+
off_color=None,
|
|
853
|
+
brightness: float = 1.0,
|
|
854
|
+
):
|
|
855
|
+
self.width = _positive(width, "width")
|
|
856
|
+
self.height = _positive(height, "height")
|
|
857
|
+
self.window_width, self.window_height = _size_pair(window_size, "window_size")
|
|
858
|
+
_check_anchor(anchor)
|
|
859
|
+
if isinstance(fps, bool) or not isinstance(fps, (int, float)) or fps <= 0:
|
|
860
|
+
raise ValueError(f"fps must be a positive number, got {fps!r}")
|
|
861
|
+
if rgb and (on_color is not None or off_color is not None):
|
|
862
|
+
raise ValueError("on_color/off_color are for the black-and-white mode; they cannot be used with rgb=True")
|
|
863
|
+
|
|
864
|
+
self.title = title
|
|
865
|
+
self.anchor = anchor
|
|
866
|
+
self.fps = fps
|
|
867
|
+
self.always_on_top = always_on_top
|
|
868
|
+
self.hide_from_taskbar = hide_from_taskbar
|
|
869
|
+
self.strict = strict
|
|
870
|
+
self.rgb = bool(rgb)
|
|
871
|
+
self._on = None if rgb else _parse_color("white" if on_color is None else on_color)
|
|
872
|
+
self._off = None if rgb else _parse_color("black" if off_color is None else off_color)
|
|
873
|
+
self._brightness = _unit_float(brightness, "brightness")
|
|
874
|
+
|
|
875
|
+
_Win32.enable_dpi_awareness()
|
|
876
|
+
window = (self.window_width, self.window_height)
|
|
877
|
+
preferred = _top_left(position, window, anchor)
|
|
878
|
+
fallback = _top_left(fallback_position, window, anchor)
|
|
879
|
+
self.position = preferred if _Win32.rect_visible(*preferred, *window) else fallback
|
|
880
|
+
|
|
881
|
+
self._lock = threading.RLock()
|
|
882
|
+
self._guard = self._lock
|
|
883
|
+
self._cond = threading.Condition(self._lock)
|
|
884
|
+
self._version = 0
|
|
885
|
+
self._presented = -1
|
|
886
|
+
self._layers: dict[str, Layer] = {}
|
|
887
|
+
self._base = self._new_layer("main", "or")
|
|
888
|
+
|
|
889
|
+
self._running = True
|
|
890
|
+
self._error: BaseException | None = None
|
|
891
|
+
self._ready = threading.Event()
|
|
892
|
+
self._closed = threading.Event()
|
|
893
|
+
|
|
894
|
+
os.environ["SDL_VIDEO_WINDOW_POS"] = f"{self.position[0]},{self.position[1]}"
|
|
895
|
+
self._thread = threading.Thread(target=self._run, name="PixelWidget-render", daemon=True)
|
|
896
|
+
self._thread.start()
|
|
897
|
+
|
|
898
|
+
if not self._ready.wait(timeout=_READY_TIMEOUT):
|
|
899
|
+
raise RuntimeError(f"PixelWidget: the window was not created within {_READY_TIMEOUT:.0f} seconds")
|
|
900
|
+
self._check()
|
|
901
|
+
|
|
902
|
+
@property
|
|
903
|
+
def _a(self) -> np.ndarray:
|
|
904
|
+
return self._base._a
|
|
905
|
+
|
|
906
|
+
@property
|
|
907
|
+
def running(self) -> bool:
|
|
908
|
+
return self._running
|
|
909
|
+
|
|
910
|
+
@property
|
|
911
|
+
def layers(self) -> list[str]:
|
|
912
|
+
with self._lock:
|
|
913
|
+
return list(self._layers)
|
|
914
|
+
|
|
915
|
+
@property
|
|
916
|
+
def color(self) -> tuple[int, int, int]:
|
|
917
|
+
return self._base.color
|
|
918
|
+
|
|
919
|
+
@color.setter
|
|
920
|
+
def color(self, value) -> None:
|
|
921
|
+
self._base.color = value
|
|
922
|
+
|
|
923
|
+
@property
|
|
924
|
+
def on_color(self):
|
|
925
|
+
return self._on
|
|
926
|
+
|
|
927
|
+
@on_color.setter
|
|
928
|
+
def on_color(self, value) -> None:
|
|
929
|
+
self._set_mono_color("_on", value)
|
|
930
|
+
|
|
931
|
+
@property
|
|
932
|
+
def off_color(self):
|
|
933
|
+
return self._off
|
|
934
|
+
|
|
935
|
+
@off_color.setter
|
|
936
|
+
def off_color(self, value) -> None:
|
|
937
|
+
self._set_mono_color("_off", value)
|
|
938
|
+
|
|
939
|
+
@property
|
|
940
|
+
def brightness(self) -> float:
|
|
941
|
+
return self._brightness
|
|
942
|
+
|
|
943
|
+
@brightness.setter
|
|
944
|
+
def brightness(self, value: float) -> None:
|
|
945
|
+
value = _unit_float(value, "brightness")
|
|
946
|
+
with self._lock:
|
|
947
|
+
self._brightness = value
|
|
948
|
+
self._version += 1
|
|
949
|
+
|
|
950
|
+
def _set_mono_color(self, attribute: str, value) -> None:
|
|
951
|
+
if self.rgb:
|
|
952
|
+
raise ValueError("on_color/off_color are for the black-and-white mode")
|
|
953
|
+
color = _parse_color(value)
|
|
954
|
+
with self._lock:
|
|
955
|
+
setattr(self, attribute, color)
|
|
956
|
+
self._version += 1
|
|
957
|
+
|
|
958
|
+
def _before(self) -> None:
|
|
959
|
+
self._check()
|
|
960
|
+
|
|
961
|
+
def _touch(self) -> None:
|
|
962
|
+
self._version += 1
|
|
963
|
+
|
|
964
|
+
def _check(self) -> None:
|
|
965
|
+
if self._error is not None:
|
|
966
|
+
raise RuntimeError("PixelWidget: the window thread has crashed") from self._error
|
|
967
|
+
|
|
968
|
+
def _new_layer(self, name: str, blend: Blend) -> Layer:
|
|
969
|
+
layer = Layer(self, name, blend)
|
|
970
|
+
self._layers[name] = layer
|
|
971
|
+
return layer
|
|
972
|
+
|
|
973
|
+
def layer(self, name: str = "main", blend: Blend | None = None) -> Layer:
|
|
974
|
+
if not isinstance(name, str) or not name:
|
|
975
|
+
raise ValueError(f"a layer name must be a non-empty string, got {name!r}")
|
|
976
|
+
if blend is not None:
|
|
977
|
+
_check_blend(blend)
|
|
978
|
+
with self._lock:
|
|
979
|
+
layer = self._layers.get(name)
|
|
980
|
+
if layer is None:
|
|
981
|
+
layer = self._new_layer(name, blend or "or")
|
|
982
|
+
self._version += 1
|
|
983
|
+
elif blend is not None and blend != layer.blend:
|
|
984
|
+
layer.blend = blend
|
|
985
|
+
self._version += 1
|
|
986
|
+
return layer
|
|
987
|
+
|
|
988
|
+
def remove_layer(self, name: str) -> None:
|
|
989
|
+
if name == "main":
|
|
990
|
+
raise ValueError("the 'main' layer cannot be removed")
|
|
991
|
+
with self._lock:
|
|
992
|
+
if self._layers.pop(name, None) is not None:
|
|
993
|
+
self._version += 1
|
|
994
|
+
|
|
995
|
+
def reset(self) -> None:
|
|
996
|
+
self._check()
|
|
997
|
+
with self._lock:
|
|
998
|
+
for name in list(self._layers):
|
|
999
|
+
if name != "main":
|
|
1000
|
+
del self._layers[name]
|
|
1001
|
+
self._base._a[...] = 0
|
|
1002
|
+
self._base._visible = True
|
|
1003
|
+
self._base.blend = "or"
|
|
1004
|
+
self._base._color = _WHITE
|
|
1005
|
+
self._version += 1
|
|
1006
|
+
|
|
1007
|
+
def _compose_locked(self) -> np.ndarray:
|
|
1008
|
+
image = np.zeros((self.height, self.width, 3), dtype=np.uint8)
|
|
1009
|
+
for layer in self._layers.values():
|
|
1010
|
+
if not layer._visible:
|
|
1011
|
+
continue
|
|
1012
|
+
if layer.blend == "xor":
|
|
1013
|
+
image ^= layer._a
|
|
1014
|
+
else:
|
|
1015
|
+
lit = layer._a.any(axis=2)
|
|
1016
|
+
image[lit] = layer._a[lit]
|
|
1017
|
+
return image
|
|
1018
|
+
|
|
1019
|
+
def _visible_array(self) -> np.ndarray:
|
|
1020
|
+
return self._compose_locked()
|
|
1021
|
+
|
|
1022
|
+
def _display_locked(self) -> np.ndarray:
|
|
1023
|
+
image = self._compose_locked()
|
|
1024
|
+
if not self.rgb:
|
|
1025
|
+
image = np.where(_lit(image)[..., None], _color_array(self._on), _color_array(self._off))
|
|
1026
|
+
if self._brightness < 1.0:
|
|
1027
|
+
image = (image * self._brightness).astype(np.uint8)
|
|
1028
|
+
return image.astype(np.uint8)
|
|
1029
|
+
|
|
1030
|
+
def frame(self, layer: str | Layer = "main", keep: bool = False) -> _Frame:
|
|
1031
|
+
return _Frame(self, layer, keep)
|
|
1032
|
+
|
|
1033
|
+
def loop(self, fps: float = 25, layer: str | Layer = "main", keep: bool = False, frames: int | None = None):
|
|
1034
|
+
pacer = _Pacer(fps)
|
|
1035
|
+
limit = None if frames is None else _non_negative(frames, "frames")
|
|
1036
|
+
count = 0
|
|
1037
|
+
while self._running and (limit is None or count < limit):
|
|
1038
|
+
image = self.frame(layer, keep).open()
|
|
1039
|
+
try:
|
|
1040
|
+
yield image
|
|
1041
|
+
finally:
|
|
1042
|
+
image.show()
|
|
1043
|
+
count += 1
|
|
1044
|
+
time.sleep(pacer.delay())
|
|
1045
|
+
self._check()
|
|
1046
|
+
|
|
1047
|
+
async def aloop(self, fps: float = 25, layer: str | Layer = "main", keep: bool = False,
|
|
1048
|
+
frames: int | None = None):
|
|
1049
|
+
pacer = _Pacer(fps)
|
|
1050
|
+
limit = None if frames is None else _non_negative(frames, "frames")
|
|
1051
|
+
count = 0
|
|
1052
|
+
while self._running and (limit is None or count < limit):
|
|
1053
|
+
image = self.frame(layer, keep).open()
|
|
1054
|
+
try:
|
|
1055
|
+
yield image
|
|
1056
|
+
finally:
|
|
1057
|
+
image.show()
|
|
1058
|
+
count += 1
|
|
1059
|
+
await asyncio.sleep(pacer.delay())
|
|
1060
|
+
self._check()
|
|
1061
|
+
|
|
1062
|
+
def play(self, frames, fps: float = 10, repeat: int | None = 1, layer: str | Layer = "main") -> None:
|
|
1063
|
+
pictures = [_source(item)[0] for item in frames]
|
|
1064
|
+
pacer = _Pacer(fps)
|
|
1065
|
+
limit = None if repeat is None else _non_negative(repeat, "repeat")
|
|
1066
|
+
count = 0
|
|
1067
|
+
while self._running and (limit is None or count < limit):
|
|
1068
|
+
for picture in pictures:
|
|
1069
|
+
if not self._running:
|
|
1070
|
+
return
|
|
1071
|
+
with self.frame(layer) as image:
|
|
1072
|
+
image.paste(picture, 0, 0, transparent=False)
|
|
1073
|
+
time.sleep(pacer.delay())
|
|
1074
|
+
count += 1
|
|
1075
|
+
self._check()
|
|
1076
|
+
|
|
1077
|
+
async def aplay(self, frames, fps: float = 10, repeat: int | None = 1, layer: str | Layer = "main") -> None:
|
|
1078
|
+
pictures = [_source(item)[0] for item in frames]
|
|
1079
|
+
pacer = _Pacer(fps)
|
|
1080
|
+
limit = None if repeat is None else _non_negative(repeat, "repeat")
|
|
1081
|
+
count = 0
|
|
1082
|
+
while self._running and (limit is None or count < limit):
|
|
1083
|
+
for picture in pictures:
|
|
1084
|
+
if not self._running:
|
|
1085
|
+
return
|
|
1086
|
+
async with self.frame(layer) as image:
|
|
1087
|
+
image.paste(picture, 0, 0, transparent=False)
|
|
1088
|
+
await asyncio.sleep(pacer.delay())
|
|
1089
|
+
count += 1
|
|
1090
|
+
self._check()
|
|
1091
|
+
|
|
1092
|
+
def flush(self, timeout: float = 1.0) -> bool:
|
|
1093
|
+
with self._cond:
|
|
1094
|
+
target = self._version
|
|
1095
|
+
self._cond.wait_for(lambda: self._presented >= target or not self._running, timeout)
|
|
1096
|
+
return self._presented >= target or not self._running
|
|
1097
|
+
|
|
1098
|
+
async def aflush(self, timeout: float = 1.0) -> bool:
|
|
1099
|
+
return await asyncio.get_running_loop().run_in_executor(None, self.flush, timeout)
|
|
1100
|
+
|
|
1101
|
+
def wait(self, timeout: float | None = None) -> bool:
|
|
1102
|
+
return self._closed.wait(timeout)
|
|
1103
|
+
|
|
1104
|
+
def close(self) -> None:
|
|
1105
|
+
self._running = False
|
|
1106
|
+
if threading.current_thread() is not self._thread:
|
|
1107
|
+
self._thread.join(timeout=2)
|
|
1108
|
+
|
|
1109
|
+
def __enter__(self) -> PixelWidget:
|
|
1110
|
+
return self
|
|
1111
|
+
|
|
1112
|
+
def __exit__(self, *exc_info) -> None:
|
|
1113
|
+
self.close()
|
|
1114
|
+
|
|
1115
|
+
def get_frame(self) -> np.ndarray:
|
|
1116
|
+
with self._lock:
|
|
1117
|
+
return _public(self._compose_locked(), self.rgb)
|
|
1118
|
+
|
|
1119
|
+
def to_ascii(self, on: str = "#", off: str = ".") -> str:
|
|
1120
|
+
with self._lock:
|
|
1121
|
+
lit = _lit(self._compose_locked())
|
|
1122
|
+
return _to_ascii(lit, on, off)
|
|
1123
|
+
|
|
1124
|
+
def save_png(self, path, scale: int | None = None) -> None:
|
|
1125
|
+
scale = max(1, self.window_width // self.width) if scale is None else _positive(scale, "scale")
|
|
1126
|
+
with self._lock:
|
|
1127
|
+
display = self._display_locked()
|
|
1128
|
+
_save_png(display, path, scale)
|
|
1129
|
+
|
|
1130
|
+
def snapshot(self) -> dict[str, tuple[np.ndarray, bool, str, tuple[int, int, int]]]:
|
|
1131
|
+
with self._lock:
|
|
1132
|
+
return {
|
|
1133
|
+
name: (layer._a.copy(), layer._visible, layer.blend, layer._color)
|
|
1134
|
+
for name, layer in self._layers.items()
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
def restore(self, snapshot: dict[str, tuple[np.ndarray, bool, str, tuple[int, int, int]]]) -> None:
|
|
1138
|
+
self._check()
|
|
1139
|
+
with self._lock:
|
|
1140
|
+
for name in list(self._layers):
|
|
1141
|
+
if name != "main" and name not in snapshot:
|
|
1142
|
+
del self._layers[name]
|
|
1143
|
+
for name, (array, visible, blend, color) in snapshot.items():
|
|
1144
|
+
layer = self._layers.get(name) or self._new_layer(name, blend)
|
|
1145
|
+
layer._a[...] = array
|
|
1146
|
+
layer._visible = visible
|
|
1147
|
+
layer.blend = blend
|
|
1148
|
+
layer._color = color
|
|
1149
|
+
self._version += 1
|
|
1150
|
+
|
|
1151
|
+
def __str__(self) -> str:
|
|
1152
|
+
return self.to_ascii()
|
|
1153
|
+
|
|
1154
|
+
def __repr__(self) -> str:
|
|
1155
|
+
state = "running" if self._running else "closed"
|
|
1156
|
+
mode = "rgb" if self.rgb else "mono"
|
|
1157
|
+
return (f"<PixelWidget {self.width}x{self.height} {mode} window={self.window_width}x{self.window_height} "
|
|
1158
|
+
f"at {self.position} {state} layers={self.layers}>")
|
|
1159
|
+
|
|
1160
|
+
def _pin(self, hwnd: int, first: bool = False) -> None:
|
|
1161
|
+
_Win32.pin(hwnd, *self.position, self.always_on_top, frame_changed=first)
|
|
1162
|
+
|
|
1163
|
+
def _open_window(self):
|
|
1164
|
+
pygame.display.init()
|
|
1165
|
+
screen = pygame.display.set_mode((self.window_width, self.window_height), pygame.NOFRAME)
|
|
1166
|
+
pygame.display.set_caption(self.title)
|
|
1167
|
+
small = pygame.Surface((self.width, self.height))
|
|
1168
|
+
hwnd = pygame.display.get_wm_info()["window"] if _Win32.ENABLED else None
|
|
1169
|
+
if hwnd is not None:
|
|
1170
|
+
if self.hide_from_taskbar:
|
|
1171
|
+
_Win32.hide_from_taskbar(hwnd)
|
|
1172
|
+
self._pin(hwnd, first=True)
|
|
1173
|
+
return screen, small, hwnd
|
|
1174
|
+
|
|
1175
|
+
def _poll_events(self) -> None:
|
|
1176
|
+
for event in pygame.event.get():
|
|
1177
|
+
if event.type == pygame.QUIT:
|
|
1178
|
+
self._running = False
|
|
1179
|
+
elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
|
|
1180
|
+
self._running = False
|
|
1181
|
+
|
|
1182
|
+
def _render_loop(self, screen, small, hwnd) -> None:
|
|
1183
|
+
size = (self.window_width, self.window_height)
|
|
1184
|
+
clock = pygame.time.Clock()
|
|
1185
|
+
last_pin = time.monotonic()
|
|
1186
|
+
rendered = -1
|
|
1187
|
+
while self._running:
|
|
1188
|
+
self._poll_events()
|
|
1189
|
+
if hwnd is not None and time.monotonic() - last_pin > _PIN_INTERVAL:
|
|
1190
|
+
self._pin(hwnd)
|
|
1191
|
+
last_pin = time.monotonic()
|
|
1192
|
+
with self._lock:
|
|
1193
|
+
version = self._version
|
|
1194
|
+
image = self._display_locked() if version != rendered else None
|
|
1195
|
+
if image is not None:
|
|
1196
|
+
pygame.surfarray.blit_array(small, np.ascontiguousarray(image.transpose(1, 0, 2)))
|
|
1197
|
+
screen.blit(pygame.transform.scale(small, size), (0, 0))
|
|
1198
|
+
pygame.display.flip()
|
|
1199
|
+
rendered = version
|
|
1200
|
+
with self._cond:
|
|
1201
|
+
self._presented = version
|
|
1202
|
+
self._cond.notify_all()
|
|
1203
|
+
clock.tick(self.fps)
|
|
1204
|
+
|
|
1205
|
+
def _run(self) -> None:
|
|
1206
|
+
try:
|
|
1207
|
+
screen, small, hwnd = self._open_window()
|
|
1208
|
+
self._ready.set()
|
|
1209
|
+
self._render_loop(screen, small, hwnd)
|
|
1210
|
+
except BaseException as exc:
|
|
1211
|
+
self._error = exc
|
|
1212
|
+
traceback.print_exc()
|
|
1213
|
+
finally:
|
|
1214
|
+
self._running = False
|
|
1215
|
+
with contextlib.suppress(Exception):
|
|
1216
|
+
pygame.display.quit()
|
|
1217
|
+
self._ready.set()
|
|
1218
|
+
with self._cond:
|
|
1219
|
+
self._cond.notify_all()
|
|
1220
|
+
self._closed.set()
|