pgwidgets-python 0.2.3__py3-none-any.whl → 0.3.1__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.
- pgwidgets/__init__.py +2 -0
- pgwidgets/async_/application.py +295 -31
- pgwidgets/async_/widget.py +9 -1
- pgwidgets/buffer.py +125 -0
- pgwidgets/extras/file_browser.py +7 -2
- pgwidgets/method_types.py +45 -2
- pgwidgets/sync/application.py +378 -30
- pgwidgets/sync/widget.py +9 -1
- {pgwidgets_python-0.2.3.dist-info → pgwidgets_python-0.3.1.dist-info}/METADATA +1 -1
- pgwidgets_python-0.3.1.dist-info/RECORD +21 -0
- pgwidgets_python-0.2.3.dist-info/RECORD +0 -20
- {pgwidgets_python-0.2.3.dist-info → pgwidgets_python-0.3.1.dist-info}/WHEEL +0 -0
- {pgwidgets_python-0.2.3.dist-info → pgwidgets_python-0.3.1.dist-info}/licenses/LICENSE.md +0 -0
- {pgwidgets_python-0.2.3.dist-info → pgwidgets_python-0.3.1.dist-info}/top_level.txt +0 -0
pgwidgets/method_types.py
CHANGED
|
@@ -82,6 +82,17 @@ ACTION_METHODS = {
|
|
|
82
82
|
"expand_all", "collapse_all", "expand_item", "collapse_item",
|
|
83
83
|
"sort_by_column", "set_optimal_column_widths",
|
|
84
84
|
"select_path", "select_paths", "select_all",
|
|
85
|
+
"select_cell", "select_cells", "clear_cell_selection",
|
|
86
|
+
# Per-cell / row / column / table colour overrides — the
|
|
87
|
+
# ``set_*`` naming would otherwise classify them as SETTERs,
|
|
88
|
+
# whose single-state-slot semantics can't represent the
|
|
89
|
+
# accumulated dict of overrides we actually keep. ACTION
|
|
90
|
+
# dispatch sends each call straight through to the JS side,
|
|
91
|
+
# which holds the canonical state in its own maps.
|
|
92
|
+
"set_cell_color", "set_row_color", "set_column_color",
|
|
93
|
+
"set_table_color",
|
|
94
|
+
"clear_cell_color", "clear_row_color", "clear_column_color",
|
|
95
|
+
"clear_all_colors",
|
|
85
96
|
# TextSource editing
|
|
86
97
|
"insert_text", "delete_range", "create_tag", "remove_tag_def",
|
|
87
98
|
"apply_tag", "remove_tag", "create_ref", "remove_ref",
|
|
@@ -338,6 +349,34 @@ def _menuaction_get_state(self):
|
|
|
338
349
|
"""Alias for MenuAction.get_checked — keeps a single state key."""
|
|
339
350
|
return self.get_checked()
|
|
340
351
|
|
|
352
|
+
# Above this size (bytes), set_binary_image switches from the single-
|
|
353
|
+
# frame _send_binary transport to the chunked _send_binary_chunked
|
|
354
|
+
# transport. Keeps small frames cheap and lets multi-megabyte frames
|
|
355
|
+
# yield to other WebSocket traffic between chunks.
|
|
356
|
+
_BINARY_CHUNK_THRESHOLD = 1 * 1024 * 1024 # 1 MiB
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _send_binary_auto(session, wid, method, args, data):
|
|
360
|
+
"""Pick single-frame or chunked transport based on payload size.
|
|
361
|
+
|
|
362
|
+
If ``data`` is a :class:`pgwidgets.Buffer`, its bytes ship via the
|
|
363
|
+
chunked transport (regardless of size) with ``shape`` and
|
|
364
|
+
``dtype`` attached to the announce so the JS receiver constructs
|
|
365
|
+
a typed array. Plain bytes-like input falls back to the size
|
|
366
|
+
heuristic (chunked above ~1 MiB, single-frame below).
|
|
367
|
+
"""
|
|
368
|
+
from pgwidgets.buffer import Buffer # local import to avoid cycle
|
|
369
|
+
if isinstance(data, Buffer):
|
|
370
|
+
session._send_binary_chunked(
|
|
371
|
+
wid, method, args, data.data,
|
|
372
|
+
shape=data.shape, dtype=data.dtype)
|
|
373
|
+
return
|
|
374
|
+
if len(data) > _BINARY_CHUNK_THRESHOLD:
|
|
375
|
+
session._send_binary_chunked(wid, method, args, data)
|
|
376
|
+
else:
|
|
377
|
+
session._send_binary(wid, method, args, data)
|
|
378
|
+
|
|
379
|
+
|
|
341
380
|
def _image_set_binary_image(self, data, format="jpeg"):
|
|
342
381
|
"""Set the image from raw bytes via a WebSocket binary frame.
|
|
343
382
|
|
|
@@ -358,6 +397,10 @@ def _image_set_binary_image(self, data, format="jpeg"):
|
|
|
358
397
|
that reconstruction after a browser reconnect re-sends the most
|
|
359
398
|
recently set image. Earlier set_image (URL-based) state is
|
|
360
399
|
cleared since the two methods are mutually exclusive.
|
|
400
|
+
|
|
401
|
+
Large payloads (above ~1 MiB) automatically use the chunked
|
|
402
|
+
binary transport so the WebSocket can interleave other messages
|
|
403
|
+
while the image streams.
|
|
361
404
|
"""
|
|
362
405
|
if not isinstance(data, (bytes, bytearray, memoryview)):
|
|
363
406
|
raise TypeError(
|
|
@@ -365,8 +408,8 @@ def _image_set_binary_image(self, data, format="jpeg"):
|
|
|
365
408
|
data = bytes(data)
|
|
366
409
|
self._state.pop("image", None)
|
|
367
410
|
self._state["binary_image"] = (format, data)
|
|
368
|
-
self._session.
|
|
369
|
-
|
|
411
|
+
_send_binary_auto(self._session, self._wid,
|
|
412
|
+
"set_binary_image", [format], data)
|
|
370
413
|
|
|
371
414
|
# State keys whose value is a (format, bytes) tuple that must be
|
|
372
415
|
# replayed via _send_binary instead of _call during reconstruction.
|
pgwidgets/sync/application.py
CHANGED
|
@@ -33,7 +33,7 @@ from pgwidgets.method_types import (
|
|
|
33
33
|
STATE_SYNC_CALLBACKS, STATE_SYNC_REQUIRES_OPTION,
|
|
34
34
|
WIDGET_CALLBACK_SYNC, POST_CHILDREN_STATE_KEYS, ITEM_LIST_CONFIG,
|
|
35
35
|
CHILD_CLOSE_CALLBACKS, REPLAY_METHODS, TREE_VIEW_WIDGETS,
|
|
36
|
-
BINARY_STATE_KEYS,
|
|
36
|
+
BINARY_STATE_KEYS, _send_binary_auto,
|
|
37
37
|
)
|
|
38
38
|
|
|
39
39
|
_CONCURRENCY_MODES = ("serialized", "per_session", "concurrent")
|
|
@@ -130,6 +130,17 @@ def _run_queue_loop(cb_queue, stop_event, logger=None):
|
|
|
130
130
|
result_slot['event'].set()
|
|
131
131
|
|
|
132
132
|
|
|
133
|
+
# MIME types for the font formats register_font accepts. Used to
|
|
134
|
+
# set the ``Content-Type`` header when the HTTP server delivers a
|
|
135
|
+
# registered font to the browser.
|
|
136
|
+
_FONT_MIME = {
|
|
137
|
+
".ttf": "font/ttf",
|
|
138
|
+
".otf": "font/otf",
|
|
139
|
+
".woff": "font/woff",
|
|
140
|
+
".woff2": "font/woff2",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
133
144
|
class Session:
|
|
134
145
|
"""
|
|
135
146
|
A session that owns a widget tree and its associated state.
|
|
@@ -169,6 +180,11 @@ class Session:
|
|
|
169
180
|
|
|
170
181
|
self._widget_classes = app._widget_classes
|
|
171
182
|
self._transfers = {} # transfer_id -> transfer state dict
|
|
183
|
+
# FIFO of binary-chunk JSON headers (encoding="binary") still
|
|
184
|
+
# awaiting their paired raw binary frame. Each connection's
|
|
185
|
+
# send order on the JS side is single-threaded, so a simple
|
|
186
|
+
# FIFO suffices on the receive side too.
|
|
187
|
+
self._pending_binary_headers = []
|
|
172
188
|
self._callback_source_ws = None # ws that sent current callback
|
|
173
189
|
|
|
174
190
|
self._reconstructing = False # suppress callbacks during reconstruction
|
|
@@ -273,6 +289,20 @@ class Session:
|
|
|
273
289
|
# -- Message handling --
|
|
274
290
|
|
|
275
291
|
def _handle_message(self, data):
|
|
292
|
+
# Raw binary frames are chunk payloads — pair with the head of
|
|
293
|
+
# the pending-binary FIFO (a list of binary-chunk JSON headers
|
|
294
|
+
# whose encoding == "binary"). This is symmetric with the
|
|
295
|
+
# JS-side intake.
|
|
296
|
+
if isinstance(data, (bytes, bytearray, memoryview)):
|
|
297
|
+
queue = self._pending_binary_headers
|
|
298
|
+
if not queue:
|
|
299
|
+
self._logger.warning(
|
|
300
|
+
"Session %s: unexpected binary frame with no "
|
|
301
|
+
"queued header (ignored).", self.id)
|
|
302
|
+
return
|
|
303
|
+
header = queue.pop(0)
|
|
304
|
+
self._handle_binary_chunk(header, bytes(data))
|
|
305
|
+
return
|
|
276
306
|
msg = json.loads(data)
|
|
277
307
|
if isinstance(msg, list):
|
|
278
308
|
for m in msg:
|
|
@@ -293,8 +323,20 @@ class Session:
|
|
|
293
323
|
elif msg_type == "viewport":
|
|
294
324
|
self._screen_size = (msg.get("width", 0), msg.get("height", 0))
|
|
295
325
|
|
|
296
|
-
elif msg_type == "
|
|
297
|
-
|
|
326
|
+
elif msg_type == "binary-chunk":
|
|
327
|
+
# Either reserves the next binary frame (encoding="binary")
|
|
328
|
+
# or carries an inline base64 payload (encoding="base64").
|
|
329
|
+
encoding = msg.get("encoding", "binary")
|
|
330
|
+
if encoding == "binary":
|
|
331
|
+
self._pending_binary_headers.append(msg)
|
|
332
|
+
elif encoding == "base64":
|
|
333
|
+
import base64 as _b64
|
|
334
|
+
payload = _b64.b64decode(msg.get("data") or "")
|
|
335
|
+
self._handle_binary_chunk(msg, payload)
|
|
336
|
+
else:
|
|
337
|
+
self._logger.warning(
|
|
338
|
+
"Session %s: unknown binary-chunk encoding %r "
|
|
339
|
+
"(ignored).", self.id, encoding)
|
|
298
340
|
|
|
299
341
|
elif msg_type == "callback":
|
|
300
342
|
# If the payload has a transfer_id, stash the metadata —
|
|
@@ -321,25 +363,45 @@ class Session:
|
|
|
321
363
|
self._dispatch_callback(
|
|
322
364
|
msg["wid"], msg["action"], *msg.get("args", []))
|
|
323
365
|
|
|
324
|
-
def
|
|
325
|
-
"""
|
|
326
|
-
|
|
366
|
+
def _handle_binary_chunk(self, header, data):
|
|
367
|
+
"""Buffer one chunk of an in-flight transfer.
|
|
368
|
+
|
|
369
|
+
``header`` is the parsed binary-chunk JSON; ``data`` is the
|
|
370
|
+
chunk's raw bytes (either from the paired binary WebSocket
|
|
371
|
+
frame or decoded from an inline base64 ``data`` field).
|
|
372
|
+
|
|
373
|
+
For file-upload transfers (drag-drop, FileDialog), the header
|
|
374
|
+
also carries ``file_index`` / ``file_count`` so multiple files
|
|
375
|
+
can be reassembled in parallel. For other future server-bound
|
|
376
|
+
chunked transports, those fields can be omitted.
|
|
377
|
+
"""
|
|
378
|
+
tid = header["transfer_id"]
|
|
327
379
|
transfer = self._transfers.get(tid)
|
|
328
380
|
if transfer is None:
|
|
329
381
|
return
|
|
330
382
|
|
|
331
|
-
fi =
|
|
332
|
-
fc =
|
|
383
|
+
fi = header.get("file_index", 0)
|
|
384
|
+
fc = header.get("file_count", 1)
|
|
385
|
+
ci = header["chunk_index"]
|
|
386
|
+
nc = header["num_chunks"]
|
|
333
387
|
if fi not in transfer["file_data"]:
|
|
334
|
-
transfer["file_data"][fi] = []
|
|
335
|
-
transfer["num_chunks"][fi] =
|
|
336
|
-
transfer["file_data"][fi]
|
|
388
|
+
transfer["file_data"][fi] = [None] * nc
|
|
389
|
+
transfer["num_chunks"][fi] = nc
|
|
390
|
+
slot = transfer["file_data"][fi]
|
|
391
|
+
if 0 <= ci < len(slot):
|
|
392
|
+
slot[ci] = data
|
|
393
|
+
else:
|
|
394
|
+
self._logger.warning(
|
|
395
|
+
"Session %s: chunk_index %d out of range for "
|
|
396
|
+
"transfer_id %s (num_chunks=%d).",
|
|
397
|
+
self.id, ci, tid, nc)
|
|
398
|
+
return
|
|
337
399
|
|
|
338
400
|
# Check if all files have received all their chunks.
|
|
339
401
|
all_complete = (
|
|
340
402
|
len(transfer["num_chunks"]) == fc
|
|
341
403
|
and all(
|
|
342
|
-
|
|
404
|
+
None not in transfer["file_data"][i]
|
|
343
405
|
for i in range(fc)
|
|
344
406
|
)
|
|
345
407
|
)
|
|
@@ -351,16 +413,17 @@ class Session:
|
|
|
351
413
|
for i, fmeta in enumerate(files_meta):
|
|
352
414
|
fsize = fmeta.get("size", 0)
|
|
353
415
|
total_bytes += fsize
|
|
354
|
-
|
|
355
|
-
if
|
|
356
|
-
|
|
357
|
-
|
|
416
|
+
n = transfer["num_chunks"].get(i)
|
|
417
|
+
if n:
|
|
418
|
+
slots = transfer["file_data"].get(i, [])
|
|
419
|
+
received = sum(1 for s in slots if s is not None)
|
|
420
|
+
transferred_bytes += fsize * received // n
|
|
358
421
|
|
|
359
422
|
progress_info = {
|
|
360
423
|
"transfer_id": tid,
|
|
361
424
|
"file_index": fi,
|
|
362
|
-
"chunk_index":
|
|
363
|
-
"num_chunks":
|
|
425
|
+
"chunk_index": ci,
|
|
426
|
+
"num_chunks": nc,
|
|
364
427
|
"transferred_bytes": transferred_bytes,
|
|
365
428
|
"total_bytes": total_bytes,
|
|
366
429
|
"complete": all_complete,
|
|
@@ -376,8 +439,8 @@ class Session:
|
|
|
376
439
|
# Reassemble file data and fire the original callback.
|
|
377
440
|
payload = transfer["payload"]
|
|
378
441
|
for i, file_meta in enumerate(payload["files"]):
|
|
379
|
-
|
|
380
|
-
|
|
442
|
+
slots = transfer["file_data"].get(i, [])
|
|
443
|
+
file_meta["data"] = b"".join(slots)
|
|
381
444
|
del self._transfers[tid]
|
|
382
445
|
self._dispatch_callback(
|
|
383
446
|
transfer["wid"], action, payload)
|
|
@@ -718,6 +781,91 @@ class Session:
|
|
|
718
781
|
continue
|
|
719
782
|
fut.add_done_callback(_drain_send_exception)
|
|
720
783
|
|
|
784
|
+
def _send_binary_chunked(self, wid, method, args, data,
|
|
785
|
+
chunk_size=512 * 1024,
|
|
786
|
+
shape=None, dtype=None):
|
|
787
|
+
"""Fire-and-forget chunked binary call.
|
|
788
|
+
|
|
789
|
+
Splits ``data`` (bytes) into ``chunk_size`` chunks and sends
|
|
790
|
+
them as a ``binary-call-chunked`` announce followed by N
|
|
791
|
+
(``binary-chunk`` JSON + raw binary frame) pairs. The JS side
|
|
792
|
+
reassembles into a single ``ArrayBuffer`` and dispatches as
|
|
793
|
+
``widget[method](buffer, *args)``.
|
|
794
|
+
|
|
795
|
+
When ``shape`` and ``dtype`` are provided, they are attached to
|
|
796
|
+
the announce header and the JS receiver constructs a typed
|
|
797
|
+
array (``Uint8Array``, ``Float32Array``, …) instead of a raw
|
|
798
|
+
``ArrayBuffer`` before dispatch. This is what :class:`Buffer`
|
|
799
|
+
arguments end up doing.
|
|
800
|
+
|
|
801
|
+
All chunks for a single transfer ship atomically on each
|
|
802
|
+
connection (one coroutine per connection) so they can't
|
|
803
|
+
interleave with other binary sends from another thread.
|
|
804
|
+
"""
|
|
805
|
+
if not self._connections:
|
|
806
|
+
return
|
|
807
|
+
if not isinstance(data, (bytes, bytearray, memoryview)):
|
|
808
|
+
raise TypeError(
|
|
809
|
+
"_send_binary_chunked: data must be bytes-like, got "
|
|
810
|
+
+ type(data).__name__)
|
|
811
|
+
data = bytes(data)
|
|
812
|
+
n = len(data)
|
|
813
|
+
if chunk_size <= 0:
|
|
814
|
+
raise ValueError("chunk_size must be positive")
|
|
815
|
+
num_chunks = max(1, (n + chunk_size - 1) // chunk_size)
|
|
816
|
+
with self._lock:
|
|
817
|
+
msg_id = self._next_id
|
|
818
|
+
self._next_id += 1
|
|
819
|
+
transfer_id = self._next_id
|
|
820
|
+
self._next_id += 1
|
|
821
|
+
announce_obj = {
|
|
822
|
+
"type": "binary-call-chunked",
|
|
823
|
+
"id": msg_id,
|
|
824
|
+
"wid": wid,
|
|
825
|
+
"method": method,
|
|
826
|
+
"args": list(args),
|
|
827
|
+
"transfer_id": transfer_id,
|
|
828
|
+
"num_chunks": num_chunks,
|
|
829
|
+
}
|
|
830
|
+
if shape is not None:
|
|
831
|
+
announce_obj["shape"] = list(shape)
|
|
832
|
+
if dtype is not None:
|
|
833
|
+
announce_obj["dtype"] = dtype
|
|
834
|
+
announce = json.dumps(announce_obj, cls=JsonEncoder)
|
|
835
|
+
# Pre-build chunk headers + slices so JSON encoding cost is
|
|
836
|
+
# paid once even if multiple browsers are connected.
|
|
837
|
+
pairs = []
|
|
838
|
+
for ci in range(num_chunks):
|
|
839
|
+
start = ci * chunk_size
|
|
840
|
+
end = min(start + chunk_size, n)
|
|
841
|
+
header = json.dumps({
|
|
842
|
+
"type": "binary-chunk",
|
|
843
|
+
"transfer_id": transfer_id,
|
|
844
|
+
"chunk_index": ci,
|
|
845
|
+
"num_chunks": num_chunks,
|
|
846
|
+
"encoding": "binary",
|
|
847
|
+
}, cls=JsonEncoder)
|
|
848
|
+
pairs.append((header, data[start:end]))
|
|
849
|
+
|
|
850
|
+
async def _send_all(ws):
|
|
851
|
+
await ws.send(announce)
|
|
852
|
+
for header, payload in pairs:
|
|
853
|
+
await ws.send(header)
|
|
854
|
+
await ws.send(payload)
|
|
855
|
+
|
|
856
|
+
for ws in self._connections:
|
|
857
|
+
coro = _send_all(ws)
|
|
858
|
+
try:
|
|
859
|
+
fut = asyncio.run_coroutine_threadsafe(coro,
|
|
860
|
+
self._app._loop)
|
|
861
|
+
except RuntimeError as e:
|
|
862
|
+
self._logger.warning(
|
|
863
|
+
"Session %s: loop refused chunked-binary coroutine: %r",
|
|
864
|
+
self.id, e)
|
|
865
|
+
coro.close()
|
|
866
|
+
continue
|
|
867
|
+
fut.add_done_callback(_drain_send_exception)
|
|
868
|
+
|
|
721
869
|
def _listen(self, wid, action, handler):
|
|
722
870
|
"""Register a callback listener.
|
|
723
871
|
|
|
@@ -1219,12 +1367,14 @@ class Session:
|
|
|
1219
1367
|
continue
|
|
1220
1368
|
|
|
1221
1369
|
# Binary-payload state (e.g. set_binary_image) replays via
|
|
1222
|
-
# _send_binary so the bytes go in
|
|
1223
|
-
# as base64 in JSON.
|
|
1370
|
+
# _send_binary / _send_binary_chunked so the bytes go in
|
|
1371
|
+
# raw frame(s), not embedded as base64 in JSON. Large
|
|
1372
|
+
# payloads switch to chunked transport automatically.
|
|
1224
1373
|
if key in BINARY_STATE_KEYS:
|
|
1225
1374
|
method_name = BINARY_STATE_KEYS[key]
|
|
1226
1375
|
fmt, data = value
|
|
1227
|
-
self
|
|
1376
|
+
_send_binary_auto(self, widget._wid, method_name,
|
|
1377
|
+
[fmt], data)
|
|
1228
1378
|
continue
|
|
1229
1379
|
|
|
1230
1380
|
if key in self._STATE_KEY_TO_SETTER:
|
|
@@ -1466,13 +1616,24 @@ class Application:
|
|
|
1466
1616
|
|
|
1467
1617
|
def __init__(self, ws_port=9500, http_port=9501, host="127.0.0.1",
|
|
1468
1618
|
http_server=True, concurrency_handling="per_session",
|
|
1469
|
-
max_sessions=1, logger=None):
|
|
1619
|
+
max_sessions=1, logger=None, ws_sock=None):
|
|
1470
1620
|
if concurrency_handling not in _CONCURRENCY_MODES:
|
|
1471
1621
|
raise ValueError(
|
|
1472
1622
|
f"concurrency_handling must be one of "
|
|
1473
1623
|
f"{_CONCURRENCY_MODES!r}, got {concurrency_handling!r}")
|
|
1474
1624
|
self._host = host
|
|
1475
|
-
|
|
1625
|
+
# ws_sock, if provided, is a bound TCP socket the WebSocket
|
|
1626
|
+
# server should adopt directly. This removes the TOCTOU race
|
|
1627
|
+
# that would otherwise exist between "find a free port" and
|
|
1628
|
+
# "bind that port" — the caller binds, hands the socket in,
|
|
1629
|
+
# and we never release the port between the two steps.
|
|
1630
|
+
# ``ws_port`` is read back from the socket so logging /
|
|
1631
|
+
# introspection still report a useful value.
|
|
1632
|
+
self._ws_sock = ws_sock
|
|
1633
|
+
if ws_sock is not None:
|
|
1634
|
+
self._ws_port = ws_sock.getsockname()[1]
|
|
1635
|
+
else:
|
|
1636
|
+
self._ws_port = ws_port
|
|
1476
1637
|
self._http_port = http_port
|
|
1477
1638
|
self._use_http_server = http_server
|
|
1478
1639
|
self._concurrency = concurrency_handling
|
|
@@ -1492,6 +1653,17 @@ class Application:
|
|
|
1492
1653
|
self._on_disconnect = None # user callback: fn(session)
|
|
1493
1654
|
self._cb_queue = queue.Queue() # for "serialized" mode
|
|
1494
1655
|
|
|
1656
|
+
# Custom-font registry. Each entry is
|
|
1657
|
+
# ``{id, family, weight, style, bytes, mime}``; ``id`` is a
|
|
1658
|
+
# monotonic int used as the path component of the HTTP URL
|
|
1659
|
+
# the browser fetches the font from. ``_default_font`` is
|
|
1660
|
+
# ``{family, size, weight, style}`` or ``None``.
|
|
1661
|
+
self._fonts = []
|
|
1662
|
+
self._fonts_by_id = {}
|
|
1663
|
+
self._next_font_id = 1
|
|
1664
|
+
self._default_font = None
|
|
1665
|
+
self._font_lock = threading.Lock()
|
|
1666
|
+
|
|
1495
1667
|
self._loop = None
|
|
1496
1668
|
self._shutdown = threading.Event()
|
|
1497
1669
|
self._thread = None
|
|
@@ -1564,6 +1736,141 @@ class Application:
|
|
|
1564
1736
|
self._widget_classes[name] = cls
|
|
1565
1737
|
return cls
|
|
1566
1738
|
|
|
1739
|
+
# ----- Custom font registration ---------------------------
|
|
1740
|
+
|
|
1741
|
+
def register_font(self, family, source, *,
|
|
1742
|
+
weight="normal", style="normal"):
|
|
1743
|
+
"""Register a custom font with the application.
|
|
1744
|
+
|
|
1745
|
+
Browsers receive a ``@font-face``-equivalent declaration
|
|
1746
|
+
through the JS ``FontFace`` API, so any widget that does
|
|
1747
|
+
``set_font(family, ...)`` thereafter renders with this
|
|
1748
|
+
face. Multiple registrations for the same ``family`` but
|
|
1749
|
+
different ``weight`` / ``style`` combine into a single
|
|
1750
|
+
font family with multiple faces.
|
|
1751
|
+
|
|
1752
|
+
Parameters
|
|
1753
|
+
----------
|
|
1754
|
+
family : str
|
|
1755
|
+
CSS font-family name to expose to widgets.
|
|
1756
|
+
source : str | os.PathLike | bytes | bytearray | memoryview
|
|
1757
|
+
Path to a font file (``.ttf`` / ``.otf`` / ``.woff`` /
|
|
1758
|
+
``.woff2``) or raw font bytes.
|
|
1759
|
+
weight : str
|
|
1760
|
+
CSS ``font-weight`` -- ``'normal'``, ``'bold'``, or a
|
|
1761
|
+
numeric string (``'100'`` ... ``'900'``).
|
|
1762
|
+
style : str
|
|
1763
|
+
CSS ``font-style`` -- ``'normal'``, ``'italic'``, or
|
|
1764
|
+
``'oblique'``.
|
|
1765
|
+
|
|
1766
|
+
Returns
|
|
1767
|
+
-------
|
|
1768
|
+
int
|
|
1769
|
+
The registration id (rarely needed; callers usually
|
|
1770
|
+
just refer to the font by ``family``).
|
|
1771
|
+
"""
|
|
1772
|
+
if isinstance(source, (bytes, bytearray, memoryview)):
|
|
1773
|
+
data = bytes(source)
|
|
1774
|
+
mime = "font/ttf"
|
|
1775
|
+
else:
|
|
1776
|
+
p = Path(source)
|
|
1777
|
+
data = p.read_bytes()
|
|
1778
|
+
ext = p.suffix.lower()
|
|
1779
|
+
mime = _FONT_MIME.get(ext, "font/ttf")
|
|
1780
|
+
with self._font_lock:
|
|
1781
|
+
font_id = self._next_font_id
|
|
1782
|
+
self._next_font_id += 1
|
|
1783
|
+
entry = {
|
|
1784
|
+
"id": font_id,
|
|
1785
|
+
"family": str(family),
|
|
1786
|
+
"weight": str(weight),
|
|
1787
|
+
"style": str(style),
|
|
1788
|
+
"bytes": data,
|
|
1789
|
+
"mime": mime,
|
|
1790
|
+
}
|
|
1791
|
+
self._fonts.append(entry)
|
|
1792
|
+
self._fonts_by_id[font_id] = entry
|
|
1793
|
+
# Push to any already-connected sessions so live UIs pick
|
|
1794
|
+
# the font up without a reconnect.
|
|
1795
|
+
msg = self._font_register_msg(entry)
|
|
1796
|
+
for session in list(self._sessions.values()):
|
|
1797
|
+
try:
|
|
1798
|
+
session._send(msg)
|
|
1799
|
+
except Exception:
|
|
1800
|
+
pass
|
|
1801
|
+
return font_id
|
|
1802
|
+
|
|
1803
|
+
def set_default_font(self, family, *, size=None,
|
|
1804
|
+
weight=None, style=None):
|
|
1805
|
+
"""Set the document-level default font.
|
|
1806
|
+
|
|
1807
|
+
Writes ``--pg-default-font-family`` / ``--pg-default-font-size``
|
|
1808
|
+
/ ``--pg-default-font-weight`` / ``--pg-default-font-style``
|
|
1809
|
+
CSS variables on ``:root``; the base pgwidgets stylesheet
|
|
1810
|
+
consumes these so any widget that hasn't been given an
|
|
1811
|
+
explicit ``set_font(...)`` follows the default.
|
|
1812
|
+
|
|
1813
|
+
Pass ``family=None`` to clear the default and fall back to
|
|
1814
|
+
the built-in stylesheet."""
|
|
1815
|
+
if family is None:
|
|
1816
|
+
self._default_font = None
|
|
1817
|
+
else:
|
|
1818
|
+
self._default_font = {
|
|
1819
|
+
"family": str(family),
|
|
1820
|
+
"size": None if size is None else float(size),
|
|
1821
|
+
"weight": None if weight is None else str(weight),
|
|
1822
|
+
"style": None if style is None else str(style),
|
|
1823
|
+
}
|
|
1824
|
+
msg = self._font_default_msg()
|
|
1825
|
+
for session in list(self._sessions.values()):
|
|
1826
|
+
try:
|
|
1827
|
+
session._send(msg)
|
|
1828
|
+
except Exception:
|
|
1829
|
+
pass
|
|
1830
|
+
|
|
1831
|
+
def _font_register_msg(self, entry):
|
|
1832
|
+
return {
|
|
1833
|
+
"type": "register-font",
|
|
1834
|
+
"id": entry["id"],
|
|
1835
|
+
"family": entry["family"],
|
|
1836
|
+
"weight": entry["weight"],
|
|
1837
|
+
"style": entry["style"],
|
|
1838
|
+
"url": f"/_pgwidgets/font/{entry['id']}",
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
def _font_default_msg(self):
|
|
1842
|
+
return {
|
|
1843
|
+
"type": "set-default-font",
|
|
1844
|
+
"font": self._default_font,
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
def _replay_fonts_to_session(self, session):
|
|
1848
|
+
"""Push the full font registry + default font to a freshly
|
|
1849
|
+
connected (or reconnecting) session before any user code
|
|
1850
|
+
creates widgets. Called from ``_on_session_open``."""
|
|
1851
|
+
with self._font_lock:
|
|
1852
|
+
fonts = list(self._fonts)
|
|
1853
|
+
default = self._default_font
|
|
1854
|
+
for entry in fonts:
|
|
1855
|
+
try:
|
|
1856
|
+
session._send(self._font_register_msg(entry))
|
|
1857
|
+
except Exception:
|
|
1858
|
+
pass
|
|
1859
|
+
if default is not None:
|
|
1860
|
+
try:
|
|
1861
|
+
session._send(self._font_default_msg())
|
|
1862
|
+
except Exception:
|
|
1863
|
+
pass
|
|
1864
|
+
|
|
1865
|
+
def _get_font_bytes(self, font_id):
|
|
1866
|
+
"""Return ``(bytes, mime)`` for a registered font, or
|
|
1867
|
+
``(None, None)`` if unknown. Called by the HTTP handler."""
|
|
1868
|
+
with self._font_lock:
|
|
1869
|
+
entry = self._fonts_by_id.get(font_id)
|
|
1870
|
+
if entry is None:
|
|
1871
|
+
return None, None
|
|
1872
|
+
return entry["bytes"], entry["mime"]
|
|
1873
|
+
|
|
1567
1874
|
def start(self):
|
|
1568
1875
|
"""Start the WebSocket server (and HTTP server if enabled).
|
|
1569
1876
|
|
|
@@ -1595,9 +1902,14 @@ class Application:
|
|
|
1595
1902
|
self._loop.run_until_complete(self._serve_ws())
|
|
1596
1903
|
|
|
1597
1904
|
async def _serve_ws(self):
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1905
|
+
if self._ws_sock is not None:
|
|
1906
|
+
async with websockets.serve(self._ws_handler,
|
|
1907
|
+
sock=self._ws_sock):
|
|
1908
|
+
await asyncio.Future()
|
|
1909
|
+
else:
|
|
1910
|
+
async with websockets.serve(self._ws_handler, self._host,
|
|
1911
|
+
self._ws_port):
|
|
1912
|
+
await asyncio.Future()
|
|
1601
1913
|
|
|
1602
1914
|
async def _ws_handler(self, ws):
|
|
1603
1915
|
# Init handshake: send init, receive ack which may contain
|
|
@@ -1681,6 +1993,10 @@ class Application:
|
|
|
1681
1993
|
# because _send() blocks with event.wait() while the event
|
|
1682
1994
|
# loop that must process ws.send() is also blocked.
|
|
1683
1995
|
def do_reconstruct():
|
|
1996
|
+
# Replay the font registry before reconstruct() so any
|
|
1997
|
+
# widget reconstructed with ``set_font(...)`` finds the
|
|
1998
|
+
# face already declared.
|
|
1999
|
+
self._replay_fonts_to_session(session)
|
|
1684
2000
|
self._logger.info(
|
|
1685
2001
|
f"Session {session.id}: reconstructing UI.")
|
|
1686
2002
|
session._reconstructing = True
|
|
@@ -1693,8 +2009,14 @@ class Application:
|
|
|
1693
2009
|
self._dispatch(session, do_reconstruct, ())
|
|
1694
2010
|
else:
|
|
1695
2011
|
self._logger.info(f"Session {session.id} connected.")
|
|
1696
|
-
|
|
1697
|
-
|
|
2012
|
+
def do_connect():
|
|
2013
|
+
# Replay fonts before the user callback so any widget
|
|
2014
|
+
# the user builds with ``set_font(family, ...)`` sees
|
|
2015
|
+
# the face already declared.
|
|
2016
|
+
self._replay_fonts_to_session(session)
|
|
2017
|
+
if self._on_connect:
|
|
2018
|
+
self._on_connect(session)
|
|
2019
|
+
self._dispatch(session, do_connect, ())
|
|
1698
2020
|
|
|
1699
2021
|
try:
|
|
1700
2022
|
async for message in ws:
|
|
@@ -1787,6 +2109,7 @@ class Application:
|
|
|
1787
2109
|
favicon_path = self._favicon_path
|
|
1788
2110
|
ws_host = self._host
|
|
1789
2111
|
ws_port = self._ws_port
|
|
2112
|
+
app = self
|
|
1790
2113
|
|
|
1791
2114
|
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
1792
2115
|
def __init__(self, *args, **kwargs):
|
|
@@ -1796,6 +2119,31 @@ class Application:
|
|
|
1796
2119
|
# serve remote.html at the root, with WS URL injected
|
|
1797
2120
|
# Strip query string for path matching (e.g. /?session=1)
|
|
1798
2121
|
path = self.path.split("?")[0]
|
|
2122
|
+
# Custom-font registry: /_pgwidgets/font/<id> serves
|
|
2123
|
+
# the bytes the app registered via ``register_font``.
|
|
2124
|
+
# ``Cache-Control: immutable`` is safe because the
|
|
2125
|
+
# registry assigns a fresh id on every call -- the
|
|
2126
|
+
# URL is content-stable for the lifetime of the
|
|
2127
|
+
# registration.
|
|
2128
|
+
if path.startswith("/_pgwidgets/font/"):
|
|
2129
|
+
try:
|
|
2130
|
+
font_id = int(path.rsplit("/", 1)[-1])
|
|
2131
|
+
except ValueError:
|
|
2132
|
+
self.send_error(404)
|
|
2133
|
+
return
|
|
2134
|
+
data, mime = app._get_font_bytes(font_id)
|
|
2135
|
+
if data is None:
|
|
2136
|
+
self.send_error(404)
|
|
2137
|
+
return
|
|
2138
|
+
self.send_response(200)
|
|
2139
|
+
self.send_header("Content-Type", mime)
|
|
2140
|
+
self.send_header("Content-Length", str(len(data)))
|
|
2141
|
+
self.send_header(
|
|
2142
|
+
"Cache-Control", "public, max-age=31536000, immutable")
|
|
2143
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
2144
|
+
self.end_headers()
|
|
2145
|
+
self.wfile.write(data)
|
|
2146
|
+
return
|
|
1799
2147
|
if path == "/" or path == "/index.html":
|
|
1800
2148
|
html = remote_html.read_text(encoding="utf-8")
|
|
1801
2149
|
inject = (
|
pgwidgets/sync/widget.py
CHANGED
|
@@ -420,6 +420,11 @@ def _resolve_kwargs(method_name, param_names, args, kwargs):
|
|
|
420
420
|
are bundled into a dict for that parameter (e.g.
|
|
421
421
|
``add_widget(child, title="Tab 1")`` becomes
|
|
422
422
|
``add_widget(child, {"title": "Tab 1"})``).
|
|
423
|
+
|
|
424
|
+
Skipped-positional kwargs are supported: a call like
|
|
425
|
+
``set_color(fg='red')`` against ``param_names = ['bg', 'fg']``
|
|
426
|
+
fills the omitted ``bg`` slot with ``None`` (the JS-side
|
|
427
|
+
default) instead of erroring out.
|
|
423
428
|
"""
|
|
424
429
|
if not kwargs:
|
|
425
430
|
return args
|
|
@@ -430,7 +435,10 @@ def _resolve_kwargs(method_name, param_names, args, kwargs):
|
|
|
430
435
|
if name in kwargs:
|
|
431
436
|
merged.append(kwargs.pop(name))
|
|
432
437
|
else:
|
|
433
|
-
|
|
438
|
+
# Leave a placeholder so subsequent kwargs can land in
|
|
439
|
+
# later positions. The JS side reads omitted args as
|
|
440
|
+
# null / default, which matches ``None`` here.
|
|
441
|
+
merged.append(None)
|
|
434
442
|
if kwargs and param_names and param_names[-1] == "options":
|
|
435
443
|
# Bundle remaining kwargs into the options dict
|
|
436
444
|
opts_idx = len(param_names) - 1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
pgwidgets/__init__.py,sha256=z6QFxTtjwnduH-s0zT8u_GJb0rT7OZsgPawMIlIzfQE,919
|
|
2
|
+
pgwidgets/_json.py,sha256=o21qywJ6yAldbqxTq3nLwgK9O67r3a8JxhvI_WAJnMY,2184
|
|
3
|
+
pgwidgets/buffer.py,sha256=BYj_nb6fuyGsBqUp3_Z1YrzUm4h5VmoWPGKvpTjWrYk,4048
|
|
4
|
+
pgwidgets/callbacks.py,sha256=gA2FnX0N5BmbmOMyEV35yHySgAfVjfAZcZhZbo7j4-c,3314
|
|
5
|
+
pgwidgets/defs.py,sha256=Q8qhvTeansFU1Av6j5ncdwF2xSNHrpXuKErdHWQ3HiA,436
|
|
6
|
+
pgwidgets/method_types.py,sha256=1gOTR8pdX_t7E_UEyUy4rnWiFoh10tJSUAINA1mE-SU,18298
|
|
7
|
+
pgwidgets/async_/Widgets.py,sha256=vld2xkvusBBEVbhbezaJsjBRRj3L7c17Up0pOVs8PGo,723
|
|
8
|
+
pgwidgets/async_/__init__.py,sha256=rXB-v9XRrYt1imYuYikhkzIyiRqaYhlTpQei2LHaQ18,564
|
|
9
|
+
pgwidgets/async_/application.py,sha256=5tUlfyE9ie0aFPh2WAs2VRvgyuqQmdaGTX0d1IkSB7o,80077
|
|
10
|
+
pgwidgets/async_/widget.py,sha256=gO7eJh9LV3UY7yRLdeml_0gqmHnFB5VU3NWlDURLtfc,38042
|
|
11
|
+
pgwidgets/extras/__init__.py,sha256=AXUmFtnn4RSlp6pJX6z55j-m_29VmfzpYIjQvAy7pO0,325
|
|
12
|
+
pgwidgets/extras/file_browser.py,sha256=CXQSfJx1VQ-fJfhKNrVyqrkKTJEQngmiP954y70Fjo0,17229
|
|
13
|
+
pgwidgets/sync/Widgets.py,sha256=7SaocMVMzFHhi51pjuEAr47Wez90b_a61kDbiv0jOfM,706
|
|
14
|
+
pgwidgets/sync/__init__.py,sha256=SF5RTAvtu8BbYBWzpiCPipy6DJzNXf6nqk9xdvwxUhQ,542
|
|
15
|
+
pgwidgets/sync/application.py,sha256=Gfft77F66PfRnr_8_FBqU0v_gWe4YC9M-q5rww1N-2U,94741
|
|
16
|
+
pgwidgets/sync/widget.py,sha256=yrScNkZB34u0JXn3-kCB-1mPLxLysVbuaPsCjC8kq0E,38343
|
|
17
|
+
pgwidgets_python-0.3.1.dist-info/licenses/LICENSE.md,sha256=LoM3fMTiMnQuHRCJghdjOtjnCrL8soBpu2PFk24Xvyg,1528
|
|
18
|
+
pgwidgets_python-0.3.1.dist-info/METADATA,sha256=QKWuFclVrksOLKxHPqSM-6CoNVskbt_bw_rqaUa3WpI,4568
|
|
19
|
+
pgwidgets_python-0.3.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
20
|
+
pgwidgets_python-0.3.1.dist-info/top_level.txt,sha256=wwL6fBq0gU-JwzlM6TdduY1qYpu39ysqnnbQT-1bqAs,10
|
|
21
|
+
pgwidgets_python-0.3.1.dist-info/RECORD,,
|