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.
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/PKG-INFO +111 -7
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/README.md +110 -6
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/pyproject.toml +13 -3
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/src/pywebview_htmx/runtime.py +74 -30
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/runtime.js +195 -32
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/themes/aurora.css +6 -1
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/themes/base.css +48 -6
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/themes/cybermind.css +9 -4
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/themes/paper.css +6 -1
- pywebview_htmx-0.5.0/tests/README.md +8 -0
- pywebview_htmx-0.5.0/tests/native_smoke.py +302 -0
- pywebview_htmx-0.5.0/tests/test_demo.py +169 -0
- pywebview_htmx-0.5.0/tests/test_html_assets_browser.py +42 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/tests/test_js_runtime.py +639 -3
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/tests/test_python_injection.py +42 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/tests/test_python_params_attr.py +8 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/tests/test_python_themes.py +41 -0
- pywebview_htmx-0.5.0/tests/test_theme_accessibility.py +200 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/LICENSE +0 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/src/pywebview_htmx/__init__.py +0 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/src/pywebview_htmx/py.typed +0 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/tests/conftest.py +0 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/tests/test_python_create_window.py +0 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.5.0}/tests/test_python_script_loading.py +0 -0
- {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.
|
|
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
|
|
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
|
|
251
|
-
- For submit
|
|
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`
|
|
362
|
-
|
|
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
|
|
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
|
|
211
|
-
- For submit
|
|
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`
|
|
322
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
if
|
|
93
|
-
current_theme =
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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(
|