diff --git a/docs/_quartodoc-core.yml b/docs/_quartodoc-core.yml index 4d0340194..834d4ae72 100644 --- a/docs/_quartodoc-core.yml +++ b/docs/_quartodoc-core.yml @@ -22,6 +22,7 @@ quartodoc: - ui.page_bootstrap - ui.page_auto - ui.page_output + - ui.theme - title: UI Layouts desc: Control the layout of multiple UI components. contents: diff --git a/docs/_quartodoc-express.yml b/docs/_quartodoc-express.yml index 7dfd81413..8ab7c32c5 100644 --- a/docs/_quartodoc-express.yml +++ b/docs/_quartodoc-express.yml @@ -47,6 +47,7 @@ quartodoc: desc: Tools for creating, arranging, and styling UI components. contents: - express.ui.page_opts + - express.ui.theme - express.ui.sidebar - express.ui.layout_columns - express.ui.layout_column_wrap diff --git a/shiny/api-examples/theme/app-core.py b/shiny/api-examples/theme/app-core.py new file mode 100644 index 000000000..7230bdc78 --- /dev/null +++ b/shiny/api-examples/theme/app-core.py @@ -0,0 +1,30 @@ +from pathlib import Path + +from shiny import App, render, ui + +app_ui = ui.page_sidebar( + ui.sidebar( + ui.input_numeric("n", "N", min=0, max=100, value=20), + title="Parameters", + ), + ui.h2("Output"), + ui.output_text_verbatim("txt"), + ui.markdown( + """ +**AI-generated filler text.** In the world of exotic fruits, the durian stands out with its spiky exterior and strong odor. Despite its divisive smell, many people are drawn to its rich, creamy texture and unique flavor profile. This tropical fruit is often referred to as the "king of fruits" in various Southeast Asian countries. + +Durians are known for their large size and thorn-covered husk, which requires careful handling. The flesh inside can vary in color from pale yellow to deep orange, with a custard-like consistency that melts in your mouth. Some describe its taste as a mix of sweet, savory, and creamy, while others find it overpowering and pungent. +""" + ), + title="Theme Example", + theme=ui.theme(Path(__file__).parent / "theme.css", replace="none"), +) + + +def server(input, output, session): + @render.text + def txt(): + return f"n*2 is {input.n() * 2}" + + +app = App(app_ui, server) diff --git a/shiny/api-examples/theme/app-express.py b/shiny/api-examples/theme/app-express.py new file mode 100644 index 000000000..2af24aa5e --- /dev/null +++ b/shiny/api-examples/theme/app-express.py @@ -0,0 +1,27 @@ +from pathlib import Path + +from shiny.express import input, render, ui + +ui.page_opts( + title="Theme Example", + theme=ui.theme(Path(__file__).parent / "theme.css", replace="none"), +) + +with ui.sidebar(title="Parameters"): + ui.input_numeric("n", "N", min=0, max=100, value=20) + +ui.h2("Output") + + +@render.code +def txt(): + return f"n*2 is {input.n() * 2}" + + +ui.markdown( + """ +**AI-generated filler text.** In the world of exotic fruits, the durian stands out with its spiky exterior and strong odor. Despite its divisive smell, many people are drawn to its rich, creamy texture and unique flavor profile. This tropical fruit is often referred to as the "king of fruits" in various Southeast Asian countries. + +Durians are known for their large size and thorn-covered husk, which requires careful handling. The flesh inside can vary in color from pale yellow to deep orange, with a custard-like consistency that melts in your mouth. Some describe its taste as a mix of sweet, savory, and creamy, while others find it overpowering and pungent. +""" +) diff --git a/shiny/api-examples/theme/theme.css b/shiny/api-examples/theme/theme.css new file mode 100644 index 000000000..494231530 --- /dev/null +++ b/shiny/api-examples/theme/theme.css @@ -0,0 +1,39 @@ +@font-face { + font-family: InterVariable; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url("https://rsms.me/inter/font-files/InterVariable.woff2?v=4.0") + format("woff2"); +} +@font-face { + font-family: InterVariable; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: url("https://rsms.me/inter/font-files/InterVariable-Italic.woff2?v=4.0") + format("woff2"); +} + +:root { + font-size: 20px; + --bs-body-font-family: "InterVariable"; + --bs-heading-color: var(--bs-pink); + + --pink-rgb: 191, 0, 127; + + --bslib-sidebar-bg: rgba(var(--pink-rgb), 0.05); + --bslib-sidebar-fg: var(--bs-pink); + --bslib-sidebar-toggle-bg: rgba(var(--pink-rgb), 0.1); + --bs-border-color-translucent: rgba(var(--pink-rgb), 0.33); + --bs-border-color: var(--bs-border-color-translucent); +} + +.bslib-page-sidebar { + --bslib-page-sidebar-title-bg: var(--bs-pink); + --bslib-page-sidebar-title-color: var(--bs-white); +} + +pre { + background: rgba(var(--pink-rgb), 0.05); +} diff --git a/shiny/experimental/ui/_deprecated.py b/shiny/experimental/ui/_deprecated.py index e6c98b051..28a1a2d17 100644 --- a/shiny/experimental/ui/_deprecated.py +++ b/shiny/experimental/ui/_deprecated.py @@ -1434,6 +1434,7 @@ def page_sidebar( fillable_mobile=fillable_mobile, window_title=window_title, lang=lang, + theme=MISSING, **kwargs, ) @@ -1510,5 +1511,6 @@ def page_fillable( fillable_mobile=fillable_mobile, title=title, lang=lang, + theme=MISSING, **kwargs, ) diff --git a/shiny/express/ui/__init__.py b/shiny/express/ui/__init__.py index 14853187f..851c8dcd8 100644 --- a/shiny/express/ui/__init__.py +++ b/shiny/express/ui/__init__.py @@ -104,6 +104,7 @@ notification_remove, nav_spacer, Progress, + theme, value_box_theme, js_eval, ) @@ -248,6 +249,7 @@ "notification_remove", "nav_spacer", "Progress", + "theme", "value_box_theme", # Imports from ._cm_components "sidebar", diff --git a/shiny/express/ui/_page.py b/shiny/express/ui/_page.py index 738d25356..e8e320cf2 100644 --- a/shiny/express/ui/_page.py +++ b/shiny/express/ui/_page.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import Callable from htmltools import Tag @@ -7,6 +8,7 @@ from ... import ui from ..._docstring import no_example from ...types import MISSING, MISSING_TYPE +from ...ui._theme import Theme from .._recall_context import RecallContextManager from .._run import get_top_level_recall_context_manager @@ -22,6 +24,7 @@ def page_opts( *, title: str | MISSING_TYPE = MISSING, window_title: str | MISSING_TYPE = MISSING, + theme: str | Path | Theme | MISSING_TYPE = MISSING, lang: str | MISSING_TYPE = MISSING, page_fn: Callable[..., Tag] | None | MISSING_TYPE = MISSING, fillable: bool | MISSING_TYPE = MISSING, @@ -39,6 +42,18 @@ def page_opts( window_title The browser window title. If no value is provided, this will use the value of ``title``. + theme + A path to a CSS file that will replace the Bootstrap CSS bundled with a Shiny + app, or a theme create width :func:`~shiny.ui.theme`. If no value is provided, + the default Shiny Bootstrap theme is used. + + CSS file: If a string or :class:`~pathlib.Path` is provided, it is + interpreted as a path to a CSS file that should completely replace + `bootstrap.min.css`. The CSS file is included using + :func:`~shiny.ui.include_css`. + + Theme object: For more control over the theme and how it is applied, use + :func:`~shiny.ui.theme`. lang ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This will be used as the lang in the ```` tag, as in ````. The @@ -71,6 +86,8 @@ def page_opts( cm.kwargs["title"] = title if not isinstance(window_title, MISSING_TYPE): cm.kwargs["window_title"] = window_title + if not isinstance(theme, MISSING_TYPE): + cm.kwargs["theme"] = theme if not isinstance(lang, MISSING_TYPE): cm.kwargs["lang"] = lang if not isinstance(page_fn, MISSING_TYPE): diff --git a/shiny/ui/__init__.py b/shiny/ui/__init__.py index 7d2a47487..f64721626 100644 --- a/shiny/ui/__init__.py +++ b/shiny/ui/__init__.py @@ -155,6 +155,7 @@ sidebar, update_sidebar, ) +from ._theme import theme from ._tooltip import tooltip from ._utils import js_eval from ._valuebox import ( @@ -324,6 +325,8 @@ "showcase_top_right", "ValueBoxTheme", "ShowcaseLayout", + # _theme + "theme", # _tooltip "tooltip", # _progress diff --git a/shiny/ui/_html_deps_external.py b/shiny/ui/_html_deps_external.py index c4f1ea8f5..4eb1f7edf 100644 --- a/shiny/ui/_html_deps_external.py +++ b/shiny/ui/_html_deps_external.py @@ -1,10 +1,14 @@ from __future__ import annotations -from htmltools import HTML, HTMLDependency +from pathlib import Path + +from htmltools import HTML, HTMLDependency, Tag, TagList from .._versions import bootstrap as bootstrap_version from .._versions import shiny_html_deps from ..html_dependencies import jquery_deps +from ..types import MISSING, MISSING_TYPE +from ._theme import Theme """ HTML dependencies for external dependencies Bootstrap, ionrangeslider, datepicker, selectize, and jQuery UI. @@ -16,17 +20,38 @@ """ -def bootstrap_deps() -> list[HTMLDependency]: +def bootstrap_deps( + theme: str | Path | Theme | MISSING_TYPE = MISSING, +) -> list[HTMLDependency | Tag | TagList]: + if isinstance(theme, (str, Path)): + theme = Theme(theme) + + if isinstance(theme, Theme): + theme.check_compatibility(bootstrap_version) + + theme_deps = theme.theme + replace = theme.replace + if replace == "all": + return [jquery_deps(), theme_deps] + elif theme is not MISSING: + raise TypeError( + f"Invalid type for `theme`: {type(theme)}. " + + "Must be a string, Path, or Theme object created with `shiny.ui.theme()`." + ) + else: + theme_deps = None + replace = "none" + dep = HTMLDependency( name="bootstrap", version=bootstrap_version, source={"package": "shiny", "subdir": "www/shared/bootstrap/"}, script={"src": "bootstrap.bundle.min.js"}, - stylesheet={"href": "bootstrap.min.css"}, + stylesheet={"href": "bootstrap.min.css"} if replace != "css" else None, meta={"name": "viewport", "content": "width=device-width, initial-scale=1"}, ) - deps = [jquery_deps(), dep] - return deps + deps = [jquery_deps(), dep, theme_deps] + return [d for d in deps if d is not None] def ionrangeslider_deps() -> list[HTMLDependency]: diff --git a/shiny/ui/_page.py b/shiny/ui/_page.py index f6be05e0c..7512dd679 100644 --- a/shiny/ui/_page.py +++ b/shiny/ui/_page.py @@ -12,6 +12,7 @@ ) from copy import copy +from pathlib import Path from typing import Any, Callable, Literal, Optional, Sequence, cast from htmltools import ( @@ -37,6 +38,7 @@ from ._navs import NavMenu, NavPanel, navset_bar from ._sidebar import Sidebar, SidebarOpen, layout_sidebar from ._tag import consolidate_attrs +from ._theme import Theme from ._utils import get_window_title from .css import CssUnit, as_css_padding, as_css_unit from .fill._fill import as_fillable_container @@ -53,6 +55,7 @@ def page_sidebar( fillable_mobile: bool = False, window_title: str | MISSING_TYPE = MISSING, lang: Optional[str] = None, + theme: str | Path | Theme | MISSING_TYPE = MISSING, **kwargs: TagAttrValue, ) -> Tag: """ @@ -74,6 +77,18 @@ def page_sidebar( window_title The browser's window title (defaults to the host URL of the page). Can also be set as a side effect via :func:`~shiny.ui.panel_title`. + theme + A path to a CSS file that will replace the Bootstrap CSS bundled with a Shiny + app, or a theme create width :func:`~shiny.ui.theme`. If no value is provided, + the default Shiny Bootstrap theme is used. + + CSS file: If a string or :class:`~pathlib.Path` is provided, it is + interpreted as a path to a CSS file that should completely replace + `bootstrap.min.css`. The CSS file is included using + :func:`~shiny.ui.include_css`. + + Theme object: For more control over the theme and how it is applied, use + :func:`~shiny.ui.theme`. lang ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This will be used as the lang in the ```` tag, as in ````. The @@ -123,6 +138,7 @@ def page_sidebar( get_window_title(title, window_title=window_title), padding=0, gap=0, + theme=theme, lang=lang, fillable_mobile=fillable_mobile, ) @@ -150,6 +166,7 @@ def page_navbar( collapsible: bool = True, fluid: bool = True, window_title: str | MISSING_TYPE = MISSING, + theme: str | Path | Theme | MISSING_TYPE = MISSING, lang: Optional[str] = None, ) -> Tag: """ @@ -196,6 +213,18 @@ def page_navbar( window_title The browser's window title (defaults to the host URL of the page). Can also be set as a side effect via :func:`~shiny.ui.panel_title`. + theme + A path to a CSS file that will replace the Bootstrap CSS bundled with a Shiny + app, or a theme create width :func:`~shiny.ui.theme`. If no value is provided, + the default Shiny Bootstrap theme is used. + + CSS file: If a string or :class:`~pathlib.Path` is provided, it is + interpreted as a path to a CSS file that should completely replace + `bootstrap.min.css`. The CSS file is included using + :func:`~shiny.ui.include_css`. + + Theme object: For more control over the theme and how it is applied, use + :func:`~shiny.ui.theme`. lang ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This will be used as the lang in the ```` tag, as in ````. The @@ -264,6 +293,7 @@ def page_navbar( if fillable is False and sidebar is None: return page_bootstrap( *page_args, + theme=theme, **page_kwargs, ) @@ -273,6 +303,7 @@ def page_navbar( fillable_mobile=fillable_mobile, padding=0, gap=0, + theme=theme, **page_kwargs, ) @@ -284,6 +315,7 @@ def page_fillable( gap: Optional[CssUnit] = None, fillable_mobile: bool = False, title: Optional[str] = None, + theme: str | Path | Theme | MISSING_TYPE = MISSING, lang: Optional[str] = None, **kwargs: TagAttrValue, ) -> Tag: @@ -306,6 +338,18 @@ def page_fillable( title The browser window title (defaults to the host URL of the page). Can also be set as a side effect via :func:`~shiny.ui.panel_title`. + theme + A path to a CSS file that will replace the Bootstrap CSS bundled with a Shiny + app, or a theme create width :func:`~shiny.ui.theme`. If no value is provided, + the default Shiny Bootstrap theme is used. + + CSS file: If a string or :class:`~pathlib.Path` is provided, it is + interpreted as a path to a CSS file that should completely replace + `bootstrap.min.css`. The CSS file is included using + :func:`~shiny.ui.include_css`. + + Theme object: For more control over the theme and how it is applied, use + :func:`~shiny.ui.theme`. lang ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This will be used as the lang in the ```` tag, as in ````. The @@ -333,6 +377,7 @@ def page_fillable( *children, components_dependencies(), title=title, + theme=theme, lang=lang, ) @@ -350,6 +395,7 @@ def page_fillable( def page_fluid( *args: TagChild | TagAttrs, title: Optional[str] = None, + theme: str | Path | Theme | MISSING_TYPE = MISSING, lang: Optional[str] = None, **kwargs: str, ) -> Tag: @@ -363,6 +409,18 @@ def page_fluid( title The browser window title (defaults to the host URL of the page). Can also be set as a side effect via :func:`~shiny.ui.panel_title`. + theme + A path to a CSS file that will replace the Bootstrap CSS bundled with a Shiny + app, or a theme create width :func:`~shiny.ui.theme`. If no value is provided, + the default Shiny Bootstrap theme is used. + + CSS file: If a string or :class:`~pathlib.Path` is provided, it is + interpreted as a path to a CSS file that should completely replace + `bootstrap.min.css`. The CSS file is included using + :func:`~shiny.ui.include_css`. + + Theme object: For more control over the theme and how it is applied, use + :func:`~shiny.ui.theme`. lang ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This will be used as the lang in the ```` tag, as in ````. The @@ -383,7 +441,10 @@ def page_fluid( """ return page_bootstrap( - div({"class": "container-fluid"}, *args, **kwargs), title=title, lang=lang + div({"class": "container-fluid"}, *args, **kwargs), + title=title, + theme=theme, + lang=lang, ) @@ -391,6 +452,7 @@ def page_fluid( def page_fixed( *args: TagChild | TagAttrs, title: Optional[str] = None, + theme: str | Path | Theme | MISSING_TYPE = MISSING, lang: Optional[str] = None, **kwargs: str, ) -> Tag: @@ -404,6 +466,18 @@ def page_fixed( title The browser window title (defaults to the host URL of the page). Can also be set as a side effect via :func:`~shiny.ui.panel_title`. + theme + A path to a CSS file that will replace the Bootstrap CSS bundled with a Shiny + app, or a theme create width :func:`~shiny.ui.theme`. If no value is provided, + the default Shiny Bootstrap theme is used. + + CSS file: If a string or :class:`~pathlib.Path` is provided, it is + interpreted as a path to a CSS file that should completely replace + `bootstrap.min.css`. The CSS file is included using + :func:`~shiny.ui.include_css`. + + Theme object: For more control over the theme and how it is applied, use + :func:`~shiny.ui.theme`. lang ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This will be used as the lang in the ```` tag, as in ````. The @@ -424,7 +498,10 @@ def page_fixed( """ return page_bootstrap( - div({"class": "container"}, *args, **kwargs), title=title, lang=lang + div({"class": "container"}, *args, **kwargs), + title=title, + theme=theme, + lang=lang, ) @@ -433,6 +510,7 @@ def page_fixed( def page_bootstrap( *args: TagChild | TagAttrs, title: Optional[str] = None, + theme: str | Path | Theme | MISSING_TYPE = MISSING, lang: Optional[str] = None, **kwargs: TagAttrValue, ) -> Tag: @@ -446,6 +524,18 @@ def page_bootstrap( title The browser window title (defaults to the host URL of the page). Can also be set as a side effect via :func:`~shiny.ui.panel_title`. + theme + A path to a CSS file that will replace the Bootstrap CSS bundled with a Shiny + app, or a theme create width :func:`~shiny.ui.theme`. If no value is provided, + the default Shiny Bootstrap theme is used. + + CSS file: If a string or :class:`~pathlib.Path` is provided, it is + interpreted as a path to a CSS file that should completely replace + `bootstrap.min.css`. The CSS file is included using + :func:`~shiny.ui.include_css`. + + Theme object: For more control over the theme and how it is applied, use + :func:`~shiny.ui.theme`. lang ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This will be used as the lang in the ```` tag, as in ````. The @@ -466,7 +556,7 @@ def page_bootstrap( head = tags.title(title) if title else None return tags.html( tags.head(head), - tags.body(*bootstrap_deps(), *args, **kwargs), + tags.body(*bootstrap_deps(theme), *args, **kwargs), lang=lang, ) @@ -476,6 +566,7 @@ def page_auto( *args: TagChild | TagAttrs, title: str | MISSING_TYPE = MISSING, window_title: str | MISSING_TYPE = MISSING, + theme: str | Path | Theme | MISSING_TYPE = MISSING, lang: str | MISSING_TYPE = MISSING, fillable: bool | MISSING_TYPE = MISSING, full_width: bool = False, @@ -501,6 +592,18 @@ def page_auto( window_title The browser window title. If no value is provided, this will use the value of ``title``. + theme + A path to a CSS file that will replace the Bootstrap CSS bundled with a Shiny + app, or a theme create width :func:`~shiny.ui.theme`. If no value is provided, + the default Shiny Bootstrap theme is used. + + CSS file: If a string or :class:`~pathlib.Path` is provided, it is + interpreted as a path to a CSS file that should completely replace + `bootstrap.min.css`. The CSS file is included using + :func:`~shiny.ui.include_css`. + + Theme object: For more control over the theme and how it is applied, use + :func:`~shiny.ui.theme`. lang ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This will be used as the lang in the ```` tag, as in ````. The @@ -533,6 +636,8 @@ def page_auto( kwargs["window_title"] = window_title if not isinstance(lang, MISSING_TYPE): kwargs["lang"] = lang + if not isinstance(theme, MISSING_TYPE): + kwargs["theme"] = theme # Presence of a top-level nav items and/or sidebar determines the page function navs = [x for x in args if isinstance(x, (NavPanel, NavMenu))] diff --git a/shiny/ui/_theme.py b/shiny/ui/_theme.py new file mode 100644 index 000000000..b21dfd7f4 --- /dev/null +++ b/shiny/ui/_theme.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Literal, Optional +from warnings import warn + +from htmltools import HTMLDependency, Tag, Tagifiable, TagList, head_content +from packaging.version import Version + +from .._docstring import add_example +from .._versions import bootstrap as BOOTSTRAP_VERSION +from ._include_helpers import include_css + +__all__ = ("theme",) + + +class Theme: + """ + Provide a theme for styling Shiny via the `theme` argument of page functions. + + Specify a theme that customizes the appearance of a Shiny application by replacing + the default Bootstrap theme, by replacing Bootstrap altogether, or by layering on + top of the default Bootstrap theme. + + Parameters + ---------- + theme + The theme to apply. This can be a path to a CSS file, a :class:`~htmltools.Tag` + or :class:`~htmltools.Tagifiable` object, or an + :class:`~htmltools.HTMLDependency`. When `theme` is a string or + :class:`~pathlib.Path`, it is interpreted as a path to a CSS file that will be + added to the app using :func:`~shiny.ui.include_css`. + name + An optional name for the theme. + version + An optional version of the theme. + bs_version + The Bootstrap version with which the theme is compatible. When the theme is + used, an error will be raised if the major version of Shiny's built-in Bootstrap + version and does not match the theme's major Bootstrap version. Otherwise, a + warning is raised if the minor or patch versions do not match. + replace + Specifies how the theme should replace or augment Shiny's bundled Bootstrap + theme: + + * `"css"` is the default: The theme completely replaces the Bootstrap + stylesheet, i.e. the `bootstrap.min.css` file, of Shiny's built-in Bootstrap + theme. + * `"none"`: The theme is added as additional CSS (and/or JavaScript) files in + addition to Shiny's built-in Bootstrap theme. + * `"all"`: Shiny's built-in Bootstrap theme is completely replaced by the theme. + This option is for expert usage and should be used with caution. Shiny is + designed to work with the currently bundled version of Bootstrap. Use the + `bs_version` parameter to check compatibility of the provided theme with the + bundled Bootstrap version at runtime. + + Attributes + ---------- + theme + The theme to apply as a tagified :class:`~htmltools.Tag`, + :class:`~htmltools.TagList`, or :class:`~htmltools.HTMLDependency`. + name + An optional name for the theme. + version + An optional version of the theme. + bs_version + The Bootstrap version with which the theme is compatible. + replace + Specifies how the theme should replace or augment Shiny's bundled Bootstrap + theme: + + * `"css"` is the default: The theme replaces only the `bootstrap.min.css` file + of Shiny's built-in Bootstrap theme. + * `"none"`: The theme is added as additional CSS (and/or JavaScript) files in + addition to Shiny's built-in Bootstrap theme. + * `"all"`: Shiny's built-in Bootstrap theme is completely replaced by the theme. + This option is for expert usage and should be used with caution. Shiny is + designed to work with the currently bundled version of Bootstrap. Use the + `bs_version` parameter to check compatibility of the provided theme with the + bundled Bootstrap version at runtime. + + Raises + ------ + ValueError + If an invalid replacement strategy is specified for `replace`. + TypeError + If the theme, when tagified, does not return a `Tag` or `TagList`. + """ + + def __init__( + self, + theme: str | Path | Tag | Tagifiable | HTMLDependency, + *, + name: Optional[str] = None, + version: Optional[str | Version] = None, + bs_version: Optional[str | Version] = None, + replace: Literal["css", "all", "none"] = "css", + ) -> None: + if replace not in ("css", "all", "none"): + raise ValueError( + f"Invalid value for `replace`: '{replace}'. " + + "Must be one of 'css', 'all', 'none'." + ) + + if isinstance(theme, (str, Path)): + theme_tag = head_content(include_css(theme)) + elif isinstance(theme, HTMLDependency): + theme_tag = theme + else: + theme_tag = theme.tagify() + + if not isinstance(theme_tag, (Tag, TagList)): + raise TypeError( + f"Invalid tagified type for `theme`: {type(theme_tag)}. " + + "When tagified, `theme` must return a `Tag` or `TagList`." + ) + + self.theme: Tag | TagList | HTMLDependency = theme_tag + self.name: Optional[str] = name + self.version: Optional[Version] = maybe_version(version, "version") + self.bs_version: Optional[Version] = maybe_version(bs_version, "bs_version") + self.replace: Literal["css", "all", "none"] = replace + + def __repr__(self) -> str: + return ( + f"Theme(theme={self.theme!r}, name={self.name!r}, " + + f"version={self.version!r}, bs_version={self.bs_version!r}, " + + f"replace={self.replace!r})" + ) + + def check_compatibility( + self, + base_version: str | Version = BOOTSTRAP_VERSION, + ) -> None: + """ + Checks the compatibility of the theme's Bootstrap version with the base version. + + This method compares the major version of the theme's Bootstrap version + (`bs_version`) with the provided `base_version`. If the major versions are + different, it raises a `RuntimeError` indicating that the versions are + incompatible. If the major versions are the same but the versions are not + identical, it issues a warning about the potential for unexpected issues due to + the version mismatch. + + Parameters + ---------- + base_version + The base Bootstrap version to compare against. Defaults to the version of + Bootstrap bundled with Shiny. + + Raises + ------ + RuntimeError + When the major versions of `bs_version` and `base_version` are different, + indicating incompatibility between the theme's Bootstrap version and the + base version. + + Warns + ----- + RuntimeWarning + When the versions are not identical but the major versions match, indicating + a potential for unexpected issues. + """ + if self.bs_version is None: + return + + if not isinstance(base_version, Version): + base_version = Version(base_version) + + if self.bs_version == base_version: + return + + the_theme = f"'{self.name}'" if self.name else "" + the_theme += f" ({self.version})" if self.version else "" + the_theme = f"theme {the_theme}" if the_theme else "`theme`" + + if self.bs_version.major != base_version.major: + raise RuntimeError( + "Bootstrap version mismatch:" + + f"\n * {self.bs_version} from {the_theme}." + + f"\n * {base_version} from Shiny." + + "\n ! These versions of Bootstrap are incompatible." + ) + + warn( + "Bootstrap version mismatch:" + + f"\n * {self.bs_version} from {the_theme}." + + f"\n * {base_version} from Shiny." + + "\n ! This version mismatch may cause unexpected issues.", + RuntimeWarning, + ) + + +@add_example() +def theme( + theme: str | Path | Tag | Tagifiable | HTMLDependency, + *, + name: Optional[str] = None, + version: Optional[str] = None, + bs_version: Optional[str | Version] = None, + replace: Literal["css", "all", "none"] = "css", +) -> Theme: + """ + Provide a theme for styling Shiny via the `theme` argument of page functions. + + Specify a theme that customizes the appearance of a Shiny application by replacing + the default Bootstrap theme, by replacing Bootstrap altogether, or by layering on + top of the default Bootstrap theme. + + Parameters + ---------- + theme + The theme to apply. This can be a path to a CSS file, a :class:`~htmltools.Tag` + or :class:`~htmltools.Tagifiable` object, or an + :class:`~htmltools.HTMLDependency`. When `theme` is a string or + :class:`~pathlib.Path`, it is interpreted as a path to a CSS file that will be + added to the app using :func:`~shiny.ui.include_css`. + name + An optional name for the theme. + version + An optional version of the theme. + bs_version + The Bootstrap version with which the theme is compatible. When the theme is + used, an error will be raised if the major version of Shiny's built-in Bootstrap + version and does not match the theme's major Bootstrap version. Otherwise, a + warning is raised if the minor or patch versions do not match. + replace + Specifies how the theme should replace or augment Shiny's bundled Bootstrap + theme: + + * `"css"` is the default: The theme completely replaces the Bootstrap + stylesheet, i.e. the `bootstrap.min.css` file, of Shiny's built-in Bootstrap + theme. + * `"none"`: The theme is added as additional CSS (and/or JavaScript) files in + addition to Shiny's built-in Bootstrap theme. + * `"all"`: Shiny's built-in Bootstrap theme is completely replaced by the theme. + This option is for expert usage and should be used with caution. Shiny is + designed to work with the currently bundled version of Bootstrap. Use the + `bs_version` parameter to check compatibility of the provided theme with the + bundled Bootstrap version at runtime. + + + Returns + ------- + : + A theme object for use with the `theme` argument of Shiny UI page elements. + """ + return Theme( + theme, + name=name, + version=version, + bs_version=bs_version, + replace=replace, + ) + + +def maybe_version( + version: Optional[str | Version], + name: str = "version", +) -> Optional[Version]: + if version is None: + return None + + if not isinstance(version, (str, Version)): + raise TypeError( + f"Invalid type for `{name}`: {type(version)}. " + + "Must be a string or `packaging.version.Version`." + ) + + if isinstance(version, str): + try: + version = Version(version) + except ValueError: + raise ValueError( + f"Invalid version string for `{name}`: '{version}'. " + + "Must be a valid version string." + ) + + return version diff --git a/tests/pytest/test_theme.py b/tests/pytest/test_theme.py new file mode 100644 index 000000000..6e236d307 --- /dev/null +++ b/tests/pytest/test_theme.py @@ -0,0 +1,37 @@ +import pytest +from htmltools import HTMLDependency + +from shiny import ui +from shiny._versions import bootstrap + +v_bs = bootstrap.split(".") + + +def test_theme_incompatible_error(): + with pytest.raises(RuntimeError, match=r"Bootstrap version mismatch"): + ui.theme( + HTMLDependency("my-theme", "0.1.2"), + bs_version="4.2.0", + name="My Theme", + version="0.1.2", + ).check_compatibility() + + +def test_theme_incompatible_warning() -> None: + theme_v_bs = f"{v_bs[0]}.{int(v_bs[1]) + 1}.0" + + with pytest.warns(RuntimeWarning, match=r"Bootstrap version mismatch"): + ui.theme( + HTMLDependency("my-theme", "0.1.2"), + bs_version=theme_v_bs, + name="My Theme", + version=theme_v_bs, + ).check_compatibility() + + with pytest.warns(RuntimeWarning, match=r"Bootstrap version mismatch"): + ui.theme( + HTMLDependency("name", "1.2.3"), + bs_version="5.1.2", + name="My Theme", + version="0.1.0", + ).check_compatibility("5.2.3")