pywebview-htmx 0.4.1__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/PKG-INFO +111 -7
  2. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/README.md +110 -6
  3. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/pyproject.toml +13 -3
  4. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/src/pywebview_htmx/runtime.py +74 -30
  5. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/runtime.js +195 -32
  6. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/themes/aurora.css +6 -1
  7. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/themes/base.css +48 -6
  8. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/themes/cybermind.css +9 -4
  9. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/themes/paper.css +6 -1
  10. pywebview_htmx-0.5.0/tests/README.md +8 -0
  11. pywebview_htmx-0.5.0/tests/native_smoke.py +302 -0
  12. pywebview_htmx-0.5.0/tests/test_demo.py +169 -0
  13. pywebview_htmx-0.5.0/tests/test_html_assets_browser.py +42 -0
  14. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/tests/test_js_runtime.py +639 -3
  15. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/tests/test_python_injection.py +42 -0
  16. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/tests/test_python_params_attr.py +8 -0
  17. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/tests/test_python_themes.py +41 -0
  18. pywebview_htmx-0.5.0/tests/test_theme_accessibility.py +200 -0
  19. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/LICENSE +0 -0
  20. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/src/pywebview_htmx/__init__.py +0 -0
  21. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/src/pywebview_htmx/py.typed +0 -0
  22. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/tests/conftest.py +0 -0
  23. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/tests/test_python_create_window.py +0 -0
  24. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/tests/test_python_script_loading.py +0 -0
  25. {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/tests/test_repo_legibility.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pywebview-htmx
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: HTMX-style declarative UI bindings for PyWebview apps
5
5
  Author-Email: "B.T. Franklin" <brandon.franklin@gmail.com>
6
6
  License: MIT License
@@ -87,7 +87,9 @@ pdm add pywebview-htmx
87
87
  ## Maintainer Docs
88
88
 
89
89
  For maintainer and agent-facing repository guidance, see
90
- [`docs/index.md`](docs/index.md).
90
+ [`docs/index.md`](docs/index.md). Run `pdm run check` for lint, tests, and
91
+ offline documentation checks. The documentation check requires the native
92
+ `perfect-doc` executable and can run alone with `pdm run check-docs`.
91
93
 
92
94
  ## Agent Skill
93
95
 
@@ -159,7 +161,7 @@ it is injected automatically.
159
161
  - Python handlers should return an HTML string fragment. Returning objects or
160
162
  other data types is treated as an error.
161
163
  - For ordinary buttons/links, params still come from `data-py-params`.
162
- - For `<form py-trigger="submit">`, named fields are serialized automatically
164
+ - For forms with `submit`, `input`, or `change` triggers, named fields are serialized automatically
163
165
  and merged over `data-py-params`.
164
166
  - Returned HTML is inserted directly into the DOM, so escape untrusted values
165
167
  before returning markup.
@@ -195,6 +197,9 @@ Injects the runtime script tag into an HTML string (idempotent).
195
197
  Returns HTML-escaped JSON that is safe to place in a `data-py-params`
196
198
  attribute.
197
199
 
200
+ Non-finite numbers (`NaN` and positive or negative infinity) raise `ValueError`.
201
+ JSON does not support these values.
202
+
198
203
  ```python
199
204
  from pywebview_htmx import encode_params_attr
200
205
 
@@ -237,6 +242,9 @@ form submission, link navigation, and submit/reset controls associated with a
237
242
  form. Ordinary buttons and the native state changes of checkboxes, radio
238
243
  buttons, and labels are preserved.
239
244
 
245
+ A label that contains its control calls Python once per activation. Clicking
246
+ the label text still changes the checkbox or radio state.
247
+
240
248
  ### `data-py-params` (optional)
241
249
 
242
250
  JSON payload passed to Python method.
@@ -247,8 +255,8 @@ JSON payload passed to Python method.
247
255
 
248
256
  Notes:
249
257
  - If missing, params default to `{}`.
250
- - Invalid JSON logs an error and falls back to `{}`.
251
- - For submit-triggered forms, named form fields are serialized automatically and
258
+ - Invalid JSON or a value that is not an object emits `py:error`. Python is not called.
259
+ - For forms with `submit`, `input`, or `change` triggers, named fields are serialized automatically and
252
260
  merged over this payload.
253
261
 
254
262
  ### `py-target` (optional)
@@ -288,6 +296,7 @@ Per-element concurrency override. If omitted, the global
288
296
  - `swapDelay` (ms, default: `0`)
289
297
  - `settleDelay` (ms, default: `20`)
290
298
  - `requestPolicy` (`"latest-wins"` default, or `"drop"`)
299
+ - `requestTimeout` (ms, default: `0`; `py-timeout` overrides it per element)
291
300
 
292
301
  Runtime defaults are read for each accepted request. An element's explicit
293
302
  `py-swap` continues to override `defaultSwapStyle`; otherwise, changing
@@ -346,6 +355,9 @@ The `py-wait` selector is resolved for each accepted request. Loading state is
346
355
  counted separately per resolved wait element, so a shared spinner stays active
347
356
  until every request using that particular element finishes.
348
357
 
358
+ The bundled loading styles show `.py-waiting` without blocking pointer input.
359
+ The request policy controls whether another trigger is accepted.
360
+
349
361
  ### `latest-wins` (default)
350
362
 
351
363
  Multiple rapid requests are allowed; stale responses in the same request state
@@ -356,10 +368,33 @@ scope are ignored. Only the latest request for that scope updates the DOM.
356
368
  If a request is already in flight for the same request state scope, new triggers
357
369
  are ignored and `py:ignored` is emitted.
358
370
 
371
+ ### Python Handler Concurrency
372
+
373
+ `latest-wins` does not cancel Python work. Each accepted call still runs, even
374
+ when its returned HTML is later ignored. PyWebview runs exposed handlers in
375
+ separate threads. Protect shared mutable data with a lock or a database
376
+ transaction when needed.
377
+
378
+ `drop` applies to the same request scope. It does not prevent calls from other
379
+ scopes from changing the same Python data.
380
+
381
+ ### Bridge Readiness
382
+
383
+ PyWebview creates the bridge after the document loads. Wait for the
384
+ `pywebviewready` event before starting programmatic calls:
385
+
386
+ ```js
387
+ window.addEventListener("pywebviewready", () => {
388
+ document.querySelector("#load-button").click();
389
+ });
390
+ ```
391
+
392
+ A call made before the bridge is available emits `py:error`. It is not queued.
393
+
359
394
  ## Dynamic Content Re-processing
360
395
 
361
- After a swap, `pywebview-htmx` automatically scans swapped content for new `py-call`
362
- elements and binds them. This enables chained interactions in returned fragments
396
+ After a swap, `pywebview-htmx` binds controls in the inserted content. Append
397
+ and outer replacement do not scan existing siblings again. This enables chained interactions in returned fragments
363
398
  without manual re-init code.
364
399
 
365
400
  ## Theme System
@@ -484,3 +519,72 @@ The demo includes:
484
519
  ## License
485
520
 
486
521
  MIT (see [LICENSE](LICENSE)).
522
+
523
+ ## Request deadlines
524
+
525
+ Set `py-timeout="2000"` for a two-second Python response deadline. The global
526
+ `requestTimeout` default is `0` (no deadline). Values are milliseconds from
527
+ 0 to 2147483647. A timeout emits `py:error` with `detail.phase="timeout"` and
528
+ `error.name="TimeoutError"`. The loading indicator stops, and a late response
529
+ cannot change the page. A late Python failure still emits `py:error` with
530
+ `detail.expired=true`.
531
+
532
+ A deadline does not cancel Python work or retry the call. Under `drop`, the
533
+ scope stays locked until Python finishes. Show the timeout message and let the
534
+ application check the operation's result before another attempt. If Python
535
+ never returns, the lock remains until the page is reloaded; reloading does not
536
+ prove that the operation was cancelled. Use `latest-wins` for independent read
537
+ operations that can safely run again.
538
+
539
+ ## Delay rapid input calls
540
+
541
+ Use `py-debounce="200"` on a read operation to wait until input stops for 200 ms.
542
+ Each event replaces the pending call for that control. Only the final event's
543
+ parameter snapshot is sent. Forms with `py-trigger="input"` or `change`
544
+ serialize their named fields, as submit forms do. Input during text composition
545
+ is not sent; the final input event supplies the completed text.
546
+
547
+ ```html
548
+ <form py-call="search" py-trigger="input" py-debounce="200" py-target="#results">
549
+ <label for="query">Search</label>
550
+ <input id="query" name="query" type="search">
551
+ </form>
552
+ <div id="results"></div>
553
+ ```
554
+
555
+ The delay defaults to zero. Clicks and submissions remain immediate unless a
556
+ control explicitly sets `py-debounce`. A removed control cannot send its pending
557
+ call while detached. Waiting state and request policy start when the call is
558
+ sent, not while its timer is pending. `drop` can still reject that call if
559
+ another operation is running; use `latest-wins` for live read results. The delay
560
+ does not cancel Python work that has already started.
561
+
562
+ ## Focus after replacement
563
+
564
+ Give controls stable, unique IDs. When a destructive swap removes the focused
565
+ control, the runtime focuses the inserted control with the same ID without
566
+ scrolling the page. Text inputs and textareas also retain their selection,
567
+ limited to the replacement value's length.
568
+
569
+ Focus is read immediately before the swap. Moving to a control outside the
570
+ updated region during a request is respected. A lifecycle listener that sets
571
+ focus after the swap also takes precedence. Append operations do not change
572
+ focus. If no matching control is inserted, the runtime does not guess a new
573
+ focus target.
574
+
575
+ ## Accessible loading and theme states
576
+
577
+ While a request is waiting, its wait element has `aria-busy="true"` and
578
+ `.py-waiting`. Shared wait elements remain busy until all visible waits finish
579
+ or time out. The runtime then restores the element's original `aria-busy`
580
+ value, or removes the attribute if it was absent. It does not add live regions
581
+ to application content. Use `role="status"` on a short result or status message
582
+ when an announcement is useful.
583
+
584
+ Bundled themes provide readable text for each button variant, visible keyboard
585
+ focus, and the matching light or dark color scheme for native controls.
586
+ Reduced-motion settings remove button movement and spinner animation while
587
+ keeping the busy marker visible. Custom theme authors should define
588
+ `--pyh-focus`, `--pyh-color-scheme`, and the `--pyh-btn-primary-text`,
589
+ `--pyh-btn-secondary-text`, and `--pyh-btn-danger-text` tokens along with the
590
+ existing colors.
@@ -47,7 +47,9 @@ pdm add pywebview-htmx
47
47
  ## Maintainer Docs
48
48
 
49
49
  For maintainer and agent-facing repository guidance, see
50
- [`docs/index.md`](docs/index.md).
50
+ [`docs/index.md`](docs/index.md). Run `pdm run check` for lint, tests, and
51
+ offline documentation checks. The documentation check requires the native
52
+ `perfect-doc` executable and can run alone with `pdm run check-docs`.
51
53
 
52
54
  ## Agent Skill
53
55
 
@@ -119,7 +121,7 @@ it is injected automatically.
119
121
  - Python handlers should return an HTML string fragment. Returning objects or
120
122
  other data types is treated as an error.
121
123
  - For ordinary buttons/links, params still come from `data-py-params`.
122
- - For `<form py-trigger="submit">`, named fields are serialized automatically
124
+ - For forms with `submit`, `input`, or `change` triggers, named fields are serialized automatically
123
125
  and merged over `data-py-params`.
124
126
  - Returned HTML is inserted directly into the DOM, so escape untrusted values
125
127
  before returning markup.
@@ -155,6 +157,9 @@ Injects the runtime script tag into an HTML string (idempotent).
155
157
  Returns HTML-escaped JSON that is safe to place in a `data-py-params`
156
158
  attribute.
157
159
 
160
+ Non-finite numbers (`NaN` and positive or negative infinity) raise `ValueError`.
161
+ JSON does not support these values.
162
+
158
163
  ```python
159
164
  from pywebview_htmx import encode_params_attr
160
165
 
@@ -197,6 +202,9 @@ form submission, link navigation, and submit/reset controls associated with a
197
202
  form. Ordinary buttons and the native state changes of checkboxes, radio
198
203
  buttons, and labels are preserved.
199
204
 
205
+ A label that contains its control calls Python once per activation. Clicking
206
+ the label text still changes the checkbox or radio state.
207
+
200
208
  ### `data-py-params` (optional)
201
209
 
202
210
  JSON payload passed to Python method.
@@ -207,8 +215,8 @@ JSON payload passed to Python method.
207
215
 
208
216
  Notes:
209
217
  - If missing, params default to `{}`.
210
- - Invalid JSON logs an error and falls back to `{}`.
211
- - For submit-triggered forms, named form fields are serialized automatically and
218
+ - Invalid JSON or a value that is not an object emits `py:error`. Python is not called.
219
+ - For forms with `submit`, `input`, or `change` triggers, named fields are serialized automatically and
212
220
  merged over this payload.
213
221
 
214
222
  ### `py-target` (optional)
@@ -248,6 +256,7 @@ Per-element concurrency override. If omitted, the global
248
256
  - `swapDelay` (ms, default: `0`)
249
257
  - `settleDelay` (ms, default: `20`)
250
258
  - `requestPolicy` (`"latest-wins"` default, or `"drop"`)
259
+ - `requestTimeout` (ms, default: `0`; `py-timeout` overrides it per element)
251
260
 
252
261
  Runtime defaults are read for each accepted request. An element's explicit
253
262
  `py-swap` continues to override `defaultSwapStyle`; otherwise, changing
@@ -306,6 +315,9 @@ The `py-wait` selector is resolved for each accepted request. Loading state is
306
315
  counted separately per resolved wait element, so a shared spinner stays active
307
316
  until every request using that particular element finishes.
308
317
 
318
+ The bundled loading styles show `.py-waiting` without blocking pointer input.
319
+ The request policy controls whether another trigger is accepted.
320
+
309
321
  ### `latest-wins` (default)
310
322
 
311
323
  Multiple rapid requests are allowed; stale responses in the same request state
@@ -316,10 +328,33 @@ scope are ignored. Only the latest request for that scope updates the DOM.
316
328
  If a request is already in flight for the same request state scope, new triggers
317
329
  are ignored and `py:ignored` is emitted.
318
330
 
331
+ ### Python Handler Concurrency
332
+
333
+ `latest-wins` does not cancel Python work. Each accepted call still runs, even
334
+ when its returned HTML is later ignored. PyWebview runs exposed handlers in
335
+ separate threads. Protect shared mutable data with a lock or a database
336
+ transaction when needed.
337
+
338
+ `drop` applies to the same request scope. It does not prevent calls from other
339
+ scopes from changing the same Python data.
340
+
341
+ ### Bridge Readiness
342
+
343
+ PyWebview creates the bridge after the document loads. Wait for the
344
+ `pywebviewready` event before starting programmatic calls:
345
+
346
+ ```js
347
+ window.addEventListener("pywebviewready", () => {
348
+ document.querySelector("#load-button").click();
349
+ });
350
+ ```
351
+
352
+ A call made before the bridge is available emits `py:error`. It is not queued.
353
+
319
354
  ## Dynamic Content Re-processing
320
355
 
321
- After a swap, `pywebview-htmx` automatically scans swapped content for new `py-call`
322
- elements and binds them. This enables chained interactions in returned fragments
356
+ After a swap, `pywebview-htmx` binds controls in the inserted content. Append
357
+ and outer replacement do not scan existing siblings again. This enables chained interactions in returned fragments
323
358
  without manual re-init code.
324
359
 
325
360
  ## Theme System
@@ -444,3 +479,72 @@ The demo includes:
444
479
  ## License
445
480
 
446
481
  MIT (see [LICENSE](LICENSE)).
482
+
483
+ ## Request deadlines
484
+
485
+ Set `py-timeout="2000"` for a two-second Python response deadline. The global
486
+ `requestTimeout` default is `0` (no deadline). Values are milliseconds from
487
+ 0 to 2147483647. A timeout emits `py:error` with `detail.phase="timeout"` and
488
+ `error.name="TimeoutError"`. The loading indicator stops, and a late response
489
+ cannot change the page. A late Python failure still emits `py:error` with
490
+ `detail.expired=true`.
491
+
492
+ A deadline does not cancel Python work or retry the call. Under `drop`, the
493
+ scope stays locked until Python finishes. Show the timeout message and let the
494
+ application check the operation's result before another attempt. If Python
495
+ never returns, the lock remains until the page is reloaded; reloading does not
496
+ prove that the operation was cancelled. Use `latest-wins` for independent read
497
+ operations that can safely run again.
498
+
499
+ ## Delay rapid input calls
500
+
501
+ Use `py-debounce="200"` on a read operation to wait until input stops for 200 ms.
502
+ Each event replaces the pending call for that control. Only the final event's
503
+ parameter snapshot is sent. Forms with `py-trigger="input"` or `change`
504
+ serialize their named fields, as submit forms do. Input during text composition
505
+ is not sent; the final input event supplies the completed text.
506
+
507
+ ```html
508
+ <form py-call="search" py-trigger="input" py-debounce="200" py-target="#results">
509
+ <label for="query">Search</label>
510
+ <input id="query" name="query" type="search">
511
+ </form>
512
+ <div id="results"></div>
513
+ ```
514
+
515
+ The delay defaults to zero. Clicks and submissions remain immediate unless a
516
+ control explicitly sets `py-debounce`. A removed control cannot send its pending
517
+ call while detached. Waiting state and request policy start when the call is
518
+ sent, not while its timer is pending. `drop` can still reject that call if
519
+ another operation is running; use `latest-wins` for live read results. The delay
520
+ does not cancel Python work that has already started.
521
+
522
+ ## Focus after replacement
523
+
524
+ Give controls stable, unique IDs. When a destructive swap removes the focused
525
+ control, the runtime focuses the inserted control with the same ID without
526
+ scrolling the page. Text inputs and textareas also retain their selection,
527
+ limited to the replacement value's length.
528
+
529
+ Focus is read immediately before the swap. Moving to a control outside the
530
+ updated region during a request is respected. A lifecycle listener that sets
531
+ focus after the swap also takes precedence. Append operations do not change
532
+ focus. If no matching control is inserted, the runtime does not guess a new
533
+ focus target.
534
+
535
+ ## Accessible loading and theme states
536
+
537
+ While a request is waiting, its wait element has `aria-busy="true"` and
538
+ `.py-waiting`. Shared wait elements remain busy until all visible waits finish
539
+ or time out. The runtime then restores the element's original `aria-busy`
540
+ value, or removes the attribute if it was absent. It does not add live regions
541
+ to application content. Use `role="status"` on a short result or status message
542
+ when an announcement is useful.
543
+
544
+ Bundled themes provide readable text for each button variant, visible keyboard
545
+ focus, and the matching light or dark color scheme for native controls.
546
+ Reduced-motion settings remove button movement and spinner animation while
547
+ keeping the busy marker visible. Custom theme authors should define
548
+ `--pyh-focus`, `--pyh-color-scheme`, and the `--pyh-btn-primary-text`,
549
+ `--pyh-btn-secondary-text`, and `--pyh-btn-danger-text` tokens along with the
550
+ existing colors.
@@ -21,7 +21,7 @@ classifiers = [
21
21
  "Topic :: Software Development :: Libraries :: Python Modules",
22
22
  "Topic :: Internet :: WWW/HTTP",
23
23
  ]
24
- version = "0.4.1"
24
+ version = "0.5.0"
25
25
 
26
26
  [project.license]
27
27
  file = "LICENSE"
@@ -68,6 +68,16 @@ includes = [
68
68
  ]
69
69
 
70
70
  [tool.pdm.scripts]
71
- test = "pytest"
71
+ check-docs = "perfect-doc check . --offline --no-banner"
72
+ test = "pytest --browser chromium --browser webkit"
73
+ check-dist = "python scripts/check_distribution.py"
74
+ test-native = "python tests/native_smoke.py"
72
75
  lint = "ruff check ."
73
- install-browser = "playwright install chromium"
76
+ install-browser = "playwright install chromium webkit"
77
+
78
+ [tool.pdm.scripts.check]
79
+ composite = [
80
+ "lint",
81
+ "test",
82
+ "check-docs",
83
+ ]
@@ -2,24 +2,77 @@ from __future__ import annotations
2
2
 
3
3
  from importlib.resources import files
4
4
  import json
5
- import re
6
5
  from html import escape as html_escape
6
+ from html.parser import HTMLParser
7
7
  from typing import Any
8
8
 
9
9
  import webview
10
10
 
11
- _SCRIPT_TAG_RE = re.compile(
12
- r"<script[^>]*\bdata-pywebview-htmx\s*=\s*(?:[\"']true[\"']|true\b)",
13
- re.IGNORECASE,
14
- )
15
- _THEME_TAG_RE = re.compile(
16
- r"<style[^>]*\bdata-pywebview-theme\s*=\s*[\"'](?P<name>[^\"']+)[\"'][^>]*>.*?</style>",
17
- re.IGNORECASE | re.DOTALL,
18
- )
19
11
  _THEME_BASE_NAME = "base"
20
12
  DEFAULT_THEME = "aurora"
21
13
 
22
14
 
15
+ class _AssetTags(HTMLParser):
16
+ """Find active asset tags and closing tags in the original HTML."""
17
+
18
+ CDATA_CONTENT_ELEMENTS = (
19
+ *HTMLParser.CDATA_CONTENT_ELEMENTS, "title", "textarea", "noscript",
20
+ )
21
+
22
+ def __init__(self, html: str) -> None:
23
+ super().__init__(convert_charrefs=False)
24
+ self._html = html
25
+ self._line_starts = [0] + [
26
+ index + 1 for index, char in enumerate(html) if char == "\n"
27
+ ]
28
+ self._template_depth = 0
29
+ self._theme_start: tuple[int, str] | None = None
30
+ self.runtime_present = False
31
+ self.theme: tuple[int, int, str] | None = None
32
+ self.closing_tags: dict[str, int] = {}
33
+ self.feed(html)
34
+ self.close()
35
+
36
+ def _offset(self) -> int:
37
+ line, column = self.getpos()
38
+ return self._line_starts[line - 1] + column
39
+
40
+ def handle_starttag(
41
+ self, tag: str, attrs: list[tuple[str, str | None]],
42
+ ) -> None:
43
+ if tag == "template":
44
+ self._template_depth += 1
45
+ if self._template_depth:
46
+ return
47
+
48
+ attributes: dict[str, str | None] = {}
49
+ for name, value in attrs:
50
+ attributes.setdefault(name, value)
51
+ marker = attributes.get("data-pywebview-htmx")
52
+ if tag == "script" and marker is not None and marker.lower() == "true":
53
+ self.runtime_present = True
54
+ if tag == "style" and self.theme is None:
55
+ theme = attributes.get("data-pywebview-theme")
56
+ if theme is not None:
57
+ self._theme_start = (self._offset(), theme)
58
+
59
+ def handle_endtag(self, tag: str) -> None:
60
+ if tag == "template" and self._template_depth:
61
+ self._template_depth -= 1
62
+ return
63
+ if self._template_depth:
64
+ return
65
+
66
+ offset = self._offset()
67
+ if tag in {"head", "body"}:
68
+ self.closing_tags[tag] = offset
69
+ if tag == "style" and self._theme_start is not None:
70
+ start, theme = self._theme_start
71
+ end = self._html.index(">", offset) + 1
72
+ self.theme = (start, end, theme)
73
+ self._theme_start = None
74
+
75
+
23
76
  def list_themes() -> list[str]:
24
77
  """Return all bundled selectable theme names."""
25
78
  themes_dir = files("pywebview_htmx").joinpath("static/themes")
@@ -61,24 +114,21 @@ def get_runtime_script() -> str:
61
114
  def encode_params_attr(params: Any) -> str:
62
115
  """Encode a payload for safe embedding in ``data-py-params``."""
63
116
  return html_escape(
64
- json.dumps(params, sort_keys=True),
117
+ json.dumps(params, sort_keys=True, allow_nan=False),
65
118
  quote=True,
66
119
  )
67
120
 
68
121
 
69
122
  def inject_runtime(html: str) -> str:
70
123
  """Inject the runtime script into an HTML string before ``</body>`` when present."""
71
- if _SCRIPT_TAG_RE.search(html):
124
+ tags = _AssetTags(html)
125
+ if tags.runtime_present:
72
126
  return html
73
127
 
74
128
  script_content = get_runtime_script()
75
129
  injection = f'<script data-pywebview-htmx="true">{script_content}</script>'
76
130
 
77
- lower_html = html.lower()
78
- body_close_idx = lower_html.rfind("</body>")
79
- if body_close_idx == -1:
80
- return f"{html}{injection}"
81
-
131
+ body_close_idx = tags.closing_tags.get("body", len(html))
82
132
  return f"{html[:body_close_idx]}{injection}{html[body_close_idx:]}"
83
133
 
84
134
 
@@ -88,24 +138,18 @@ def inject_theme(html: str, theme: str = DEFAULT_THEME) -> str:
88
138
  css = get_theme_css(normalized_theme)
89
139
  injection = f'<style data-pywebview-theme="{normalized_theme}">{css}</style>'
90
140
 
91
- match = _THEME_TAG_RE.search(html)
92
- if match:
93
- current_theme = match.group("name").strip().lower()
141
+ tags = _AssetTags(html)
142
+ if tags.theme is not None:
143
+ start, end, current_theme = tags.theme
144
+ current_theme = current_theme.strip().lower()
94
145
  if current_theme == normalized_theme:
95
146
  return html
96
- start, end = match.span()
97
147
  return f"{html[:start]}{injection}{html[end:]}"
98
148
 
99
- lower_html = html.lower()
100
- head_close_idx = lower_html.rfind("</head>")
101
- if head_close_idx != -1:
102
- return f"{html[:head_close_idx]}{injection}{html[head_close_idx:]}"
103
-
104
- body_close_idx = lower_html.rfind("</body>")
105
- if body_close_idx != -1:
106
- return f"{html[:body_close_idx]}{injection}{html[body_close_idx:]}"
107
-
108
- return f"{injection}{html}"
149
+ insertion_idx = tags.closing_tags.get(
150
+ "head", tags.closing_tags.get("body", 0),
151
+ )
152
+ return f"{html[:insertion_idx]}{injection}{html[insertion_idx:]}"
109
153
 
110
154
 
111
155
  def create_window(