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.
Files changed (26) hide show
  1. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/PKG-INFO +79 -7
  2. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/README.md +78 -6
  3. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/pyproject.toml +12 -3
  4. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/runtime.js +189 -50
  5. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/themes/aurora.css +6 -1
  6. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/themes/base.css +48 -5
  7. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/themes/cybermind.css +9 -4
  8. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/src/pywebview_htmx/static/themes/paper.css +6 -1
  9. pywebview_htmx-0.5.0/tests/README.md +8 -0
  10. pywebview_htmx-0.5.0/tests/native_smoke.py +302 -0
  11. pywebview_htmx-0.5.0/tests/test_demo.py +169 -0
  12. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/tests/test_js_runtime.py +414 -3
  13. pywebview_htmx-0.5.0/tests/test_theme_accessibility.py +200 -0
  14. pywebview_htmx-0.4.2/tests/native_smoke.py +0 -104
  15. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/LICENSE +0 -0
  16. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/src/pywebview_htmx/__init__.py +0 -0
  17. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/src/pywebview_htmx/py.typed +0 -0
  18. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/src/pywebview_htmx/runtime.py +0 -0
  19. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/tests/conftest.py +0 -0
  20. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/tests/test_html_assets_browser.py +0 -0
  21. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/tests/test_python_create_window.py +0 -0
  22. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/tests/test_python_injection.py +0 -0
  23. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/tests/test_python_params_attr.py +0 -0
  24. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/tests/test_python_script_loading.py +0 -0
  25. {pywebview_htmx-0.4.2 → pywebview_htmx-0.5.0}/tests/test_python_themes.py +0 -0
  26. {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.4.2
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.
@@ -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 logs an error and falls back to `{}`.
257
- - 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
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` automatically scans swapped content for new `py-call`
394
- 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
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 `<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.
@@ -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 logs an error and falls back to `{}`.
217
- - 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
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` automatically scans swapped content for new `py-call`
354
- 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
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.4.2"
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
- 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"
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 pySwap(target, content, swapStyle = config.defaultSwapStyle) {
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 (!raw) return {};
129
+ if (raw === null) return {};
130
+ let params;
62
131
  try {
63
- return JSON.parse(raw);
132
+ params = JSON.parse(raw);
64
133
  } catch (error) {
65
- console.error("PyWebview HTMX: invalid JSON in data-py-params", error);
66
- return {};
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" || eventName !== "submit") {
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
- const count = (waitState.get(waitTarget) || 0) + 1;
157
- waitState.set(waitTarget, count);
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 count = Math.max(0, (waitState.get(waitTarget) || 0) - 1);
163
- if (count === 0) {
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
- return;
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
- elements.push(...root.querySelectorAll("[py-call]"));
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
- element.addEventListener(eventName, async (event) => {
229
- if (eventName === "click" && pendingLabelControlClicks.has(element)) {
230
- pendingLabelControlClicks.delete(element);
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
- if (requestId !== state.lastIssued) {
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 processRoot = pySwap(target, response, swapStyle);
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(processRoot);
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
- console.error("PyWebview HTMX: error calling Python function", error);
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
- removeWaiting(waitTarget);
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: rgba(255, 255, 255, 0.12);
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