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.
Files changed (103) hide show
  1. nicegui_bootstrap_components/__init__.py +7 -0
  2. nicegui_bootstrap_components/_base.py +590 -0
  3. nicegui_bootstrap_components/_host.py +259 -0
  4. nicegui_bootstrap_components/assets.py +371 -0
  5. nicegui_bootstrap_components/bs/__init__.py +267 -0
  6. nicegui_bootstrap_components/bs/_actions.py +232 -0
  7. nicegui_bootstrap_components/bs/_alert_spinner.py +423 -0
  8. nicegui_bootstrap_components/bs/_carousel.py +312 -0
  9. nicegui_bootstrap_components/bs/_choices.py +779 -0
  10. nicegui_bootstrap_components/bs/_content.py +1343 -0
  11. nicegui_bootstrap_components/bs/_forms.py +378 -0
  12. nicegui_bootstrap_components/bs/_inputs.py +560 -0
  13. nicegui_bootstrap_components/bs/_layout.py +341 -0
  14. nicegui_bootstrap_components/bs/_nav.py +650 -0
  15. nicegui_bootstrap_components/bs/_navbar.py +445 -0
  16. nicegui_bootstrap_components/bs/_overlay_root.py +73 -0
  17. nicegui_bootstrap_components/bs/_overlays.py +2633 -0
  18. nicegui_bootstrap_components/bs/_scope.py +28 -0
  19. nicegui_bootstrap_components/bs/_tabs.py +605 -0
  20. nicegui_bootstrap_components/bs/_toast.py +137 -0
  21. nicegui_bootstrap_components/bs/_tooltip_popover.py +537 -0
  22. nicegui_bootstrap_components/contracts/dbc-2.0.4.json +16811 -0
  23. nicegui_bootstrap_components/dbc/__init__.py +108 -0
  24. nicegui_bootstrap_components/icons.py +29 -0
  25. nicegui_bootstrap_components/persist.py +201 -0
  26. nicegui_bootstrap_components/py.typed +0 -0
  27. nicegui_bootstrap_components/static/dist/fonts/bootstrap-icons.woff +0 -0
  28. nicegui_bootstrap_components/static/dist/fonts/bootstrap-icons.woff2 +0 -0
  29. nicegui_bootstrap_components/static/dist/ngbs-bootstrap-unscoped.css +12047 -0
  30. nicegui_bootstrap_components/static/dist/ngbs-bootstrap.css +12057 -0
  31. nicegui_bootstrap_components/static/dist/ngbs-cerulean-unscoped.css +12338 -0
  32. nicegui_bootstrap_components/static/dist/ngbs-cerulean.css +12348 -0
  33. nicegui_bootstrap_components/static/dist/ngbs-compat-unscoped.css +12 -0
  34. nicegui_bootstrap_components/static/dist/ngbs-compat.css +12 -0
  35. nicegui_bootstrap_components/static/dist/ngbs-cosmo-unscoped.css +11825 -0
  36. nicegui_bootstrap_components/static/dist/ngbs-cosmo.css +11835 -0
  37. nicegui_bootstrap_components/static/dist/ngbs-cyborg-unscoped.css +12213 -0
  38. nicegui_bootstrap_components/static/dist/ngbs-cyborg.css +12223 -0
  39. nicegui_bootstrap_components/static/dist/ngbs-darkly-unscoped.css +12143 -0
  40. nicegui_bootstrap_components/static/dist/ngbs-darkly.css +12153 -0
  41. nicegui_bootstrap_components/static/dist/ngbs-flatly-unscoped.css +12129 -0
  42. nicegui_bootstrap_components/static/dist/ngbs-flatly.css +12139 -0
  43. nicegui_bootstrap_components/static/dist/ngbs-grid-unscoped.css +4084 -0
  44. nicegui_bootstrap_components/static/dist/ngbs-grid.css +4094 -0
  45. nicegui_bootstrap_components/static/dist/ngbs-host-unscoped.css +9 -0
  46. nicegui_bootstrap_components/static/dist/ngbs-host.css +9 -0
  47. nicegui_bootstrap_components/static/dist/ngbs-icons-bootstrap.css +5 -0
  48. nicegui_bootstrap_components/static/dist/ngbs-icons-fontawesome.css +9 -0
  49. nicegui_bootstrap_components/static/dist/ngbs-journal-unscoped.css +12112 -0
  50. nicegui_bootstrap_components/static/dist/ngbs-journal.css +12122 -0
  51. nicegui_bootstrap_components/static/dist/ngbs-litera-unscoped.css +12214 -0
  52. nicegui_bootstrap_components/static/dist/ngbs-litera.css +12224 -0
  53. nicegui_bootstrap_components/static/dist/ngbs-lumen-unscoped.css +12377 -0
  54. nicegui_bootstrap_components/static/dist/ngbs-lumen.css +12387 -0
  55. nicegui_bootstrap_components/static/dist/ngbs-lux-unscoped.css +12048 -0
  56. nicegui_bootstrap_components/static/dist/ngbs-lux.css +12058 -0
  57. nicegui_bootstrap_components/static/dist/ngbs-materia-unscoped.css +13197 -0
  58. nicegui_bootstrap_components/static/dist/ngbs-materia.css +13207 -0
  59. nicegui_bootstrap_components/static/dist/ngbs-minty-unscoped.css +12184 -0
  60. nicegui_bootstrap_components/static/dist/ngbs-minty.css +12194 -0
  61. nicegui_bootstrap_components/static/dist/ngbs-morph-unscoped.css +12829 -0
  62. nicegui_bootstrap_components/static/dist/ngbs-morph.css +12839 -0
  63. nicegui_bootstrap_components/static/dist/ngbs-pulse-unscoped.css +11901 -0
  64. nicegui_bootstrap_components/static/dist/ngbs-pulse.css +11911 -0
  65. nicegui_bootstrap_components/static/dist/ngbs-quartz-unscoped.css +12625 -0
  66. nicegui_bootstrap_components/static/dist/ngbs-quartz.css +12635 -0
  67. nicegui_bootstrap_components/static/dist/ngbs-sandstone-unscoped.css +12214 -0
  68. nicegui_bootstrap_components/static/dist/ngbs-sandstone.css +12224 -0
  69. nicegui_bootstrap_components/static/dist/ngbs-simplex-unscoped.css +12199 -0
  70. nicegui_bootstrap_components/static/dist/ngbs-simplex.css +12209 -0
  71. nicegui_bootstrap_components/static/dist/ngbs-sketchy-unscoped.css +12518 -0
  72. nicegui_bootstrap_components/static/dist/ngbs-sketchy.css +12528 -0
  73. nicegui_bootstrap_components/static/dist/ngbs-slate-unscoped.css +12527 -0
  74. nicegui_bootstrap_components/static/dist/ngbs-slate.css +12537 -0
  75. nicegui_bootstrap_components/static/dist/ngbs-solar-unscoped.css +12152 -0
  76. nicegui_bootstrap_components/static/dist/ngbs-solar.css +12162 -0
  77. nicegui_bootstrap_components/static/dist/ngbs-spacelab-unscoped.css +12279 -0
  78. nicegui_bootstrap_components/static/dist/ngbs-spacelab.css +12289 -0
  79. nicegui_bootstrap_components/static/dist/ngbs-superhero-unscoped.css +12223 -0
  80. nicegui_bootstrap_components/static/dist/ngbs-superhero.css +12233 -0
  81. nicegui_bootstrap_components/static/dist/ngbs-united-unscoped.css +12090 -0
  82. nicegui_bootstrap_components/static/dist/ngbs-united.css +12100 -0
  83. nicegui_bootstrap_components/static/dist/ngbs-vapor-unscoped.css +12556 -0
  84. nicegui_bootstrap_components/static/dist/ngbs-vapor.css +12566 -0
  85. nicegui_bootstrap_components/static/dist/ngbs-yeti-unscoped.css +12333 -0
  86. nicegui_bootstrap_components/static/dist/ngbs-yeti.css +12343 -0
  87. nicegui_bootstrap_components/static/dist/ngbs-zephyr-unscoped.css +12296 -0
  88. nicegui_bootstrap_components/static/dist/ngbs-zephyr.css +12306 -0
  89. nicegui_bootstrap_components/static/prototype-ngbs.css +541 -0
  90. nicegui_bootstrap_components/static/webfonts/fa-brands-400.ttf +0 -0
  91. nicegui_bootstrap_components/static/webfonts/fa-brands-400.woff2 +0 -0
  92. nicegui_bootstrap_components/static/webfonts/fa-regular-400.ttf +0 -0
  93. nicegui_bootstrap_components/static/webfonts/fa-regular-400.woff2 +0 -0
  94. nicegui_bootstrap_components/static/webfonts/fa-solid-900.ttf +0 -0
  95. nicegui_bootstrap_components/static/webfonts/fa-solid-900.woff2 +0 -0
  96. nicegui_bootstrap_components/static/webfonts/fa-v4compatibility.ttf +0 -0
  97. nicegui_bootstrap_components/static/webfonts/fa-v4compatibility.woff2 +0 -0
  98. nicegui_bootstrap_components/theme.py +238 -0
  99. nicegui_bootstrap_components/themes.py +131 -0
  100. nicegui_bootstrap_components-0.1.0.dist-info/METADATA +234 -0
  101. nicegui_bootstrap_components-0.1.0.dist-info/RECORD +103 -0
  102. nicegui_bootstrap_components-0.1.0.dist-info/WHEEL +4 -0
  103. 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