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/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._send_binary(
369
- self._wid, "set_binary_image", [format], data)
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.
@@ -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 == "file-chunk":
297
- self._handle_file_chunk(msg)
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 _handle_file_chunk(self, msg):
325
- """Handle a file-chunk message: buffer data and fire callbacks."""
326
- tid = msg["transfer_id"]
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 = msg["file_index"]
332
- fc = msg["file_count"]
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] = msg["num_chunks"]
336
- transfer["file_data"][fi].append(msg["data"])
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
- len(transfer["file_data"][i]) >= transfer["num_chunks"][i]
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
- nc = transfer["num_chunks"].get(i)
355
- if nc:
356
- received = len(transfer["file_data"].get(i, []))
357
- transferred_bytes += fsize * received // nc
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": msg["chunk_index"],
363
- "num_chunks": msg["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
- file_meta["data"] = "".join(
380
- transfer["file_data"].get(i, []))
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 a raw frame, not embedded
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._send_binary(widget._wid, method_name, [fmt], data)
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
- self._ws_port = ws_port
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
- async with websockets.serve(self._ws_handler, self._host,
1599
- self._ws_port):
1600
- await asyncio.Future()
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
- if self._on_connect:
1697
- self._dispatch(session, self._on_connect, (session,))
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
- break
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgwidgets-python
3
- Version: 0.2.3
3
+ Version: 0.3.1
4
4
  Summary: Python bindings for the pgwidgets JavaScript widget library
5
5
  Author: PGWidgets Developers
6
6
  License: BSD-3-Clause
@@ -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,,