pywebview-htmx 0.4.2__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.2 → pywebview_htmx-0.5.0}/PKG-INFO +79 -7
- {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/README.md +78 -6
- {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/pyproject.toml +12 -3
- {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/runtime.js +189 -50
- {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/themes/aurora.css +6 -1
- {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/themes/base.css +48 -5
- {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/themes/cybermind.css +9 -4
- {pywebview_htmx-0.4.2 → 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.4.2 → pywebview_htmx-0.5.0}/tests/test_js_runtime.py +414 -3
- pywebview_htmx-0.5.0/tests/test_theme_accessibility.py +200 -0
- pywebview_htmx-0.4.2/tests/native_smoke.py +0 -104
- {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/LICENSE +0 -0
- {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/src/pywebview_htmx/__init__.py +0 -0
- {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/src/pywebview_htmx/py.typed +0 -0
- {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/src/pywebview_htmx/runtime.py +0 -0
- {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/tests/conftest.py +0 -0
- {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/tests/test_html_assets_browser.py +0 -0
- {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/tests/test_python_create_window.py +0 -0
- {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/tests/test_python_injection.py +0 -0
- {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/tests/test_python_params_attr.py +0 -0
- {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/tests/test_python_script_loading.py +0 -0
- {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/tests/test_python_themes.py +0 -0
- {pywebview_htmx-0.4.2 → 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.
|
|
@@ -253,8 +255,8 @@ JSON payload passed to Python method.
|
|
|
253
255
|
|
|
254
256
|
Notes:
|
|
255
257
|
- If missing, params default to `{}`.
|
|
256
|
-
- Invalid JSON
|
|
257
|
-
- 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
|
|
258
260
|
merged over this payload.
|
|
259
261
|
|
|
260
262
|
### `py-target` (optional)
|
|
@@ -294,6 +296,7 @@ Per-element concurrency override. If omitted, the global
|
|
|
294
296
|
- `swapDelay` (ms, default: `0`)
|
|
295
297
|
- `settleDelay` (ms, default: `20`)
|
|
296
298
|
- `requestPolicy` (`"latest-wins"` default, or `"drop"`)
|
|
299
|
+
- `requestTimeout` (ms, default: `0`; `py-timeout` overrides it per element)
|
|
297
300
|
|
|
298
301
|
Runtime defaults are read for each accepted request. An element's explicit
|
|
299
302
|
`py-swap` continues to override `defaultSwapStyle`; otherwise, changing
|
|
@@ -390,8 +393,8 @@ A call made before the bridge is available emits `py:error`. It is not queued.
|
|
|
390
393
|
|
|
391
394
|
## Dynamic Content Re-processing
|
|
392
395
|
|
|
393
|
-
After a swap, `pywebview-htmx`
|
|
394
|
-
|
|
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
|
|
395
398
|
without manual re-init code.
|
|
396
399
|
|
|
397
400
|
## Theme System
|
|
@@ -516,3 +519,72 @@ The demo includes:
|
|
|
516
519
|
## License
|
|
517
520
|
|
|
518
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.
|
|
@@ -213,8 +215,8 @@ JSON payload passed to Python method.
|
|
|
213
215
|
|
|
214
216
|
Notes:
|
|
215
217
|
- If missing, params default to `{}`.
|
|
216
|
-
- Invalid JSON
|
|
217
|
-
- 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
|
|
218
220
|
merged over this payload.
|
|
219
221
|
|
|
220
222
|
### `py-target` (optional)
|
|
@@ -254,6 +256,7 @@ Per-element concurrency override. If omitted, the global
|
|
|
254
256
|
- `swapDelay` (ms, default: `0`)
|
|
255
257
|
- `settleDelay` (ms, default: `20`)
|
|
256
258
|
- `requestPolicy` (`"latest-wins"` default, or `"drop"`)
|
|
259
|
+
- `requestTimeout` (ms, default: `0`; `py-timeout` overrides it per element)
|
|
257
260
|
|
|
258
261
|
Runtime defaults are read for each accepted request. An element's explicit
|
|
259
262
|
`py-swap` continues to override `defaultSwapStyle`; otherwise, changing
|
|
@@ -350,8 +353,8 @@ A call made before the bridge is available emits `py:error`. It is not queued.
|
|
|
350
353
|
|
|
351
354
|
## Dynamic Content Re-processing
|
|
352
355
|
|
|
353
|
-
After a swap, `pywebview-htmx`
|
|
354
|
-
|
|
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
|
|
355
358
|
without manual re-init code.
|
|
356
359
|
|
|
357
360
|
## Theme System
|
|
@@ -476,3 +479,72 @@ The demo includes:
|
|
|
476
479
|
## License
|
|
477
480
|
|
|
478
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,7 +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"
|
|
72
74
|
test-native = "python tests/native_smoke.py"
|
|
73
75
|
lint = "ruff check ."
|
|
74
|
-
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
|
+
]
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
swapDelay: 0,
|
|
7
7
|
settleDelay: 20,
|
|
8
8
|
requestPolicy: "latest-wins",
|
|
9
|
+
requestTimeout: 0,
|
|
9
10
|
};
|
|
10
11
|
const selfRequestState = new WeakMap();
|
|
11
12
|
const targetedRequestState = new Map();
|
|
@@ -33,38 +34,109 @@
|
|
|
33
34
|
return event;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
function reportError(element, error, detail = {}) {
|
|
38
|
+
console.error("PyWebview HTMX: request failed", error);
|
|
39
|
+
const target = element.isConnected ? element : document.body || document;
|
|
40
|
+
triggerEvent(target, "py:error", { element, error, stale: false, ...detail });
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
function delay(ms) {
|
|
37
44
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
function
|
|
47
|
+
function captureFocus(target, swapStyle) {
|
|
48
|
+
const active = document.activeElement;
|
|
49
|
+
if (swapStyle === "append" || !active?.id || !target.contains(active)) return null;
|
|
50
|
+
const scroll = [];
|
|
51
|
+
for (let parent = active.parentElement; parent; parent = parent.parentElement) {
|
|
52
|
+
scroll.push({ element: parent, left: parent.scrollLeft, top: parent.scrollTop });
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
scroll,
|
|
56
|
+
left: window.scrollX,
|
|
57
|
+
top: window.scrollY,
|
|
58
|
+
element: active,
|
|
59
|
+
id: active.id,
|
|
60
|
+
start: active.selectionStart,
|
|
61
|
+
end: active.selectionEnd,
|
|
62
|
+
direction: active.selectionDirection,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function restoreFocus(snapshot, insertedNodes) {
|
|
67
|
+
if (!snapshot || snapshot.element.isConnected) return;
|
|
68
|
+
const active = document.activeElement;
|
|
69
|
+
if (active && active !== document.body && active !== document.documentElement) return;
|
|
70
|
+
const selector = `#${CSS.escape(snapshot.id)}`;
|
|
71
|
+
let replacement;
|
|
72
|
+
for (const node of insertedNodes) {
|
|
73
|
+
if (node.nodeType !== Node.ELEMENT_NODE || !node.isConnected) continue;
|
|
74
|
+
replacement = node.id === snapshot.id ? node : node.querySelector(selector);
|
|
75
|
+
if (replacement) break;
|
|
76
|
+
}
|
|
77
|
+
if (!replacement || typeof replacement.focus !== "function") return;
|
|
78
|
+
// Wait for layout so WebKit does not scroll to the replaced control.
|
|
79
|
+
requestAnimationFrame(() => {
|
|
80
|
+
const active = document.activeElement;
|
|
81
|
+
if (!replacement.isConnected || snapshot.element.isConnected ||
|
|
82
|
+
(active && active !== document.body && active !== document.documentElement)) return;
|
|
83
|
+
replacement.focus({ preventScroll: true });
|
|
84
|
+
if (typeof snapshot.start === "number" && typeof replacement.selectionStart === "number") {
|
|
85
|
+
replacement.setSelectionRange(snapshot.start, snapshot.end, snapshot.direction);
|
|
86
|
+
}
|
|
87
|
+
for (const position of snapshot.scroll) {
|
|
88
|
+
if (position.element.isConnected) {
|
|
89
|
+
position.element.scrollTo({ left: position.left, top: position.top, behavior: "instant" });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
window.scrollTo({ left: snapshot.left, top: snapshot.top, behavior: "instant" });
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function collectInsertedNodes(parent, previous, next, insertedNodes) {
|
|
97
|
+
let node = previous ? previous.nextSibling : parent.firstChild;
|
|
98
|
+
while (node && node !== next) {
|
|
99
|
+
insertedNodes.push(node);
|
|
100
|
+
node = node.nextSibling;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function pySwap(target, content, swapStyle = config.defaultSwapStyle, insertedNodes = []) {
|
|
41
105
|
switch (swapStyle) {
|
|
42
|
-
case "innerHTML":
|
|
43
|
-
target.innerHTML = content;
|
|
44
|
-
return target;
|
|
45
106
|
case "outerHTML": {
|
|
46
107
|
const parent = target.parentElement || document.body;
|
|
108
|
+
const previous = target.previousSibling;
|
|
109
|
+
const next = target.nextSibling;
|
|
47
110
|
target.outerHTML = content;
|
|
111
|
+
collectInsertedNodes(parent, previous, next, insertedNodes);
|
|
48
112
|
return parent;
|
|
49
113
|
}
|
|
50
|
-
case "append":
|
|
114
|
+
case "append": {
|
|
115
|
+
const previous = target.lastChild;
|
|
51
116
|
target.insertAdjacentHTML("beforeend", content);
|
|
117
|
+
collectInsertedNodes(target, previous, null, insertedNodes);
|
|
52
118
|
return target;
|
|
119
|
+
}
|
|
53
120
|
default:
|
|
54
121
|
target.innerHTML = content;
|
|
122
|
+
insertedNodes.push(target);
|
|
55
123
|
return target;
|
|
56
124
|
}
|
|
57
125
|
}
|
|
58
126
|
|
|
59
127
|
function parseParams(element) {
|
|
60
128
|
const raw = element.getAttribute("data-py-params");
|
|
61
|
-
if (
|
|
129
|
+
if (raw === null) return {};
|
|
130
|
+
let params;
|
|
62
131
|
try {
|
|
63
|
-
|
|
132
|
+
params = JSON.parse(raw);
|
|
64
133
|
} catch (error) {
|
|
65
|
-
|
|
66
|
-
|
|
134
|
+
throw new Error("PyWebview HTMX: invalid JSON in data-py-params", { cause: error });
|
|
135
|
+
}
|
|
136
|
+
if (params === null || typeof params !== "object" || Array.isArray(params)) {
|
|
137
|
+
throw new Error("PyWebview HTMX: data-py-params must contain a JSON object");
|
|
67
138
|
}
|
|
139
|
+
return params;
|
|
68
140
|
}
|
|
69
141
|
|
|
70
142
|
function mergeParamValue(target, key, value) {
|
|
@@ -102,7 +174,7 @@
|
|
|
102
174
|
|
|
103
175
|
function buildRequestParams(element, eventName, event) {
|
|
104
176
|
const params = parseParams(element);
|
|
105
|
-
if (element.tagName !== "FORM" ||
|
|
177
|
+
if (element.tagName !== "FORM" || !["submit", "input", "change"].includes(eventName)) {
|
|
106
178
|
return params;
|
|
107
179
|
}
|
|
108
180
|
return { ...params, ...serializeForm(element, event) };
|
|
@@ -153,19 +225,26 @@
|
|
|
153
225
|
}
|
|
154
226
|
|
|
155
227
|
function addWaiting(waitTarget) {
|
|
156
|
-
|
|
157
|
-
|
|
228
|
+
let state = waitState.get(waitTarget);
|
|
229
|
+
if (!state) {
|
|
230
|
+
state = { count: 0, previousBusy: waitTarget.getAttribute("aria-busy") };
|
|
231
|
+
waitState.set(waitTarget, state);
|
|
232
|
+
}
|
|
233
|
+
state.count += 1;
|
|
158
234
|
waitTarget.classList.add("py-waiting");
|
|
235
|
+
waitTarget.setAttribute("aria-busy", "true");
|
|
159
236
|
}
|
|
160
237
|
|
|
161
238
|
function removeWaiting(waitTarget) {
|
|
162
|
-
const
|
|
163
|
-
if (
|
|
239
|
+
const state = waitState.get(waitTarget);
|
|
240
|
+
if (!state) return;
|
|
241
|
+
state.count -= 1;
|
|
242
|
+
if (state.count === 0) {
|
|
164
243
|
waitState.delete(waitTarget);
|
|
165
244
|
waitTarget.classList.remove("py-waiting");
|
|
166
|
-
|
|
245
|
+
if (state.previousBusy === null) waitTarget.removeAttribute("aria-busy");
|
|
246
|
+
else waitTarget.setAttribute("aria-busy", state.previousBusy);
|
|
167
247
|
}
|
|
168
|
-
waitState.set(waitTarget, count);
|
|
169
248
|
}
|
|
170
249
|
|
|
171
250
|
function shouldPreventDefault(element, eventName) {
|
|
@@ -188,6 +267,14 @@
|
|
|
188
267
|
return false;
|
|
189
268
|
}
|
|
190
269
|
|
|
270
|
+
function getDuration(element, attribute, fallback) {
|
|
271
|
+
const value = Number(element.getAttribute(attribute) ?? fallback);
|
|
272
|
+
if (!Number.isFinite(value) || value < 0 || value > 2147483647) {
|
|
273
|
+
throw new Error(`PyWebview HTMX: ${attribute} must be between 0 and 2147483647 ms`);
|
|
274
|
+
}
|
|
275
|
+
return value;
|
|
276
|
+
}
|
|
277
|
+
|
|
191
278
|
function getRequestPolicy(element) {
|
|
192
279
|
const policy = element.getAttribute("py-policy") || config.requestPolicy;
|
|
193
280
|
if (policy === "drop" || policy === "latest-wins") {
|
|
@@ -205,7 +292,7 @@
|
|
|
205
292
|
if (root.matches && root.matches("[py-call]")) {
|
|
206
293
|
elements.push(root);
|
|
207
294
|
}
|
|
208
|
-
|
|
295
|
+
root.querySelectorAll("[py-call]").forEach(element => elements.push(element));
|
|
209
296
|
|
|
210
297
|
elements.forEach((element) => {
|
|
211
298
|
if (boundElements.has(element)) {
|
|
@@ -225,32 +312,15 @@
|
|
|
225
312
|
}
|
|
226
313
|
boundElements.add(element);
|
|
227
314
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
315
|
+
let debounceTimer;
|
|
316
|
+
async function sendRequest(params) {
|
|
317
|
+
let timeout;
|
|
318
|
+
try {
|
|
319
|
+
timeout = getDuration(element, "py-timeout", config.requestTimeout);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
reportError(element, error, { phase: "config" });
|
|
231
322
|
return;
|
|
232
323
|
}
|
|
233
|
-
if (labelControl && event.target === labelControl) {
|
|
234
|
-
if (pendingLabelControlClicks.has(labelControl)) {
|
|
235
|
-
pendingLabelControlClicks.delete(labelControl);
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
if (
|
|
239
|
-
labelControl.hasAttribute("py-call") &&
|
|
240
|
-
(labelControl.getAttribute("py-trigger") || "click") === "click"
|
|
241
|
-
) {
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
} else if (labelControl && event.target !== labelControl) {
|
|
245
|
-
pendingLabelControlClicks.add(labelControl);
|
|
246
|
-
setTimeout(() => pendingLabelControlClicks.delete(labelControl), 0);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
if (shouldPreventDefault(element, eventName) && event.cancelable) {
|
|
250
|
-
event.preventDefault();
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const params = buildRequestParams(element, eventName, event);
|
|
254
324
|
const scope = getRequestScope(element, targetSelector);
|
|
255
325
|
const state = scope.state;
|
|
256
326
|
const requestPolicy = getRequestPolicy(element);
|
|
@@ -272,10 +342,28 @@
|
|
|
272
342
|
const swapStyle = element.getAttribute("py-swap") || config.defaultSwapStyle;
|
|
273
343
|
const waitTarget = getWaitTarget(element);
|
|
274
344
|
addWaiting(waitTarget);
|
|
345
|
+
let waiting = true;
|
|
346
|
+
const finishWaiting = () => {
|
|
347
|
+
if (waiting) {
|
|
348
|
+
waiting = false;
|
|
349
|
+
removeWaiting(waitTarget);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
let expired = false;
|
|
353
|
+
const timer = timeout > 0 ? setTimeout(() => {
|
|
354
|
+
expired = true;
|
|
355
|
+
finishWaiting();
|
|
356
|
+
const error = new Error(`Python response timed out after ${timeout} ms; work may still be running`);
|
|
357
|
+
error.name = "TimeoutError";
|
|
358
|
+
reportError(element, error, {
|
|
359
|
+
phase: "timeout", requestId, stale: requestId !== state.lastIssued,
|
|
360
|
+
});
|
|
361
|
+
}, timeout) : null;
|
|
275
362
|
|
|
276
363
|
try {
|
|
277
364
|
const response = await invokePython(functionName, params);
|
|
278
|
-
|
|
365
|
+
clearTimeout(timer);
|
|
366
|
+
if (expired || requestId !== state.lastIssued) {
|
|
279
367
|
return;
|
|
280
368
|
}
|
|
281
369
|
if (typeof response !== "string") {
|
|
@@ -302,30 +390,81 @@
|
|
|
302
390
|
return;
|
|
303
391
|
}
|
|
304
392
|
|
|
305
|
-
const
|
|
393
|
+
const focus = captureFocus(target, swapStyle);
|
|
394
|
+
const insertedNodes = [];
|
|
395
|
+
const processRoot = pySwap(target, response, swapStyle, insertedNodes);
|
|
306
396
|
const afterSwapTarget =
|
|
307
397
|
swapStyle === "outerHTML"
|
|
308
398
|
? (targetSelector ? $(targetSelector) || processRoot : processRoot)
|
|
309
399
|
: target;
|
|
310
400
|
triggerEvent(afterSwapTarget, "py:afterSwap", { response, requestId });
|
|
311
|
-
processPyWebviewHtmxNodes
|
|
401
|
+
insertedNodes.forEach(processPyWebviewHtmxNodes);
|
|
402
|
+
restoreFocus(focus, insertedNodes);
|
|
312
403
|
|
|
313
404
|
if (config.settleDelay > 0) {
|
|
314
405
|
await delay(config.settleDelay);
|
|
315
406
|
}
|
|
316
407
|
} catch (error) {
|
|
317
|
-
|
|
318
|
-
const errorTarget = element.isConnected ? element : document.body || document;
|
|
319
|
-
triggerEvent(errorTarget, "py:error", {
|
|
320
|
-
element,
|
|
321
|
-
error,
|
|
408
|
+
reportError(element, error, {
|
|
322
409
|
requestId,
|
|
410
|
+
expired,
|
|
323
411
|
stale: requestId !== state.lastIssued,
|
|
324
412
|
});
|
|
325
413
|
} finally {
|
|
414
|
+
clearTimeout(timer);
|
|
326
415
|
state.inFlightCount = Math.max(0, state.inFlightCount - 1);
|
|
327
416
|
releaseRequestScope(scope);
|
|
328
|
-
|
|
417
|
+
finishWaiting();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
element.addEventListener(eventName, (event) => {
|
|
422
|
+
if (eventName === "click" && pendingLabelControlClicks.has(element)) {
|
|
423
|
+
pendingLabelControlClicks.delete(element);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (labelControl && event.target === labelControl) {
|
|
427
|
+
if (pendingLabelControlClicks.has(labelControl)) {
|
|
428
|
+
pendingLabelControlClicks.delete(labelControl);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (
|
|
432
|
+
labelControl.hasAttribute("py-call") &&
|
|
433
|
+
(labelControl.getAttribute("py-trigger") || "click") === "click"
|
|
434
|
+
) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
} else if (labelControl && event.target !== labelControl) {
|
|
438
|
+
pendingLabelControlClicks.add(labelControl);
|
|
439
|
+
setTimeout(() => pendingLabelControlClicks.delete(labelControl), 0);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (shouldPreventDefault(element, eventName) && event.cancelable) {
|
|
443
|
+
event.preventDefault();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
clearTimeout(debounceTimer);
|
|
447
|
+
if (event.isComposing) return;
|
|
448
|
+
let debounce;
|
|
449
|
+
try {
|
|
450
|
+
debounce = getDuration(element, "py-debounce", 0);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
reportError(element, error, { phase: "config" });
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
let params;
|
|
456
|
+
try {
|
|
457
|
+
params = buildRequestParams(element, eventName, event);
|
|
458
|
+
} catch (error) {
|
|
459
|
+
reportError(element, error, { phase: "params" });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (debounce > 0) {
|
|
463
|
+
debounceTimer = setTimeout(() => {
|
|
464
|
+
if (element.isConnected) sendRequest(params);
|
|
465
|
+
}, debounce);
|
|
466
|
+
} else {
|
|
467
|
+
sendRequest(params);
|
|
329
468
|
}
|
|
330
469
|
});
|
|
331
470
|
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
:root {
|
|
2
|
+
--pyh-color-scheme: dark;
|
|
3
|
+
--pyh-focus: #ffffff;
|
|
2
4
|
--pyh-font-ui: "Avenir Next", "Trebuchet MS", "Segoe UI", sans-serif;
|
|
3
5
|
--pyh-font-headline: "Gill Sans", "Trebuchet MS", sans-serif;
|
|
4
6
|
--pyh-font-mono: "SFMono-Regular", "Menlo", "Consolas", monospace;
|
|
@@ -38,10 +40,13 @@
|
|
|
38
40
|
|
|
39
41
|
--pyh-btn-text: #0a1d2d;
|
|
40
42
|
--pyh-btn-bg: #dce8f1;
|
|
43
|
+
--pyh-btn-primary-text: #0a1d2d;
|
|
41
44
|
--pyh-btn-primary-bg: linear-gradient(130deg, #79ffd3, #55e1b8);
|
|
45
|
+
--pyh-btn-secondary-text: #0a1d2d;
|
|
42
46
|
--pyh-btn-secondary-bg: linear-gradient(130deg, #ffd899, #f7ba54);
|
|
47
|
+
--pyh-btn-danger-text: #0a1d2d;
|
|
43
48
|
--pyh-btn-danger-bg: linear-gradient(130deg, #ffb8b8, #ef6f6f);
|
|
44
|
-
--pyh-btn-ghost-bg:
|
|
49
|
+
--pyh-btn-ghost-bg: #102c40;
|
|
45
50
|
--pyh-btn-ghost-text: #f4f8fb;
|
|
46
51
|
--pyh-btn-ghost-border: rgba(255, 255, 255, 0.28);
|
|
47
52
|
|