pywebview-htmx 0.4.1__tar.gz → 0.4.2__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.4.2}/PKG-INFO +33 -1
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/README.md +32 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/pyproject.toml +2 -1
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/src/pywebview_htmx/runtime.py +74 -30
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/runtime.js +25 -1
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/themes/base.css +0 -1
- pywebview_htmx-0.4.2/tests/native_smoke.py +104 -0
- pywebview_htmx-0.4.2/tests/test_html_assets_browser.py +42 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/tests/test_js_runtime.py +225 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/tests/test_python_injection.py +42 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/tests/test_python_params_attr.py +8 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/tests/test_python_themes.py +41 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/LICENSE +0 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/src/pywebview_htmx/__init__.py +0 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/src/pywebview_htmx/py.typed +0 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/themes/aurora.css +0 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/themes/cybermind.css +0 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/themes/paper.css +0 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/tests/conftest.py +0 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/tests/test_python_create_window.py +0 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/tests/test_python_script_loading.py +0 -0
- {pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/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.
|
|
3
|
+
Version: 0.4.2
|
|
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
|
|
@@ -195,6 +195,9 @@ Injects the runtime script tag into an HTML string (idempotent).
|
|
|
195
195
|
Returns HTML-escaped JSON that is safe to place in a `data-py-params`
|
|
196
196
|
attribute.
|
|
197
197
|
|
|
198
|
+
Non-finite numbers (`NaN` and positive or negative infinity) raise `ValueError`.
|
|
199
|
+
JSON does not support these values.
|
|
200
|
+
|
|
198
201
|
```python
|
|
199
202
|
from pywebview_htmx import encode_params_attr
|
|
200
203
|
|
|
@@ -237,6 +240,9 @@ form submission, link navigation, and submit/reset controls associated with a
|
|
|
237
240
|
form. Ordinary buttons and the native state changes of checkboxes, radio
|
|
238
241
|
buttons, and labels are preserved.
|
|
239
242
|
|
|
243
|
+
A label that contains its control calls Python once per activation. Clicking
|
|
244
|
+
the label text still changes the checkbox or radio state.
|
|
245
|
+
|
|
240
246
|
### `data-py-params` (optional)
|
|
241
247
|
|
|
242
248
|
JSON payload passed to Python method.
|
|
@@ -346,6 +352,9 @@ The `py-wait` selector is resolved for each accepted request. Loading state is
|
|
|
346
352
|
counted separately per resolved wait element, so a shared spinner stays active
|
|
347
353
|
until every request using that particular element finishes.
|
|
348
354
|
|
|
355
|
+
The bundled loading styles show `.py-waiting` without blocking pointer input.
|
|
356
|
+
The request policy controls whether another trigger is accepted.
|
|
357
|
+
|
|
349
358
|
### `latest-wins` (default)
|
|
350
359
|
|
|
351
360
|
Multiple rapid requests are allowed; stale responses in the same request state
|
|
@@ -356,6 +365,29 @@ scope are ignored. Only the latest request for that scope updates the DOM.
|
|
|
356
365
|
If a request is already in flight for the same request state scope, new triggers
|
|
357
366
|
are ignored and `py:ignored` is emitted.
|
|
358
367
|
|
|
368
|
+
### Python Handler Concurrency
|
|
369
|
+
|
|
370
|
+
`latest-wins` does not cancel Python work. Each accepted call still runs, even
|
|
371
|
+
when its returned HTML is later ignored. PyWebview runs exposed handlers in
|
|
372
|
+
separate threads. Protect shared mutable data with a lock or a database
|
|
373
|
+
transaction when needed.
|
|
374
|
+
|
|
375
|
+
`drop` applies to the same request scope. It does not prevent calls from other
|
|
376
|
+
scopes from changing the same Python data.
|
|
377
|
+
|
|
378
|
+
### Bridge Readiness
|
|
379
|
+
|
|
380
|
+
PyWebview creates the bridge after the document loads. Wait for the
|
|
381
|
+
`pywebviewready` event before starting programmatic calls:
|
|
382
|
+
|
|
383
|
+
```js
|
|
384
|
+
window.addEventListener("pywebviewready", () => {
|
|
385
|
+
document.querySelector("#load-button").click();
|
|
386
|
+
});
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
A call made before the bridge is available emits `py:error`. It is not queued.
|
|
390
|
+
|
|
359
391
|
## Dynamic Content Re-processing
|
|
360
392
|
|
|
361
393
|
After a swap, `pywebview-htmx` automatically scans swapped content for new `py-call`
|
|
@@ -155,6 +155,9 @@ Injects the runtime script tag into an HTML string (idempotent).
|
|
|
155
155
|
Returns HTML-escaped JSON that is safe to place in a `data-py-params`
|
|
156
156
|
attribute.
|
|
157
157
|
|
|
158
|
+
Non-finite numbers (`NaN` and positive or negative infinity) raise `ValueError`.
|
|
159
|
+
JSON does not support these values.
|
|
160
|
+
|
|
158
161
|
```python
|
|
159
162
|
from pywebview_htmx import encode_params_attr
|
|
160
163
|
|
|
@@ -197,6 +200,9 @@ form submission, link navigation, and submit/reset controls associated with a
|
|
|
197
200
|
form. Ordinary buttons and the native state changes of checkboxes, radio
|
|
198
201
|
buttons, and labels are preserved.
|
|
199
202
|
|
|
203
|
+
A label that contains its control calls Python once per activation. Clicking
|
|
204
|
+
the label text still changes the checkbox or radio state.
|
|
205
|
+
|
|
200
206
|
### `data-py-params` (optional)
|
|
201
207
|
|
|
202
208
|
JSON payload passed to Python method.
|
|
@@ -306,6 +312,9 @@ The `py-wait` selector is resolved for each accepted request. Loading state is
|
|
|
306
312
|
counted separately per resolved wait element, so a shared spinner stays active
|
|
307
313
|
until every request using that particular element finishes.
|
|
308
314
|
|
|
315
|
+
The bundled loading styles show `.py-waiting` without blocking pointer input.
|
|
316
|
+
The request policy controls whether another trigger is accepted.
|
|
317
|
+
|
|
309
318
|
### `latest-wins` (default)
|
|
310
319
|
|
|
311
320
|
Multiple rapid requests are allowed; stale responses in the same request state
|
|
@@ -316,6 +325,29 @@ scope are ignored. Only the latest request for that scope updates the DOM.
|
|
|
316
325
|
If a request is already in flight for the same request state scope, new triggers
|
|
317
326
|
are ignored and `py:ignored` is emitted.
|
|
318
327
|
|
|
328
|
+
### Python Handler Concurrency
|
|
329
|
+
|
|
330
|
+
`latest-wins` does not cancel Python work. Each accepted call still runs, even
|
|
331
|
+
when its returned HTML is later ignored. PyWebview runs exposed handlers in
|
|
332
|
+
separate threads. Protect shared mutable data with a lock or a database
|
|
333
|
+
transaction when needed.
|
|
334
|
+
|
|
335
|
+
`drop` applies to the same request scope. It does not prevent calls from other
|
|
336
|
+
scopes from changing the same Python data.
|
|
337
|
+
|
|
338
|
+
### Bridge Readiness
|
|
339
|
+
|
|
340
|
+
PyWebview creates the bridge after the document loads. Wait for the
|
|
341
|
+
`pywebviewready` event before starting programmatic calls:
|
|
342
|
+
|
|
343
|
+
```js
|
|
344
|
+
window.addEventListener("pywebviewready", () => {
|
|
345
|
+
document.querySelector("#load-button").click();
|
|
346
|
+
});
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
A call made before the bridge is available emits `py:error`. It is not queued.
|
|
350
|
+
|
|
319
351
|
## Dynamic Content Re-processing
|
|
320
352
|
|
|
321
353
|
After a swap, `pywebview-htmx` automatically scans swapped content for new `py-call`
|
|
@@ -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.
|
|
24
|
+
version = "0.4.2"
|
|
25
25
|
|
|
26
26
|
[project.license]
|
|
27
27
|
file = "LICENSE"
|
|
@@ -69,5 +69,6 @@ includes = [
|
|
|
69
69
|
|
|
70
70
|
[tool.pdm.scripts]
|
|
71
71
|
test = "pytest"
|
|
72
|
+
test-native = "python tests/native_smoke.py"
|
|
72
73
|
lint = "ruff check ."
|
|
73
74
|
install-browser = "playwright install chromium"
|
|
@@ -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(
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
const targetedRequestState = new Map();
|
|
12
12
|
const waitState = new WeakMap();
|
|
13
13
|
const boundElements = new WeakSet();
|
|
14
|
+
const pendingLabelControlClicks = new WeakSet();
|
|
14
15
|
|
|
15
16
|
function $(selector, root = document) {
|
|
16
17
|
if (!selector) return null;
|
|
@@ -80,7 +81,7 @@
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
function serializeForm(form, event) {
|
|
83
|
-
const params =
|
|
84
|
+
const params = Object.create(null);
|
|
84
85
|
const formData = new FormData(form);
|
|
85
86
|
|
|
86
87
|
for (const [key, value] of formData.entries()) {
|
|
@@ -214,6 +215,9 @@
|
|
|
214
215
|
const functionName = element.getAttribute("py-call");
|
|
215
216
|
const eventName = element.getAttribute("py-trigger") || "click";
|
|
216
217
|
const targetSelector = (element.getAttribute("py-target") || "").trim() || null;
|
|
218
|
+
const labelControl = element.tagName === "LABEL" && eventName === "click"
|
|
219
|
+
? element.control
|
|
220
|
+
: null;
|
|
217
221
|
|
|
218
222
|
if (!functionName) {
|
|
219
223
|
console.warn("PyWebview HTMX: py-call is empty", element);
|
|
@@ -222,6 +226,26 @@
|
|
|
222
226
|
boundElements.add(element);
|
|
223
227
|
|
|
224
228
|
element.addEventListener(eventName, async (event) => {
|
|
229
|
+
if (eventName === "click" && pendingLabelControlClicks.has(element)) {
|
|
230
|
+
pendingLabelControlClicks.delete(element);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
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
|
+
|
|
225
249
|
if (shouldPreventDefault(element, eventName) && event.cancelable) {
|
|
226
250
|
event.preventDefault();
|
|
227
251
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Check a real Python bridge in the local desktop renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from html import escape
|
|
6
|
+
from threading import Event
|
|
7
|
+
from time import monotonic, sleep
|
|
8
|
+
|
|
9
|
+
import webview
|
|
10
|
+
|
|
11
|
+
from pywebview_htmx import create_window, encode_params_attr
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class API:
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._done = Event()
|
|
17
|
+
self._params: dict[str, object] | None = None
|
|
18
|
+
self._result: dict[str, object] | None = None
|
|
19
|
+
|
|
20
|
+
def greeting(self, params: dict[str, object]) -> str:
|
|
21
|
+
self._params = params
|
|
22
|
+
return f'<p>Hello, {escape(str(params["name"]))}!</p>'
|
|
23
|
+
|
|
24
|
+
def fail(self, params: dict[str, object]) -> str:
|
|
25
|
+
raise ValueError("Expected native smoke test failure")
|
|
26
|
+
|
|
27
|
+
def report(self, result: dict[str, object]) -> str:
|
|
28
|
+
self._result = result
|
|
29
|
+
self._done.set()
|
|
30
|
+
return ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main() -> None:
|
|
34
|
+
name = 'İstanbul <bridge> "test"'
|
|
35
|
+
params = encode_params_attr({"name": name})
|
|
36
|
+
api = API()
|
|
37
|
+
html = f"""<!doctype html>
|
|
38
|
+
<html><head><title>İstanbul</title></head><body>
|
|
39
|
+
<button id="greeting" py-call="greeting" py-target="#result"
|
|
40
|
+
data-py-params="{params}">İstanbul</button>
|
|
41
|
+
<button id="failure" py-call="fail" py-target="#error-result">Fail</button>
|
|
42
|
+
<div id="result"></div><div id="error-result"></div>
|
|
43
|
+
<script>
|
|
44
|
+
document.addEventListener('DOMContentLoaded', () => {{
|
|
45
|
+
document.body.addEventListener('py:afterSwap', event => {{
|
|
46
|
+
if (event.target.id === 'result') {{
|
|
47
|
+
document.querySelector('#failure').click();
|
|
48
|
+
}}
|
|
49
|
+
}});
|
|
50
|
+
document.body.addEventListener('py:error', event => {{
|
|
51
|
+
pywebview.api.report({{
|
|
52
|
+
text: document.querySelector('#result').textContent,
|
|
53
|
+
error: event.detail.error.message,
|
|
54
|
+
name: event.detail.error.name,
|
|
55
|
+
element: event.detail.element.id,
|
|
56
|
+
stale: event.detail.stale
|
|
57
|
+
}}).then(() => {{ window.nativeReportDone = true; }});
|
|
58
|
+
}});
|
|
59
|
+
}});
|
|
60
|
+
</script>
|
|
61
|
+
</body></html>"""
|
|
62
|
+
window = create_window(
|
|
63
|
+
"pywebview-htmx native smoke test", html, js_api=api,
|
|
64
|
+
start=False, hidden=True, focus=False,
|
|
65
|
+
)
|
|
66
|
+
failures: list[Exception] = []
|
|
67
|
+
verified = Event()
|
|
68
|
+
|
|
69
|
+
def check() -> None:
|
|
70
|
+
try:
|
|
71
|
+
if not window.events.loaded.wait(15):
|
|
72
|
+
raise TimeoutError("The native window did not load")
|
|
73
|
+
window.evaluate_js("document.querySelector('#greeting').click()")
|
|
74
|
+
if not api._done.wait(15):
|
|
75
|
+
raise TimeoutError("The native bridge did not finish")
|
|
76
|
+
deadline = monotonic() + 15
|
|
77
|
+
while not window.evaluate_js("window.nativeReportDone === true"):
|
|
78
|
+
if monotonic() >= deadline:
|
|
79
|
+
raise TimeoutError("The native report did not finish")
|
|
80
|
+
sleep(0.01)
|
|
81
|
+
assert api._params == {"name": name}, api._params
|
|
82
|
+
assert api._result == {
|
|
83
|
+
"text": f"Hello, {name}!",
|
|
84
|
+
"error": "Expected native smoke test failure",
|
|
85
|
+
"name": "ValueError",
|
|
86
|
+
"element": "failure",
|
|
87
|
+
"stale": False,
|
|
88
|
+
}, api._result
|
|
89
|
+
verified.set()
|
|
90
|
+
except Exception as error:
|
|
91
|
+
failures.append(error)
|
|
92
|
+
finally:
|
|
93
|
+
window.destroy()
|
|
94
|
+
|
|
95
|
+
webview.start(check)
|
|
96
|
+
if failures:
|
|
97
|
+
raise failures[0]
|
|
98
|
+
if not verified.is_set():
|
|
99
|
+
raise RuntimeError("The native smoke test did not complete")
|
|
100
|
+
print(f"Native smoke test passed ({webview.renderer})")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
main()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from playwright.sync_api import Page
|
|
5
|
+
|
|
6
|
+
from pywebview_htmx import inject_runtime, inject_theme
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.mark.parametrize(
|
|
10
|
+
"inactive_markup",
|
|
11
|
+
[
|
|
12
|
+
'<!-- <script data-pywebview-htmx="true"></script> -->',
|
|
13
|
+
'<template><script data-pywebview-htmx="true"></script></template>',
|
|
14
|
+
'<textarea><script data-pywebview-htmx="true"></script></textarea>',
|
|
15
|
+
'<!-- <style data-pywebview-theme="paper">example</style> -->',
|
|
16
|
+
'<template><style data-pywebview-theme="paper">example</style></template>',
|
|
17
|
+
],
|
|
18
|
+
)
|
|
19
|
+
def test_injected_assets_work_with_unicode_and_inactive_markup(
|
|
20
|
+
page: Page, inactive_markup: str,
|
|
21
|
+
) -> None:
|
|
22
|
+
html = (
|
|
23
|
+
"<html>\n<head><title>İstanbul</title></head>\n<body>"
|
|
24
|
+
f"{inactive_markup}"
|
|
25
|
+
'<button id="load" py-call="load" py-target="#result">İstanbul</button>'
|
|
26
|
+
'<div id="result"></div></body></html><!-- </body> </head> -->'
|
|
27
|
+
)
|
|
28
|
+
page_errors: list[str] = []
|
|
29
|
+
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
|
30
|
+
page.set_content(inject_runtime(inject_theme(html)))
|
|
31
|
+
page.evaluate(
|
|
32
|
+
"""() => {
|
|
33
|
+
window.pywebview = {api: {load: () => '<p>İstanbul loaded</p>'}};
|
|
34
|
+
}"""
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
page.locator("#load").click()
|
|
38
|
+
page.locator("#result").get_by_text("İstanbul loaded").wait_for()
|
|
39
|
+
assert page.title() == "İstanbul"
|
|
40
|
+
assert page.locator('body > script[data-pywebview-htmx="true"]').count() == 1
|
|
41
|
+
assert page.locator('head > style[data-pywebview-theme="aurora"]').count() == 1
|
|
42
|
+
assert not page_errors
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
from dataclasses import dataclass, field
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
6
7
|
from playwright.sync_api import Page
|
|
7
8
|
|
|
9
|
+
from pywebview_htmx import get_theme_css
|
|
10
|
+
|
|
8
11
|
|
|
9
12
|
@dataclass
|
|
10
13
|
class RuntimeBrowser:
|
|
@@ -359,6 +362,128 @@ def test_native_destructive_defaults_are_prevented_selectively(
|
|
|
359
362
|
assert page.evaluate("window.calls") == 7
|
|
360
363
|
|
|
361
364
|
|
|
365
|
+
@pytest.mark.parametrize("control_type", ["checkbox", "radio"])
|
|
366
|
+
def test_label_text_click_does_not_repeat_for_forwarded_control_click(
|
|
367
|
+
runtime_browser: RuntimeBrowser,
|
|
368
|
+
js_source: str,
|
|
369
|
+
control_type: str,
|
|
370
|
+
) -> None:
|
|
371
|
+
other_radio = (
|
|
372
|
+
'<input id="other" type="radio" name="choice">'
|
|
373
|
+
if control_type == "radio"
|
|
374
|
+
else ""
|
|
375
|
+
)
|
|
376
|
+
runtime_browser.load(
|
|
377
|
+
f'<label id="label" py-call="act" py-target="#result">'
|
|
378
|
+
f'<input id="check" type="{control_type}" name="choice">'
|
|
379
|
+
'<span id="label-text">Toggle</span></label>'
|
|
380
|
+
f'{other_radio}'
|
|
381
|
+
'<div id="result"></div>',
|
|
382
|
+
js_source,
|
|
383
|
+
)
|
|
384
|
+
page = runtime_browser.page
|
|
385
|
+
page.evaluate(
|
|
386
|
+
"""
|
|
387
|
+
() => {
|
|
388
|
+
window.calls = 0;
|
|
389
|
+
window.pywebview = {api: {act: () => { calls += 1; return ''; }}};
|
|
390
|
+
}
|
|
391
|
+
"""
|
|
392
|
+
)
|
|
393
|
+
runtime_browser.process()
|
|
394
|
+
|
|
395
|
+
page.locator("#label-text").click()
|
|
396
|
+
assert page.locator("#check").is_checked()
|
|
397
|
+
assert page.evaluate("window.calls") == 1
|
|
398
|
+
|
|
399
|
+
page.locator("#check").click()
|
|
400
|
+
assert page.locator("#check").is_checked() is (control_type == "radio")
|
|
401
|
+
assert page.evaluate("window.calls") == 2
|
|
402
|
+
|
|
403
|
+
if control_type == "radio":
|
|
404
|
+
page.locator("#other").click()
|
|
405
|
+
assert not page.locator("#check").is_checked()
|
|
406
|
+
|
|
407
|
+
page.locator("#check").focus()
|
|
408
|
+
page.keyboard.press("Space")
|
|
409
|
+
assert page.locator("#check").is_checked()
|
|
410
|
+
assert page.evaluate("window.calls") == 3
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def test_external_label_and_control_clear_forwarding_marker_in_same_task(
|
|
414
|
+
runtime_browser: RuntimeBrowser,
|
|
415
|
+
js_source: str,
|
|
416
|
+
) -> None:
|
|
417
|
+
runtime_browser.load(
|
|
418
|
+
'<label id="label" for="check" py-call="labelAction" '
|
|
419
|
+
'py-target="#result">Toggle</label>'
|
|
420
|
+
'<input id="check" type="checkbox" py-call="controlAction" '
|
|
421
|
+
'py-target="#result"><div id="result"></div>',
|
|
422
|
+
js_source,
|
|
423
|
+
)
|
|
424
|
+
page = runtime_browser.page
|
|
425
|
+
page.evaluate(
|
|
426
|
+
"""
|
|
427
|
+
() => {
|
|
428
|
+
window.calls = [];
|
|
429
|
+
window.pywebview = {api: {
|
|
430
|
+
labelAction: () => { calls.push('label'); return ''; },
|
|
431
|
+
controlAction: () => { calls.push('control'); return ''; },
|
|
432
|
+
}};
|
|
433
|
+
}
|
|
434
|
+
"""
|
|
435
|
+
)
|
|
436
|
+
runtime_browser.process()
|
|
437
|
+
|
|
438
|
+
page.evaluate(
|
|
439
|
+
"""() => {
|
|
440
|
+
document.querySelector('#label').click();
|
|
441
|
+
document.querySelector('#check').click();
|
|
442
|
+
}"""
|
|
443
|
+
)
|
|
444
|
+
assert not page.locator("#check").is_checked()
|
|
445
|
+
assert page.evaluate("window.calls") == ["label", "control"]
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def test_nested_checkbox_py_call_runs_once_per_activation(
|
|
449
|
+
runtime_browser: RuntimeBrowser,
|
|
450
|
+
js_source: str,
|
|
451
|
+
) -> None:
|
|
452
|
+
runtime_browser.load(
|
|
453
|
+
'<label id="label" py-call="labelAction" py-target="#result">'
|
|
454
|
+
'<input id="check" type="checkbox" py-call="checkboxAction" '
|
|
455
|
+
'py-target="#result"><span id="label-text">Toggle</span>'
|
|
456
|
+
'</label><div id="result"></div>',
|
|
457
|
+
js_source,
|
|
458
|
+
)
|
|
459
|
+
page = runtime_browser.page
|
|
460
|
+
page.evaluate(
|
|
461
|
+
"""
|
|
462
|
+
() => {
|
|
463
|
+
window.calls = [];
|
|
464
|
+
window.pywebview = {api: {
|
|
465
|
+
labelAction: () => { calls.push('label'); return ''; },
|
|
466
|
+
checkboxAction: () => { calls.push('checkbox'); return ''; },
|
|
467
|
+
}};
|
|
468
|
+
}
|
|
469
|
+
"""
|
|
470
|
+
)
|
|
471
|
+
runtime_browser.process()
|
|
472
|
+
|
|
473
|
+
page.locator("#label-text").click()
|
|
474
|
+
assert page.locator("#check").is_checked()
|
|
475
|
+
assert page.evaluate("window.calls") == ["label"]
|
|
476
|
+
|
|
477
|
+
page.locator("#check").click()
|
|
478
|
+
assert not page.locator("#check").is_checked()
|
|
479
|
+
assert page.evaluate("window.calls") == ["label", "checkbox"]
|
|
480
|
+
|
|
481
|
+
page.locator("#check").focus()
|
|
482
|
+
page.keyboard.press("Space")
|
|
483
|
+
assert page.locator("#check").is_checked()
|
|
484
|
+
assert page.evaluate("window.calls") == ["label", "checkbox", "checkbox"]
|
|
485
|
+
|
|
486
|
+
|
|
362
487
|
def test_form_serialization_repeats_submitter_and_skips_files(
|
|
363
488
|
runtime_browser: RuntimeBrowser,
|
|
364
489
|
js_source: str,
|
|
@@ -400,6 +525,106 @@ def test_form_serialization_repeats_submitter_and_skips_files(
|
|
|
400
525
|
}
|
|
401
526
|
|
|
402
527
|
|
|
528
|
+
def test_form_serialization_preserves_reserved_object_property_names(
|
|
529
|
+
runtime_browser: RuntimeBrowser,
|
|
530
|
+
js_source: str,
|
|
531
|
+
) -> None:
|
|
532
|
+
runtime_browser.load(
|
|
533
|
+
"""
|
|
534
|
+
<form id="form" py-call="save" py-trigger="submit"
|
|
535
|
+
data-py-params='{"fixed":"static","static":"yes"}'>
|
|
536
|
+
<input name="__proto__" value="one">
|
|
537
|
+
<input name="__proto__" value="two">
|
|
538
|
+
<input name="constructor" value="ctor">
|
|
539
|
+
<input name="toString" value="string">
|
|
540
|
+
<input name="fixed" value="form">
|
|
541
|
+
<button id="save">Save</button>
|
|
542
|
+
</form>
|
|
543
|
+
""",
|
|
544
|
+
js_source,
|
|
545
|
+
)
|
|
546
|
+
page = runtime_browser.page
|
|
547
|
+
page.evaluate(
|
|
548
|
+
"""
|
|
549
|
+
() => {
|
|
550
|
+
window.receivedParams = null;
|
|
551
|
+
window.pywebview = {api: {save: params => {
|
|
552
|
+
window.receivedParams = params;
|
|
553
|
+
return '';
|
|
554
|
+
}}};
|
|
555
|
+
}
|
|
556
|
+
"""
|
|
557
|
+
)
|
|
558
|
+
runtime_browser.process()
|
|
559
|
+
page.locator("#save").click()
|
|
560
|
+
page.wait_for_function("window.receivedParams !== null")
|
|
561
|
+
|
|
562
|
+
assert json.loads(page.evaluate("JSON.stringify(window.receivedParams)")) == {
|
|
563
|
+
"__proto__": ["one", "two"],
|
|
564
|
+
"constructor": "ctor",
|
|
565
|
+
"toString": "string",
|
|
566
|
+
"fixed": "form",
|
|
567
|
+
"static": "yes",
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@pytest.mark.parametrize(
|
|
572
|
+
("policy", "expected_calls", "expected_ignored"),
|
|
573
|
+
[("latest-wins", 2, 0), ("drop", 1, 1)],
|
|
574
|
+
)
|
|
575
|
+
def test_waiting_theme_keeps_controls_clickable_for_request_policy(
|
|
576
|
+
runtime_browser: RuntimeBrowser,
|
|
577
|
+
js_source: str,
|
|
578
|
+
policy: str,
|
|
579
|
+
expected_calls: int,
|
|
580
|
+
expected_ignored: int,
|
|
581
|
+
) -> None:
|
|
582
|
+
runtime_browser.load(
|
|
583
|
+
f'<button id="load" py-call="load" py-policy="{policy}" '
|
|
584
|
+
'py-target="#result">Load</button><div id="result"></div>',
|
|
585
|
+
js_source,
|
|
586
|
+
)
|
|
587
|
+
page = runtime_browser.page
|
|
588
|
+
page.add_style_tag(content=get_theme_css())
|
|
589
|
+
install_pending_bridge(page)
|
|
590
|
+
page.evaluate(
|
|
591
|
+
"""
|
|
592
|
+
() => {
|
|
593
|
+
window.ignoredCalls = 0;
|
|
594
|
+
document.addEventListener('py:ignored', () => ignoredCalls++);
|
|
595
|
+
}
|
|
596
|
+
"""
|
|
597
|
+
)
|
|
598
|
+
runtime_browser.process()
|
|
599
|
+
|
|
600
|
+
page.locator("#load").click()
|
|
601
|
+
assert page.locator("#load").evaluate(
|
|
602
|
+
"node => node.classList.contains('py-waiting')"
|
|
603
|
+
)
|
|
604
|
+
assert page.locator("#load").evaluate(
|
|
605
|
+
"node => getComputedStyle(node).pointerEvents"
|
|
606
|
+
) == "auto"
|
|
607
|
+
|
|
608
|
+
page.locator("#load").click()
|
|
609
|
+
assert page.evaluate("window.bridgeCalls.length") == expected_calls
|
|
610
|
+
assert page.evaluate("window.ignoredCalls") == expected_ignored
|
|
611
|
+
|
|
612
|
+
if policy == "latest-wins":
|
|
613
|
+
resolve_request(page, 1, "newest")
|
|
614
|
+
page.wait_for_function(
|
|
615
|
+
"document.querySelector('#result').textContent === 'newest'"
|
|
616
|
+
)
|
|
617
|
+
resolve_request(page, 0, "stale")
|
|
618
|
+
else:
|
|
619
|
+
resolve_request(page, 0, "accepted")
|
|
620
|
+
page.wait_for_function(
|
|
621
|
+
"!document.querySelector('#load').classList.contains('py-waiting')"
|
|
622
|
+
)
|
|
623
|
+
assert page.locator("#result").inner_text() == (
|
|
624
|
+
"newest" if policy == "latest-wins" else "accepted"
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
|
|
403
628
|
@pytest.mark.parametrize("swap_style", ["innerHTML", "outerHTML", "append"])
|
|
404
629
|
def test_real_swaps_parse_html_and_bind_inserted_controls(
|
|
405
630
|
runtime_browser: RuntimeBrowser,
|
|
@@ -128,3 +128,45 @@ def test_injection_preserves_unrelated_script_tags() -> None:
|
|
|
128
128
|
result = inject_runtime(html)
|
|
129
129
|
assert "<script>var x = 1;</script>" in result
|
|
130
130
|
assert result.count("<script>var x = 1;</script>") == 1
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.parametrize(
|
|
134
|
+
"inactive_markup",
|
|
135
|
+
[
|
|
136
|
+
'<!-- <script data-pywebview-htmx="true"></script> -->',
|
|
137
|
+
'<template><script data-pywebview-htmx="true"></script></template>',
|
|
138
|
+
'<textarea><script data-pywebview-htmx="true"></script></textarea>',
|
|
139
|
+
'<script>const example = \'<script data-pywebview-htmx="true">\';</script>',
|
|
140
|
+
'<script x-data-pywebview-htmx="true"></script>',
|
|
141
|
+
],
|
|
142
|
+
)
|
|
143
|
+
def test_injection_ignores_inactive_or_unrelated_markers(
|
|
144
|
+
inactive_markup: str,
|
|
145
|
+
) -> None:
|
|
146
|
+
html = f"<html><body>{inactive_markup}</body></html>"
|
|
147
|
+
result = inject_runtime(html)
|
|
148
|
+
injection = f"{SCRIPT_MARKER}{get_runtime_script()}</script>"
|
|
149
|
+
assert result == html.replace("</body>", f"{injection}</body>")
|
|
150
|
+
assert inject_runtime(result) == result
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_injection_preserves_unicode_and_ignores_comment_closing_tag() -> None:
|
|
154
|
+
html = (
|
|
155
|
+
"<html>\n<head><title>İstanbul</title></head>\n"
|
|
156
|
+
"<body><p>İstanbul</p></body></html><!-- </body> -->"
|
|
157
|
+
)
|
|
158
|
+
result = inject_runtime(html)
|
|
159
|
+
prefix, suffix = html.split("</body>", 1)
|
|
160
|
+
injection = f"{SCRIPT_MARKER}{get_runtime_script()}</script>"
|
|
161
|
+
assert result == f"{prefix}{injection}</body>{suffix}"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_injection_ignores_closing_body_text_in_attributes_and_raw_text() -> None:
|
|
165
|
+
html = (
|
|
166
|
+
'<html><body><div title="</body>">İ</div>'
|
|
167
|
+
'<textarea></body></textarea><template></body></template>'
|
|
168
|
+
'</body></html>'
|
|
169
|
+
)
|
|
170
|
+
result = inject_runtime(html)
|
|
171
|
+
injection = f"{SCRIPT_MARKER}{get_runtime_script()}</script>"
|
|
172
|
+
assert result.endswith(f"{injection}</body></html>")
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
3
5
|
from pywebview_htmx import encode_params_attr
|
|
4
6
|
|
|
5
7
|
|
|
@@ -17,3 +19,9 @@ def test_encode_params_attr_escapes_quotes_and_tags() -> None:
|
|
|
17
19
|
def test_encode_params_attr_is_exported_from_package() -> None:
|
|
18
20
|
encoded = encode_params_attr({"name": "pywebview-htmx"})
|
|
19
21
|
assert isinstance(encoded, str)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.mark.parametrize("value", [float("nan"), float("inf"), -float("inf")])
|
|
25
|
+
def test_encode_params_attr_rejects_non_finite_numbers(value: float) -> None:
|
|
26
|
+
with pytest.raises(ValueError, match="Out of range float values"):
|
|
27
|
+
encode_params_attr({"reading": value})
|
|
@@ -112,3 +112,44 @@ def test_create_window_theme_none_skips_theme_injection(
|
|
|
112
112
|
|
|
113
113
|
assert "data-pywebview-theme" not in captured["kwargs"]["html"]
|
|
114
114
|
assert 'data-pywebview-htmx="true"' in captured["kwargs"]["html"]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.mark.parametrize(
|
|
118
|
+
"inactive_markup",
|
|
119
|
+
[
|
|
120
|
+
'<!-- <style data-pywebview-theme="paper">example</style> -->',
|
|
121
|
+
'<template><style data-pywebview-theme="paper">example</style></template>',
|
|
122
|
+
'<textarea><style data-pywebview-theme="paper">example</style></textarea>',
|
|
123
|
+
'<style x-data-pywebview-theme="paper">body { color: red; }</style>',
|
|
124
|
+
],
|
|
125
|
+
)
|
|
126
|
+
def test_theme_ignores_inactive_or_unrelated_markers(inactive_markup: str) -> None:
|
|
127
|
+
html = f"<html><head>{inactive_markup}</head><body>İstanbul</body></html>"
|
|
128
|
+
result = inject_theme(html, "aurora")
|
|
129
|
+
css = get_theme_css("aurora")
|
|
130
|
+
injection = f'<style data-pywebview-theme="aurora">{css}</style>'
|
|
131
|
+
assert result == html.replace("</head>", f"{injection}</head>")
|
|
132
|
+
assert inject_theme(result, "paper").count(inactive_markup) == 1
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_theme_preserves_unicode_and_ignores_comment_closing_tag() -> None:
|
|
136
|
+
html = (
|
|
137
|
+
"<html>\n<head><title>İİstanbul</title></head>\n"
|
|
138
|
+
"<body>İstanbul</body></html><!-- </head> -->"
|
|
139
|
+
)
|
|
140
|
+
css = get_theme_css("aurora")
|
|
141
|
+
injection = f'<style data-pywebview-theme="aurora">{css}</style>'
|
|
142
|
+
assert inject_theme(html) == html.replace("</head>", f"{injection}</head>", 1)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_theme_replacement_preserves_surrounding_unicode_and_comments() -> None:
|
|
146
|
+
html = (
|
|
147
|
+
"<html>\n<head><title>İstanbul</title>"
|
|
148
|
+
'<style DATA-PYWEBVIEW-THEME=paper>body { color: red; }</STYLE >'
|
|
149
|
+
"</head><body>İstanbul</body></html><!-- </head> -->"
|
|
150
|
+
)
|
|
151
|
+
result = inject_theme(html, "aurora")
|
|
152
|
+
old_style = '<style DATA-PYWEBVIEW-THEME=paper>body { color: red; }</STYLE >'
|
|
153
|
+
css = get_theme_css("aurora")
|
|
154
|
+
new_style = f'<style data-pywebview-theme="aurora">{css}</style>'
|
|
155
|
+
assert result == html.replace(old_style, new_style)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pywebview_htmx-0.4.1 → pywebview_htmx-0.4.2}/src/pywebview_htmx/static/themes/cybermind.css
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|