nicegui-bootstrap-components 0.1.0__py3-none-any.whl
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.
- nicegui_bootstrap_components/__init__.py +7 -0
- nicegui_bootstrap_components/_base.py +590 -0
- nicegui_bootstrap_components/_host.py +259 -0
- nicegui_bootstrap_components/assets.py +371 -0
- nicegui_bootstrap_components/bs/__init__.py +267 -0
- nicegui_bootstrap_components/bs/_actions.py +232 -0
- nicegui_bootstrap_components/bs/_alert_spinner.py +423 -0
- nicegui_bootstrap_components/bs/_carousel.py +312 -0
- nicegui_bootstrap_components/bs/_choices.py +779 -0
- nicegui_bootstrap_components/bs/_content.py +1343 -0
- nicegui_bootstrap_components/bs/_forms.py +378 -0
- nicegui_bootstrap_components/bs/_inputs.py +560 -0
- nicegui_bootstrap_components/bs/_layout.py +341 -0
- nicegui_bootstrap_components/bs/_nav.py +650 -0
- nicegui_bootstrap_components/bs/_navbar.py +445 -0
- nicegui_bootstrap_components/bs/_overlay_root.py +73 -0
- nicegui_bootstrap_components/bs/_overlays.py +2633 -0
- nicegui_bootstrap_components/bs/_scope.py +28 -0
- nicegui_bootstrap_components/bs/_tabs.py +605 -0
- nicegui_bootstrap_components/bs/_toast.py +137 -0
- nicegui_bootstrap_components/bs/_tooltip_popover.py +537 -0
- nicegui_bootstrap_components/contracts/dbc-2.0.4.json +16811 -0
- nicegui_bootstrap_components/dbc/__init__.py +108 -0
- nicegui_bootstrap_components/icons.py +29 -0
- nicegui_bootstrap_components/persist.py +201 -0
- nicegui_bootstrap_components/py.typed +0 -0
- nicegui_bootstrap_components/static/dist/fonts/bootstrap-icons.woff +0 -0
- nicegui_bootstrap_components/static/dist/fonts/bootstrap-icons.woff2 +0 -0
- nicegui_bootstrap_components/static/dist/ngbs-bootstrap-unscoped.css +12047 -0
- nicegui_bootstrap_components/static/dist/ngbs-bootstrap.css +12057 -0
- nicegui_bootstrap_components/static/dist/ngbs-cerulean-unscoped.css +12338 -0
- nicegui_bootstrap_components/static/dist/ngbs-cerulean.css +12348 -0
- nicegui_bootstrap_components/static/dist/ngbs-compat-unscoped.css +12 -0
- nicegui_bootstrap_components/static/dist/ngbs-compat.css +12 -0
- nicegui_bootstrap_components/static/dist/ngbs-cosmo-unscoped.css +11825 -0
- nicegui_bootstrap_components/static/dist/ngbs-cosmo.css +11835 -0
- nicegui_bootstrap_components/static/dist/ngbs-cyborg-unscoped.css +12213 -0
- nicegui_bootstrap_components/static/dist/ngbs-cyborg.css +12223 -0
- nicegui_bootstrap_components/static/dist/ngbs-darkly-unscoped.css +12143 -0
- nicegui_bootstrap_components/static/dist/ngbs-darkly.css +12153 -0
- nicegui_bootstrap_components/static/dist/ngbs-flatly-unscoped.css +12129 -0
- nicegui_bootstrap_components/static/dist/ngbs-flatly.css +12139 -0
- nicegui_bootstrap_components/static/dist/ngbs-grid-unscoped.css +4084 -0
- nicegui_bootstrap_components/static/dist/ngbs-grid.css +4094 -0
- nicegui_bootstrap_components/static/dist/ngbs-host-unscoped.css +9 -0
- nicegui_bootstrap_components/static/dist/ngbs-host.css +9 -0
- nicegui_bootstrap_components/static/dist/ngbs-icons-bootstrap.css +5 -0
- nicegui_bootstrap_components/static/dist/ngbs-icons-fontawesome.css +9 -0
- nicegui_bootstrap_components/static/dist/ngbs-journal-unscoped.css +12112 -0
- nicegui_bootstrap_components/static/dist/ngbs-journal.css +12122 -0
- nicegui_bootstrap_components/static/dist/ngbs-litera-unscoped.css +12214 -0
- nicegui_bootstrap_components/static/dist/ngbs-litera.css +12224 -0
- nicegui_bootstrap_components/static/dist/ngbs-lumen-unscoped.css +12377 -0
- nicegui_bootstrap_components/static/dist/ngbs-lumen.css +12387 -0
- nicegui_bootstrap_components/static/dist/ngbs-lux-unscoped.css +12048 -0
- nicegui_bootstrap_components/static/dist/ngbs-lux.css +12058 -0
- nicegui_bootstrap_components/static/dist/ngbs-materia-unscoped.css +13197 -0
- nicegui_bootstrap_components/static/dist/ngbs-materia.css +13207 -0
- nicegui_bootstrap_components/static/dist/ngbs-minty-unscoped.css +12184 -0
- nicegui_bootstrap_components/static/dist/ngbs-minty.css +12194 -0
- nicegui_bootstrap_components/static/dist/ngbs-morph-unscoped.css +12829 -0
- nicegui_bootstrap_components/static/dist/ngbs-morph.css +12839 -0
- nicegui_bootstrap_components/static/dist/ngbs-pulse-unscoped.css +11901 -0
- nicegui_bootstrap_components/static/dist/ngbs-pulse.css +11911 -0
- nicegui_bootstrap_components/static/dist/ngbs-quartz-unscoped.css +12625 -0
- nicegui_bootstrap_components/static/dist/ngbs-quartz.css +12635 -0
- nicegui_bootstrap_components/static/dist/ngbs-sandstone-unscoped.css +12214 -0
- nicegui_bootstrap_components/static/dist/ngbs-sandstone.css +12224 -0
- nicegui_bootstrap_components/static/dist/ngbs-simplex-unscoped.css +12199 -0
- nicegui_bootstrap_components/static/dist/ngbs-simplex.css +12209 -0
- nicegui_bootstrap_components/static/dist/ngbs-sketchy-unscoped.css +12518 -0
- nicegui_bootstrap_components/static/dist/ngbs-sketchy.css +12528 -0
- nicegui_bootstrap_components/static/dist/ngbs-slate-unscoped.css +12527 -0
- nicegui_bootstrap_components/static/dist/ngbs-slate.css +12537 -0
- nicegui_bootstrap_components/static/dist/ngbs-solar-unscoped.css +12152 -0
- nicegui_bootstrap_components/static/dist/ngbs-solar.css +12162 -0
- nicegui_bootstrap_components/static/dist/ngbs-spacelab-unscoped.css +12279 -0
- nicegui_bootstrap_components/static/dist/ngbs-spacelab.css +12289 -0
- nicegui_bootstrap_components/static/dist/ngbs-superhero-unscoped.css +12223 -0
- nicegui_bootstrap_components/static/dist/ngbs-superhero.css +12233 -0
- nicegui_bootstrap_components/static/dist/ngbs-united-unscoped.css +12090 -0
- nicegui_bootstrap_components/static/dist/ngbs-united.css +12100 -0
- nicegui_bootstrap_components/static/dist/ngbs-vapor-unscoped.css +12556 -0
- nicegui_bootstrap_components/static/dist/ngbs-vapor.css +12566 -0
- nicegui_bootstrap_components/static/dist/ngbs-yeti-unscoped.css +12333 -0
- nicegui_bootstrap_components/static/dist/ngbs-yeti.css +12343 -0
- nicegui_bootstrap_components/static/dist/ngbs-zephyr-unscoped.css +12296 -0
- nicegui_bootstrap_components/static/dist/ngbs-zephyr.css +12306 -0
- nicegui_bootstrap_components/static/prototype-ngbs.css +541 -0
- nicegui_bootstrap_components/static/webfonts/fa-brands-400.ttf +0 -0
- nicegui_bootstrap_components/static/webfonts/fa-brands-400.woff2 +0 -0
- nicegui_bootstrap_components/static/webfonts/fa-regular-400.ttf +0 -0
- nicegui_bootstrap_components/static/webfonts/fa-regular-400.woff2 +0 -0
- nicegui_bootstrap_components/static/webfonts/fa-solid-900.ttf +0 -0
- nicegui_bootstrap_components/static/webfonts/fa-solid-900.woff2 +0 -0
- nicegui_bootstrap_components/static/webfonts/fa-v4compatibility.ttf +0 -0
- nicegui_bootstrap_components/static/webfonts/fa-v4compatibility.woff2 +0 -0
- nicegui_bootstrap_components/theme.py +238 -0
- nicegui_bootstrap_components/themes.py +131 -0
- nicegui_bootstrap_components-0.1.0.dist-info/METADATA +234 -0
- nicegui_bootstrap_components-0.1.0.dist-info/RECORD +103 -0
- nicegui_bootstrap_components-0.1.0.dist-info/WHEEL +4 -0
- nicegui_bootstrap_components-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""NiceGUI host adapter.
|
|
2
|
+
|
|
3
|
+
Every NiceGUI import used by this library goes through this module so host
|
|
4
|
+
upgrades touch a single file. Other packages must import Element, client
|
|
5
|
+
access, asset injection, and move helpers from here — never from ``nicegui``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
from weakref import WeakKeyDictionary
|
|
13
|
+
|
|
14
|
+
from nicegui import core as ng_core
|
|
15
|
+
from nicegui import ui
|
|
16
|
+
from nicegui.element import Element as UiElement
|
|
17
|
+
from nicegui.elements.mixins.value_element import ValueElement as UiValueElement
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from nicegui import context as ng_context
|
|
21
|
+
except ImportError: # pragma: no cover - host layout varies by version
|
|
22
|
+
ng_context = None # type: ignore[assignment]
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Element",
|
|
26
|
+
"UiElement",
|
|
27
|
+
"ValueElement",
|
|
28
|
+
"add_css",
|
|
29
|
+
"add_head_html",
|
|
30
|
+
"add_static_files",
|
|
31
|
+
"client_store",
|
|
32
|
+
"create_text_span",
|
|
33
|
+
"get_app",
|
|
34
|
+
"get_client",
|
|
35
|
+
"get_parent",
|
|
36
|
+
"get_slot_parent",
|
|
37
|
+
"html_id",
|
|
38
|
+
"is_deleted",
|
|
39
|
+
"is_ui_element",
|
|
40
|
+
"move_element",
|
|
41
|
+
"run_javascript",
|
|
42
|
+
"set_element_text",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
_STORE_KEY = "nicegui_bootstrap_components"
|
|
46
|
+
_WEAK_STORES: WeakKeyDictionary[Any, dict[str, Any]] = WeakKeyDictionary()
|
|
47
|
+
_ID_STORES: dict[int, dict[str, Any]] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Element(UiElement):
|
|
51
|
+
"""``ui.element`` wrapper that honors ``_desired_tag`` set before init.
|
|
52
|
+
|
|
53
|
+
Custom Vue components still use NiceGUI class kwargs on subclasses::
|
|
54
|
+
|
|
55
|
+
class Foo(Element, component="foo.js", dependencies=[...], esm={...}):
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
Do not call ``register_importmap_override('vue', ...)``.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
tag: str = "div",
|
|
64
|
+
*,
|
|
65
|
+
_client: Any | None = None,
|
|
66
|
+
_parent_slot: Any | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
override = getattr(self, "_desired_tag", None)
|
|
69
|
+
if isinstance(override, str) and override:
|
|
70
|
+
tag = override
|
|
71
|
+
kwargs: dict[str, Any] = {}
|
|
72
|
+
if _client is not None:
|
|
73
|
+
kwargs["_client"] = _client
|
|
74
|
+
if _parent_slot is not None:
|
|
75
|
+
kwargs["_parent_slot"] = _parent_slot
|
|
76
|
+
super().__init__(tag, **kwargs)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ValueElement(UiValueElement):
|
|
80
|
+
"""ValueElement adapter that honors ``_desired_tag`` (tag is not forwarded by NiceGUI)."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
83
|
+
desired = getattr(self, "_desired_tag", None)
|
|
84
|
+
if not isinstance(desired, str) or not desired:
|
|
85
|
+
super().__init__(*args, **kwargs)
|
|
86
|
+
return
|
|
87
|
+
original = UiElement.__init__
|
|
88
|
+
|
|
89
|
+
def patched(instance: Any, tag: str = "div", *rest: Any, **kw: Any) -> None:
|
|
90
|
+
original(instance, desired, *rest, **kw)
|
|
91
|
+
|
|
92
|
+
UiElement.__init__ = patched # type: ignore[assignment]
|
|
93
|
+
try:
|
|
94
|
+
super().__init__(*args, **kwargs)
|
|
95
|
+
finally:
|
|
96
|
+
UiElement.__init__ = original # type: ignore[method-assign]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _ng_context() -> Any:
|
|
100
|
+
ctx = getattr(ui, "context", None)
|
|
101
|
+
if ctx is not None:
|
|
102
|
+
return ctx
|
|
103
|
+
if ng_context is not None:
|
|
104
|
+
return ng_context
|
|
105
|
+
raise RuntimeError("NiceGUI context is unavailable")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def add_head_html(
|
|
109
|
+
html: str,
|
|
110
|
+
*,
|
|
111
|
+
shared: bool = True,
|
|
112
|
+
client: Any | None = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Inject HTML into page ``<head>``. ``shared=True`` applies to all templates."""
|
|
115
|
+
if client is not None and shared:
|
|
116
|
+
raise ValueError("a client cannot be combined with shared head HTML")
|
|
117
|
+
if client is not None:
|
|
118
|
+
if getattr(client, "_response_built", False) and not getattr(client, "is_deleted", False):
|
|
119
|
+
client.run_javascript(
|
|
120
|
+
f'document.head.insertAdjacentHTML("beforeend", {json.dumps(html)});'
|
|
121
|
+
)
|
|
122
|
+
client._head_html += html + "\n"
|
|
123
|
+
return
|
|
124
|
+
try:
|
|
125
|
+
ui.add_head_html(html, shared=shared)
|
|
126
|
+
except TypeError:
|
|
127
|
+
ui.add_head_html(html)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def add_css(css: str, *, shared: bool = True) -> None:
|
|
131
|
+
"""Inject CSS via NiceGUI. Library styles must not use this unlayered path."""
|
|
132
|
+
try:
|
|
133
|
+
ui.add_css(css, shared=shared)
|
|
134
|
+
except TypeError:
|
|
135
|
+
ui.add_css(css)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def add_static_files(url_path: str, local_directory: str) -> None:
|
|
139
|
+
"""Serve a local directory at ``url_path`` on the NiceGUI app."""
|
|
140
|
+
ng_core.app.add_static_files(url_path, local_directory)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_app() -> Any:
|
|
144
|
+
"""Return the NiceGUI app singleton."""
|
|
145
|
+
return ng_core.app
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_client() -> Any:
|
|
149
|
+
"""Return the current NiceGUI client."""
|
|
150
|
+
return _ng_context().client
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def get_slot_parent() -> Any | None:
|
|
154
|
+
"""Return the parent element of the current slot, or ``None`` if unavailable."""
|
|
155
|
+
try:
|
|
156
|
+
slot = _ng_context().slot
|
|
157
|
+
except Exception:
|
|
158
|
+
return None
|
|
159
|
+
if slot is None:
|
|
160
|
+
return None
|
|
161
|
+
return getattr(slot, "parent", None)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def move_element(
|
|
165
|
+
element: Any,
|
|
166
|
+
target_container: Any | None = None,
|
|
167
|
+
target_index: int = -1,
|
|
168
|
+
target_slot: str = "",
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Reparent ``element`` using NiceGUI ``Element.move``."""
|
|
171
|
+
element.move(target_container, target_index, target_slot=target_slot or None)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def client_store(client: Any | None = None) -> dict[str, Any]:
|
|
175
|
+
"""Return the per-client dict used for public ids, overlay root, and theme."""
|
|
176
|
+
if client is None:
|
|
177
|
+
client = get_client()
|
|
178
|
+
extras = getattr(client, "extras", None)
|
|
179
|
+
if extras is None:
|
|
180
|
+
try:
|
|
181
|
+
extras = {}
|
|
182
|
+
client.extras = extras
|
|
183
|
+
except (AttributeError, TypeError):
|
|
184
|
+
extras = None
|
|
185
|
+
if isinstance(extras, dict):
|
|
186
|
+
bucket = extras.get(_STORE_KEY)
|
|
187
|
+
if bucket is None:
|
|
188
|
+
bucket = {}
|
|
189
|
+
extras[_STORE_KEY] = bucket
|
|
190
|
+
return bucket
|
|
191
|
+
try:
|
|
192
|
+
store = _WEAK_STORES.get(client)
|
|
193
|
+
if store is None:
|
|
194
|
+
store = {}
|
|
195
|
+
_WEAK_STORES[client] = store
|
|
196
|
+
return store
|
|
197
|
+
except TypeError:
|
|
198
|
+
store = _ID_STORES.get(id(client))
|
|
199
|
+
if store is None:
|
|
200
|
+
store = {}
|
|
201
|
+
_ID_STORES[id(client)] = store
|
|
202
|
+
return store
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def run_javascript(code: str, *, client: Any | None = None) -> Any:
|
|
206
|
+
"""Run JavaScript on the given or current client."""
|
|
207
|
+
target = client if client is not None else get_client()
|
|
208
|
+
return target.run_javascript(code)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def html_id(element: Any) -> str:
|
|
212
|
+
"""Return the DOM id NiceGUI assigned to ``element``."""
|
|
213
|
+
hid = getattr(element, "html_id", None)
|
|
214
|
+
if isinstance(hid, str) and hid:
|
|
215
|
+
return hid
|
|
216
|
+
return f"c{getattr(element, 'id', '')}"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def is_deleted(element: Any) -> bool:
|
|
220
|
+
"""Return True if the element has been deleted."""
|
|
221
|
+
if getattr(element, "_deleted", False):
|
|
222
|
+
return True
|
|
223
|
+
client = getattr(element, "client", None)
|
|
224
|
+
ident = getattr(element, "id", None)
|
|
225
|
+
elements = getattr(client, "elements", None) if client is not None else None
|
|
226
|
+
return bool(isinstance(elements, dict) and ident is not None and ident not in elements)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def is_ui_element(obj: object) -> bool:
|
|
230
|
+
"""Return True if ``obj`` is a NiceGUI element."""
|
|
231
|
+
return isinstance(obj, UiElement)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def get_parent(element: Any) -> Any | None:
|
|
235
|
+
"""Return the Python parent element, if any."""
|
|
236
|
+
slot = getattr(element, "parent_slot", None)
|
|
237
|
+
if slot is None:
|
|
238
|
+
return None
|
|
239
|
+
return getattr(slot, "parent", None)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def set_element_text(element: Any, text: str) -> None:
|
|
243
|
+
"""Set default-slot text without assigning ``innerHTML``.
|
|
244
|
+
|
|
245
|
+
Mirrors into ``props['text']`` so NiceGUI's testing helpers
|
|
246
|
+
(``ElementFilter``) can find elements by content: the filter reads
|
|
247
|
+
``props['text']``/``TextElement.text`` and a plain ``Element``'s
|
|
248
|
+
``_text`` is invisible to it.
|
|
249
|
+
"""
|
|
250
|
+
element._text = text
|
|
251
|
+
element._props["text"] = text
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def create_text_span(text: str) -> Element:
|
|
255
|
+
"""Create a ``span.ngbs-text`` carrying ``text``."""
|
|
256
|
+
span = Element("span")
|
|
257
|
+
span.classes("ngbs-text")
|
|
258
|
+
set_element_text(span, text)
|
|
259
|
+
return span
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Process-level CSS/JS registration (idempotent ``setup()``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
import re
|
|
7
|
+
from contextlib import ExitStack
|
|
8
|
+
from importlib.resources import as_file, files
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
from . import icons as _icons
|
|
13
|
+
from . import themes as _themes
|
|
14
|
+
from ._host import add_head_html, add_static_files
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AssetManager",
|
|
18
|
+
"StyleMode",
|
|
19
|
+
"get_asset_manager",
|
|
20
|
+
"get_mode",
|
|
21
|
+
"setup",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
STATIC_URL_PREFIX = "/_nicegui_bootstrap_components/static"
|
|
25
|
+
DIST_CSS_DIR = "dist"
|
|
26
|
+
PROTOTYPE_CSS_NAME = "prototype-ngbs.css"
|
|
27
|
+
_COMPAT_CSS_NAME = "ngbs-compat.css"
|
|
28
|
+
_COMPAT_CSS_NAME_UNSCOPED = "ngbs-compat-unscoped.css"
|
|
29
|
+
_HOST_CSS_NAME = "ngbs-host.css"
|
|
30
|
+
_HOST_CSS_NAME_UNSCOPED = "ngbs-host-unscoped.css"
|
|
31
|
+
_BOOTSTRAP_CDN = _themes.BOOTSTRAP.cdn
|
|
32
|
+
|
|
33
|
+
_FORBIDDEN_URL_CHARACTERS = frozenset("\"'{}\\;()<>@")
|
|
34
|
+
|
|
35
|
+
_MANAGER: AssetManager | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class StyleMode(str, enum.Enum):
|
|
39
|
+
"""CSS coexistence mode.
|
|
40
|
+
|
|
41
|
+
``UNSCOPED`` is Mode A (Bootstrap-first). ``MIXED`` is Mode B (scoped islands).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
UNSCOPED = "unscoped"
|
|
45
|
+
MIXED = "mixed"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_prototype_css() -> str:
|
|
49
|
+
try:
|
|
50
|
+
resource = files("nicegui_bootstrap_components").joinpath("static", PROTOTYPE_CSS_NAME) # type: ignore[call-arg]
|
|
51
|
+
return resource.read_text(encoding="utf-8")
|
|
52
|
+
except (OSError, ModuleNotFoundError, AttributeError, UnicodeError):
|
|
53
|
+
return "/* prototype-ngbs.css missing from package data */\n"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _validate_stylesheet_url(value: str) -> str:
|
|
57
|
+
"""Validate a stylesheet URL before it reaches CSS or JavaScript."""
|
|
58
|
+
if not isinstance(value, str):
|
|
59
|
+
raise TypeError(f"theme URL must be a string, got {type(value)!r}")
|
|
60
|
+
if not value or any(char.isspace() or char in _FORBIDDEN_URL_CHARACTERS for char in value):
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"theme URL must be a plain http(s) URL without whitespace or CSS delimiters"
|
|
63
|
+
)
|
|
64
|
+
parsed = urlsplit(value)
|
|
65
|
+
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
|
|
66
|
+
raise ValueError(f"theme URL must be a plain http(s) URL, got {value!r}")
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _css_string(value: str) -> str:
|
|
71
|
+
return (
|
|
72
|
+
value.replace("\\", "\\\\").replace('"', '\\"').replace("\r", "\\D ").replace("\n", "\\A ")
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _layered_imports(hrefs: list[str]) -> str:
|
|
77
|
+
imports = "".join(f'@import url("{_css_string(href)}") layer(overrides);' for href in hrefs)
|
|
78
|
+
return f"<style>{imports}</style>"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _layered_import(href: str) -> str:
|
|
82
|
+
return _layered_imports([href])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _validated_theme_cdn(theme: _themes.Theme) -> str:
|
|
86
|
+
for candidate in _themes._CATALOGUE:
|
|
87
|
+
if theme is candidate or theme == candidate:
|
|
88
|
+
return candidate.cdn
|
|
89
|
+
return _validate_stylesheet_url(theme.cdn)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _resolve_icons(icons: _icons.IconTheme | str | None) -> _icons.IconTheme | None:
|
|
93
|
+
if icons is None:
|
|
94
|
+
return None
|
|
95
|
+
candidates = (_icons.BOOTSTRAP, _icons.FONT_AWESOME)
|
|
96
|
+
if isinstance(icons, _icons.IconTheme):
|
|
97
|
+
for candidate in candidates:
|
|
98
|
+
if icons is candidate or icons == candidate:
|
|
99
|
+
return candidate
|
|
100
|
+
raise ValueError("icons must be bootstrap or fontawesome")
|
|
101
|
+
if isinstance(icons, str):
|
|
102
|
+
for candidate in candidates:
|
|
103
|
+
if icons in {candidate.name, candidate.bundled}:
|
|
104
|
+
return candidate
|
|
105
|
+
raise ValueError(f"unknown icon set {icons!r}; valid names: bootstrap, fontawesome")
|
|
106
|
+
raise TypeError(f"icons must be an icon theme, a name string, or None, got {type(icons)!r}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _validated_icon_cdn(icon: _icons.IconTheme) -> str:
|
|
110
|
+
for candidate in (_icons.BOOTSTRAP, _icons.FONT_AWESOME):
|
|
111
|
+
if icon is candidate or icon == candidate:
|
|
112
|
+
return candidate.cdn
|
|
113
|
+
return _validate_stylesheet_url(icon.cdn)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _unscoped_payload(css: str) -> str:
|
|
117
|
+
"""Derive the Mode A (Bootstrap-first) global stylesheet.
|
|
118
|
+
|
|
119
|
+
The prototype stylesheet is authored with an ``.ngbs`` scope prefix
|
|
120
|
+
(Mode B). Mode A applies the same rules to the real document:
|
|
121
|
+
strip the scope prefixes so Reboot targets ``:root``/``html``.
|
|
122
|
+
"""
|
|
123
|
+
out = css.replace(".ngbs {", ":root {")
|
|
124
|
+
out = out.replace(".ngbs,", ":root,")
|
|
125
|
+
out = out.replace(".ngbs[data-bs-theme=", "[data-bs-theme=")
|
|
126
|
+
out = out.replace(".ngbs *", "*")
|
|
127
|
+
out = out.replace(".ngbs ", "")
|
|
128
|
+
out = re.sub(r"\.ngbs(?![-\w])", ":root", out)
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class AssetManager:
|
|
133
|
+
"""Process-level defaults and idempotent layered CSS injection."""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
mode: StyleMode,
|
|
138
|
+
*,
|
|
139
|
+
cdn: bool = False,
|
|
140
|
+
theme: _themes.Theme | None = None,
|
|
141
|
+
icons: _icons.IconTheme | None = None,
|
|
142
|
+
color_mode: str = "auto",
|
|
143
|
+
follow_nicegui_dark: bool = True,
|
|
144
|
+
) -> None:
|
|
145
|
+
self.mode = mode
|
|
146
|
+
self.cdn = cdn
|
|
147
|
+
self.theme = theme if theme is not None else _themes.BOOTSTRAP
|
|
148
|
+
self.icons = icons
|
|
149
|
+
self.color_mode = color_mode
|
|
150
|
+
self.follow_nicegui_dark = follow_nicegui_dark
|
|
151
|
+
self._injected = False
|
|
152
|
+
self._stack = ExitStack()
|
|
153
|
+
self._static_registered = False
|
|
154
|
+
|
|
155
|
+
def close(self) -> None:
|
|
156
|
+
"""Release extracted package resources."""
|
|
157
|
+
self._stack.close()
|
|
158
|
+
|
|
159
|
+
def bundled_url(self, theme: _themes.Theme) -> str | None:
|
|
160
|
+
"""Register the package stylesheet directory and return a theme's URL."""
|
|
161
|
+
located = self._locate_bundled_theme(theme)
|
|
162
|
+
if located is None:
|
|
163
|
+
return None
|
|
164
|
+
rel, _resource = located
|
|
165
|
+
self._register_static()
|
|
166
|
+
return f"{STATIC_URL_PREFIX}/{rel}"
|
|
167
|
+
|
|
168
|
+
def _register_static(self) -> None:
|
|
169
|
+
if self._static_registered:
|
|
170
|
+
return
|
|
171
|
+
resource = files("nicegui_bootstrap_components").joinpath("static")
|
|
172
|
+
path = self._stack.enter_context(as_file(resource))
|
|
173
|
+
add_static_files(STATIC_URL_PREFIX, str(path))
|
|
174
|
+
self._static_registered = True
|
|
175
|
+
|
|
176
|
+
def _built_theme_path(self, theme: _themes.Theme) -> str | None:
|
|
177
|
+
if not theme.bundled:
|
|
178
|
+
return None
|
|
179
|
+
suffix = "" if self.mode is not StyleMode.UNSCOPED else "-unscoped"
|
|
180
|
+
return f"{DIST_CSS_DIR}/{theme.bundled}{suffix}.css"
|
|
181
|
+
|
|
182
|
+
def _built_icon_path(self, icon: _icons.IconTheme) -> str | None:
|
|
183
|
+
if not icon.bundled:
|
|
184
|
+
return None
|
|
185
|
+
return f"{DIST_CSS_DIR}/{icon.bundled}.css"
|
|
186
|
+
|
|
187
|
+
def _locate_bundled_theme(self, theme: _themes.Theme) -> tuple[str, Any] | None:
|
|
188
|
+
rel = self._built_theme_path(theme)
|
|
189
|
+
if rel is None:
|
|
190
|
+
return None
|
|
191
|
+
try:
|
|
192
|
+
resource = files("nicegui_bootstrap_components").joinpath("static", rel) # type: ignore[call-arg]
|
|
193
|
+
exists = resource.is_file()
|
|
194
|
+
except (OSError, ModuleNotFoundError, AttributeError, TypeError, ValueError) as exc:
|
|
195
|
+
raise FileNotFoundError(f"bundled stylesheet not found: {rel}") from exc
|
|
196
|
+
if not exists:
|
|
197
|
+
raise FileNotFoundError(f"bundled stylesheet not found: {rel}")
|
|
198
|
+
return rel, resource
|
|
199
|
+
|
|
200
|
+
def _optional_bundled_url(self, rel: str) -> str | None:
|
|
201
|
+
try:
|
|
202
|
+
resource = files("nicegui_bootstrap_components").joinpath("static", rel) # type: ignore[call-arg]
|
|
203
|
+
if not resource.is_file():
|
|
204
|
+
return None
|
|
205
|
+
self._register_static()
|
|
206
|
+
self._stack.enter_context(as_file(resource))
|
|
207
|
+
except (OSError, ModuleNotFoundError, AttributeError, TypeError, ValueError):
|
|
208
|
+
return None
|
|
209
|
+
return f"{STATIC_URL_PREFIX}/{rel}"
|
|
210
|
+
|
|
211
|
+
def _inject_optional_import(self, rel: str) -> bool:
|
|
212
|
+
href = self._optional_bundled_url(rel)
|
|
213
|
+
if href is None:
|
|
214
|
+
return False
|
|
215
|
+
add_head_html(_layered_import(href), shared=True)
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
def _inject_theme(self) -> bool:
|
|
219
|
+
"""Import the built stylesheet pair via the cascade layer. True if served as a file."""
|
|
220
|
+
located = self._locate_bundled_theme(self.theme)
|
|
221
|
+
if located is None:
|
|
222
|
+
return False
|
|
223
|
+
rel, resource = located
|
|
224
|
+
self._register_static()
|
|
225
|
+
self._stack.enter_context(as_file(resource))
|
|
226
|
+
href = f"{STATIC_URL_PREFIX}/{rel}"
|
|
227
|
+
add_head_html(_layered_import(href), shared=True)
|
|
228
|
+
compat_name = (
|
|
229
|
+
_COMPAT_CSS_NAME_UNSCOPED if self.mode is StyleMode.UNSCOPED else _COMPAT_CSS_NAME
|
|
230
|
+
)
|
|
231
|
+
compat_rel = f"{DIST_CSS_DIR}/{compat_name}"
|
|
232
|
+
self._inject_optional_import(compat_rel)
|
|
233
|
+
host_name = _HOST_CSS_NAME_UNSCOPED if self.mode is StyleMode.UNSCOPED else _HOST_CSS_NAME
|
|
234
|
+
self._inject_optional_import(f"{DIST_CSS_DIR}/{host_name}")
|
|
235
|
+
return True
|
|
236
|
+
|
|
237
|
+
def _inject_icons(self) -> None:
|
|
238
|
+
if self.icons is None:
|
|
239
|
+
return
|
|
240
|
+
if self.cdn:
|
|
241
|
+
add_head_html(_layered_import(_validated_icon_cdn(self.icons)), shared=True)
|
|
242
|
+
return
|
|
243
|
+
rel = self._built_icon_path(self.icons)
|
|
244
|
+
if rel is None or not self._inject_optional_import(rel):
|
|
245
|
+
raise FileNotFoundError(f"bundled icon stylesheet not found: {rel}")
|
|
246
|
+
|
|
247
|
+
def inject(self) -> None:
|
|
248
|
+
"""Inject library CSS into ``layer(overrides)``. Duplicate calls are no-ops."""
|
|
249
|
+
if self._injected:
|
|
250
|
+
return
|
|
251
|
+
if self.cdn:
|
|
252
|
+
imports = [_validated_theme_cdn(self.theme)]
|
|
253
|
+
host_name = (
|
|
254
|
+
_HOST_CSS_NAME_UNSCOPED if self.mode is StyleMode.UNSCOPED else _HOST_CSS_NAME
|
|
255
|
+
)
|
|
256
|
+
host_href = self._optional_bundled_url(f"{DIST_CSS_DIR}/{host_name}")
|
|
257
|
+
if host_href is not None:
|
|
258
|
+
imports.append(host_href)
|
|
259
|
+
add_head_html(_layered_imports(imports), shared=True)
|
|
260
|
+
self._inject_icons()
|
|
261
|
+
self._injected = True
|
|
262
|
+
return
|
|
263
|
+
if self._inject_theme():
|
|
264
|
+
self._inject_icons()
|
|
265
|
+
self._injected = True
|
|
266
|
+
return
|
|
267
|
+
injected_import = False
|
|
268
|
+
if self.mode is not StyleMode.UNSCOPED:
|
|
269
|
+
try:
|
|
270
|
+
resource = files("nicegui_bootstrap_components").joinpath(
|
|
271
|
+
"static", PROTOTYPE_CSS_NAME
|
|
272
|
+
) # type: ignore[call-arg]
|
|
273
|
+
path = self._stack.enter_context(as_file(resource))
|
|
274
|
+
add_static_files(STATIC_URL_PREFIX, str(path.parent))
|
|
275
|
+
href = f"{STATIC_URL_PREFIX}/{PROTOTYPE_CSS_NAME}"
|
|
276
|
+
add_head_html(_layered_import(href), shared=True)
|
|
277
|
+
injected_import = True
|
|
278
|
+
except (OSError, ModuleNotFoundError, AttributeError, TypeError, ValueError):
|
|
279
|
+
injected_import = False
|
|
280
|
+
if not injected_import:
|
|
281
|
+
css = _read_prototype_css()
|
|
282
|
+
if self.mode is StyleMode.UNSCOPED:
|
|
283
|
+
css = _unscoped_payload(css)
|
|
284
|
+
payload = css if "@layer overrides" in css else f"@layer overrides {{\n{css}\n}}"
|
|
285
|
+
add_head_html(f"<style>\n{payload}\n</style>", shared=True)
|
|
286
|
+
host_name = _HOST_CSS_NAME_UNSCOPED if self.mode is StyleMode.UNSCOPED else _HOST_CSS_NAME
|
|
287
|
+
self._inject_optional_import(f"{DIST_CSS_DIR}/{host_name}")
|
|
288
|
+
self._inject_icons()
|
|
289
|
+
self._injected = True
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def get_mode() -> StyleMode | None:
|
|
293
|
+
"""Return the process-wide style mode, or ``None`` if ``setup()`` was not called."""
|
|
294
|
+
if _MANAGER is None:
|
|
295
|
+
return None
|
|
296
|
+
return _MANAGER.mode
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def get_asset_manager() -> AssetManager | None:
|
|
300
|
+
"""Return the process-level manager, or ``None`` if ``setup()`` was not called."""
|
|
301
|
+
return _MANAGER
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def setup(
|
|
305
|
+
mode: StyleMode = StyleMode.MIXED,
|
|
306
|
+
*,
|
|
307
|
+
theme: _themes.Theme | str = _themes.BOOTSTRAP,
|
|
308
|
+
icons: Literal["bootstrap", "fontawesome"] | _icons.IconTheme | None = None,
|
|
309
|
+
color_mode: str | None = "auto",
|
|
310
|
+
cdn: bool = False,
|
|
311
|
+
follow_nicegui_dark: bool = True,
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Register library CSS once (process-wide).
|
|
314
|
+
|
|
315
|
+
Idempotent when called again with the same arguments. Raises ``ValueError``
|
|
316
|
+
on a conflicting mode or option, or when Mode B (scoped) is combined with
|
|
317
|
+
``cdn=True``. Does not bind to a client.
|
|
318
|
+
"""
|
|
319
|
+
global _MANAGER
|
|
320
|
+
if not isinstance(mode, StyleMode):
|
|
321
|
+
mode = StyleMode(mode)
|
|
322
|
+
if cdn and mode is not StyleMode.UNSCOPED:
|
|
323
|
+
raise ValueError("cdn=True requires mode=StyleMode.UNSCOPED (scoped builds are bundled)")
|
|
324
|
+
from .theme import _normalize_mode, _resolve_theme
|
|
325
|
+
|
|
326
|
+
normalized_color_mode = _normalize_mode(color_mode)
|
|
327
|
+
resolved_theme = _resolve_theme(theme)
|
|
328
|
+
resolved_icons = _resolve_icons(icons)
|
|
329
|
+
normalized_follow = bool(follow_nicegui_dark)
|
|
330
|
+
if _MANAGER is not None:
|
|
331
|
+
if _MANAGER.mode != mode:
|
|
332
|
+
raise ValueError(
|
|
333
|
+
f"setup() already called with mode={_MANAGER.mode!r}; cannot change to {mode!r}"
|
|
334
|
+
)
|
|
335
|
+
conflicts: list[str] = []
|
|
336
|
+
if _MANAGER.theme != resolved_theme:
|
|
337
|
+
conflicts.append("theme")
|
|
338
|
+
if _MANAGER.icons != resolved_icons:
|
|
339
|
+
conflicts.append("icons")
|
|
340
|
+
if _MANAGER.color_mode != normalized_color_mode:
|
|
341
|
+
conflicts.append("color_mode")
|
|
342
|
+
if _MANAGER.cdn != cdn:
|
|
343
|
+
conflicts.append("cdn")
|
|
344
|
+
if _MANAGER.follow_nicegui_dark != normalized_follow:
|
|
345
|
+
conflicts.append("follow_nicegui_dark")
|
|
346
|
+
if conflicts:
|
|
347
|
+
names = ", ".join(conflicts)
|
|
348
|
+
raise ValueError(f"setup() already called with conflicting argument(s): {names}")
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
manager = AssetManager(
|
|
352
|
+
mode,
|
|
353
|
+
cdn=cdn,
|
|
354
|
+
theme=resolved_theme,
|
|
355
|
+
icons=resolved_icons,
|
|
356
|
+
color_mode=normalized_color_mode,
|
|
357
|
+
follow_nicegui_dark=normalized_follow,
|
|
358
|
+
)
|
|
359
|
+
try:
|
|
360
|
+
manager.inject()
|
|
361
|
+
except Exception:
|
|
362
|
+
manager.close()
|
|
363
|
+
raise
|
|
364
|
+
_MANAGER = manager
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _reset_setup() -> None:
|
|
368
|
+
global _MANAGER
|
|
369
|
+
if _MANAGER is not None:
|
|
370
|
+
_MANAGER.close()
|
|
371
|
+
_MANAGER = None
|