diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c6393a..6c9ceac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to `shinyswatch` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [UNRELEASED] - YYYY-MM-DD + +### New features + +* `shinyswatch.theme_picker_ui()` gains a `default` argument to set the initial theme. (#22) + +### Internal changes + +* We've restructured the dependencies used to provide a shinyswatch theme. This change should not affect users of shinyswatch, but it will prevent accidentally including more than one shinyswatch themes on the same page. (#32) + +* The theme picker now transitions between themes more smoothly. That said, we do still recommend using the theme picker only while developing your app. (#32) + ## [0.5.1] - 2024-03-07 * Add typed attributes in the theme's color class for stronger type checking. diff --git a/examples/page-sidebar/app.py b/examples/page-sidebar/app.py index 6f42d4e..dd24479 100644 --- a/examples/page-sidebar/app.py +++ b/examples/page-sidebar/app.py @@ -8,7 +8,7 @@ app_ui = ui.page_sidebar( ui.sidebar( ui.input_slider("n", "N", min=0, max=100, value=20), - shinyswatch.theme_picker_ui(), + shinyswatch.theme_picker_ui("zephyr"), ), ui.card(ui.output_plot("plot")), title="Shiny Sidebar Page", diff --git a/shinyswatch/__init__.py b/shinyswatch/__init__.py index 4ecb901..f2da5ec 100644 --- a/shinyswatch/__init__.py +++ b/shinyswatch/__init__.py @@ -1,6 +1,6 @@ """Bootswatch + Bootstrap 5 themes for Shiny""" -__version__ = "0.5.1" +__version__ = "0.5.1.9000" from . import theme from ._get_theme import get_theme diff --git a/shinyswatch/_get_theme_deps.py b/shinyswatch/_get_theme_deps.py index 1646684..51127a6 100644 --- a/shinyswatch/_get_theme_deps.py +++ b/shinyswatch/_get_theme_deps.py @@ -4,9 +4,47 @@ from htmltools import HTMLDependency +# from shiny.ui._html_deps_external import bootstrap_deps_suppress +from shiny._versions import bootstrap as shiny_bootstrap_version + from ._assert import assert_theme from ._bsw5 import BSW5_THEME_NAME, bsw5_version -from ._shiny import base_dep_version, bs5_path, bs_dep_no_files + +bs5_path = os.path.join(os.path.dirname(__file__), "bs5") + + +def suppress_shiny_bootstrap() -> list[HTMLDependency]: + return [ + # shiny > 0.8.1 will (likely) split bootstrap into separate js/css deps + HTMLDependency( + name="bootstrap-js", + version=shiny_bootstrap_version + ".9999", + ), + HTMLDependency( + name="bootstrap-css", + version=shiny_bootstrap_version + ".9999", + ), + # shiny <= 0.8.1 loads bootstrap as a single dep + HTMLDependency( + name="bootstrap", + version=shiny_bootstrap_version + ".9999", + ), + # Disable ionRangeSlider + # TODO: Remove this when ionRangeSlider no longer requires Sass for BS5+ + HTMLDependency( + name="preset-shiny-ionrangeslider", + version="9999", + ), + ] + + +def dep_shinyswatch_bootstrap_js() -> HTMLDependency: + return HTMLDependency( + name="shinyswatch-js", + version=bsw5_version, + source={"package": "shinyswatch", "subdir": bs5_path}, + script={"src": "bootstrap.bundle.min.js"}, + ) def get_theme_deps(name: BSW5_THEME_NAME) -> list[HTMLDependency]: @@ -33,22 +71,11 @@ def get_theme_deps(name: BSW5_THEME_NAME) -> list[HTMLDependency]: # This is to prevent the Shiny bootstrap stylesheet from being loaded and instead load the bootswatch + bootstrap stylesheet # _Disable_ bootstrap html dep # Prevents bootstrap from being loaded at a later time (Ex: shiny.ui.card() https://github.com/rstudio/py-shiny/blob/d08af1a8534677c7026b60559cd5eafc5f6608d7/shiny/ui/_navs.py#L983) - HTMLDependency( - name="bootstrap", - version=base_dep_version, - ), - # Use a custom version of bootstrap with no stylesheets/JS - bs_dep_no_files, - # Add in the matching JS files - HTMLDependency( - name="bootstrap-js", - version=bsw5_version, - source={"package": "shinyswatch", "subdir": bs5_path}, - script={"src": "bootstrap.bundle.min.js"}, - ), + *suppress_shiny_bootstrap(), + dep_shinyswatch_bootstrap_js(), # Shinyswatch - bootstrap / bootswatch css HTMLDependency( - name=f"bootswatch-{name}-and-bootstrap", + name="shinyswatch-css", version=bsw5_version, source={"package": "shinyswatch", "subdir": subdir}, stylesheet=[{"href": "bootswatch.min.css"}], @@ -56,17 +83,45 @@ def get_theme_deps(name: BSW5_THEME_NAME) -> list[HTMLDependency]: # ## End Bootswatch # # ## Start ionRangeSlider - # Disable ionRangeSlider HTMLDependency( - name="preset-shiny-ionrangeslider", - version=base_dep_version, - ), - # Shinyswatch - ionRangeSlider css - HTMLDependency( - name=f"bootswatch-{name}-ionrangeslider", + name="shinyswatch-ionrangeslider", version=bsw5_version, source={"package": "shinyswatch", "subdir": subdir}, stylesheet=[{"href": "shinyswatch-ionRangeSlider.css"}], ), # ## End ionRangeSlider ] + + +# TODO: Update this list if the css files in the dependency above change +deps_shinyswatch_css_files = [ + "bootswatch.min.css", + "shinyswatch-ionRangeSlider.css", +] + + +def deps_shinyswatch_all(initial: str = "superhero") -> list[HTMLDependency]: + assert_theme(name=initial) + + return [ + *suppress_shiny_bootstrap(), + dep_shinyswatch_bootstrap_js(), + HTMLDependency( + name="shinyswatch-all-css", + version=bsw5_version, + source={"package": "shinyswatch", "subdir": "bsw5"}, + stylesheet=[ + shinyswatch_all_initial_css(initial, "bootswatch.min.css"), + shinyswatch_all_initial_css(initial, "shinyswatch-ionRangeSlider.css"), + ], # type: ignore + all_files=True, + ), + ] + + +def shinyswatch_all_initial_css(theme: str, css_file: str) -> dict[str, str]: + return { + "href": os.path.join(theme, css_file), + "data-shinyswatch-css": css_file, + "data-shinyswatch-theme": theme, + } diff --git a/shinyswatch/_shiny.py b/shinyswatch/_shiny.py deleted file mode 100644 index 526ed03..0000000 --- a/shinyswatch/_shiny.py +++ /dev/null @@ -1,36 +0,0 @@ -# TODO-barret; Make a test to check if the shiny version of bootstrap is not v5 -# TODO-barret; Remind users that they will need internet access to get the fonts - -from __future__ import annotations - -import os -from copy import deepcopy - -from shiny.ui import page_bootstrap as shiny_page_bootstrap - -bs5_path = os.path.join(os.path.dirname(__file__), "bs5") - -base_dep_version = "9999" - -# Overwrite the bootstrap dependency with a custom one that disables the stylesheet -bs_dep_no_files = None - -# Get a vanilla bootstrap page to extract the bootstrap dependency -shiny_deps = shiny_page_bootstrap().get_dependencies() -for dep in shiny_deps: - if dep.name != "bootstrap": - continue - # Copy the dependency, - # and disable the stylesheet (to be overwritten by shinyswatch) - # and disable the JS to make sure the BS's JS files match (to be overwritten by shinyswatch) - # There could be `meta` or `head` information in the dependency that we want to keep - bs_dep_no_files = deepcopy(dep) - # Disable bootstrap.min.css as it is included in bootswatch bundle - bs_dep_no_files.stylesheet = [] - # Disable bootstrap.min.js as it is included at end of function - bs_dep_no_files.script = [] - # Rename to convey intent (and to disable it below) - bs_dep_no_files.name = "bootstrap-no-files" - -if bs_dep_no_files is None: - raise ValueError("Could not find bootstrap dependency") diff --git a/shinyswatch/_theme_picker.py b/shinyswatch/_theme_picker.py index e366f9c..539c399 100644 --- a/shinyswatch/_theme_picker.py +++ b/shinyswatch/_theme_picker.py @@ -1,19 +1,15 @@ -from htmltools import HTMLDependency, TagList -from shiny import reactive, render, req, ui +from __future__ import annotations + +from htmltools import HTMLDependency +from shiny import reactive, ui from shiny.session import require_active_session +from . import __version__ as shinyswatch_version from ._bsw5 import BSW5_THEME_NAME, bsw5_themes -from ._get_theme_deps import get_theme_deps -from ._shiny import base_dep_version - -default_theme_name = "superhero" +from ._get_theme_deps import deps_shinyswatch_all, deps_shinyswatch_css_files -theme_name: reactive.Value[BSW5_THEME_NAME] = reactive.Value(default_theme_name) -# Use a counter to force the new theme to be registered as a dependency -counter: reactive.Value[int] = reactive.Value(0) - -def theme_picker_ui() -> ui.TagChild: +def theme_picker_ui(default: BSW5_THEME_NAME = "superhero") -> ui.TagChild: """ Theme picker - UI @@ -25,6 +21,11 @@ def theme_picker_ui() -> ui.TagChild: * Do not include more than one theme picker in your app. * Do not call the theme picker UI / server inside a module. + Parameters + ---------- + default + The default theme to be selected when the theme picker is first loaded. + Returns ------- : @@ -43,33 +44,30 @@ def theme_picker_ui() -> ui.TagChild: style="color: var(--bs-danger); background-color: var(--bs-light); display: none;", id="shinyswatch_picker_warning", ), - ui.tags.script( - """ - (function() { - const display_warning = setTimeout(function() { - window.document.querySelector("#shinyswatch_picker_warning").style.display = 'block'; - }, 1000); - Shiny.addCustomMessageHandler('shinyswatch-hide-warning', function(message) { - window.clearTimeout(display_warning); - }); - Shiny.addCustomMessageHandler('shinyswatch-refresh', function(message) { - window.location.reload(); - }); - })() - """ - ), ui.input_select( id="shinyswatch_theme_picker", label="Select a theme:", # TODO-barret; selected - selected=None, - choices=[], + selected=default, + choices=bsw5_themes, ), - get_theme_deps(default_theme_name), - ui.output_ui("shinyswatch_theme_deps"), + theme_picker_deps(default), ) +def theme_picker_deps(initial: str = "superhero") -> list[HTMLDependency]: + return [ + *deps_shinyswatch_all(initial), + HTMLDependency( + name="shinyswatch-theme-picker", + version=shinyswatch_version, + source={"package": "shinyswatch", "subdir": "picker"}, + stylesheet={"href": "theme_picker.css"}, + script={"src": "theme_picker.js"}, + ), + ] + + def theme_picker_server() -> None: """ Theme picker - Server @@ -85,38 +83,19 @@ def theme_picker_server() -> None: session = require_active_session(None) input = session.input - output = session.output - @reactive.Effect + @reactive.effect @reactive.event(input.shinyswatch_theme_picker) async def _(): - counter.set(counter() + 1) - if theme_name() != input.shinyswatch_theme_picker(): - theme_name.set(input.shinyswatch_theme_picker()) - await session.send_custom_message("shinyswatch-refresh", {}) - - @output - @render.ui - def shinyswatch_theme_deps(): # pyright: ignore[reportUnusedFunction] - req(theme_name()) - - # Get the theme dependencies and set them to a version that will always be registered - theme_deps = get_theme_deps(theme_name()) - incremented_version = HTMLDependency( - name="VersionOnly", - version=f"{base_dep_version}.{counter()}", - ).version - for theme_dep in theme_deps: - theme_dep.version = incremented_version - # Return dependencies in a TagList so they can all be utilized - return TagList(theme_deps) - - @reactive.Effect - async def _(): - ui.update_selectize( - "shinyswatch_theme_picker", - selected=theme_name(), - choices=bsw5_themes, + + await session.send_custom_message( + "shinyswatch-pick-theme", + { + "theme": input.shinyswatch_theme_picker(), + "sheets": deps_shinyswatch_css_files, + }, ) - # Disable the warning message + + @reactive.effect + async def _(): await session.send_custom_message("shinyswatch-hide-warning", {}) diff --git a/shinyswatch/picker/theme_picker.css b/shinyswatch/picker/theme_picker.css new file mode 100644 index 0000000..dba295f --- /dev/null +++ b/shinyswatch/picker/theme_picker.css @@ -0,0 +1,3 @@ +[data-shinyswatch-transitioning] * { + transition: all var(--shinyswatch-transition-duration, 100ms) ease-in-out; +} diff --git a/shinyswatch/picker/theme_picker.js b/shinyswatch/picker/theme_picker.js new file mode 100644 index 0000000..a425ca1 --- /dev/null +++ b/shinyswatch/picker/theme_picker.js @@ -0,0 +1,103 @@ +(function () { + // Get the source path of the shinyswatch-js script + // to figure out where the htmldependency has ended up + // e.g. src="lib/shinyswatch-js-5.3.1/bootstrap.bundle.min.js" + // avoids having to know the shinswatch bootstrap version or lib location + function getShinyswatchLibPath() { + const sw_script = document.querySelector('script[src*="shinyswatch-js"') + return sw_script.src + .replace(/\/bootstrap.*js$/, '') + .replace('shinyswatch-js', 'shinyswatch-all-css') + } + + function replaceShinyswatchCSS({ theme, sheet }) { + const oldLinks = document.querySelectorAll( + `link[data-shinyswatch-css="${sheet}"]` + ) + + if (oldLinks.length == 0) { + // For some reason we don't have a shinyswatch-created theme link, so we'll have + // to create one ourselves. + link = document.createElement('link') + link.rel = 'stylesheet' + link.type = 'text/css' + link.href = `${getShinyswatchLibPath()}/${theme}/${sheet}` + link.dataset.shinyswatchCss = sheet + link.dataset.shinyswatchTheme = theme + link.onload = () => shinyswatchTransition(true) + document.body.appendChild(link) + document.body.addEventListener( + 'transitionend', + () => shinyswatchTransition(false), + { once: true } + ) + return link + } + + // If we have more than one link, all but the last are already scheduled for + // removal. The current update will only copy and remove the last one. + const oldLink = oldLinks[oldLinks.length - 1] + + if (oldLink.dataset.shinyswatchTheme === theme) { + // The theme is already applied, so we don't need to do anything. + return; + } + + const newLink = oldLink.cloneNode(true) + newLink.href = newLink.href.replace(oldLink.dataset.shinyswatchTheme, theme) + newLink.href = newLink.href.replace(window.location.href, '') + newLink.dataset.shinyswatchTheme = theme + newLink.onload = () => shinyswatchTransition(true) + oldLink.parentNode.insertBefore(newLink, oldLink.nextSibling) + + const cleanup = () => { + shinyswatchTransition(false) + oldLink.remove() + } + + const backup = setTimeout(cleanup, 500) + + // Theme picker adds a `* { transition: ... }` rule that we can use to detect + // when the new theme has been applied. + document.body.addEventListener( + 'transitionend', + () => { + clearTimeout(backup) + cleanup() + }, + { once: true } + ) + + return newLink + } + + function shinyswatchTransition(transitioning) { + if (transitioning) { + document.documentElement.dataset.shinyswatchTransitioning = 'true' + } else { + setTimeout( + () => { + // Give the transition a tick to end before removing the attribute + document.documentElement.removeAttribute('data-shinyswatch-transitioning') + }, + 100 + ) + } + } + + const display_warning = setTimeout(function () { + window.document.querySelector('#shinyswatch_picker_warning').style.display = + 'block' + }, 1000) + + Shiny.addCustomMessageHandler('shinyswatch-hide-warning', function (message) { + window.clearTimeout(display_warning) + }) + + Shiny.addCustomMessageHandler( + 'shinyswatch-pick-theme', + function ({ theme, sheets }) { + sheets.forEach(sheet => replaceShinyswatchCSS({ theme, sheet })) + } + ) +})()