Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Dynamic theming #1358

Merged
merged 134 commits into from
Jun 27, 2024
Merged

feat: Dynamic theming #1358

merged 134 commits into from
Jun 27, 2024

Conversation

gadenbuie
Copy link
Collaborator

@gadenbuie gadenbuie commented May 6, 2024

Remaining tasks

  • Changelog
  • Testing in Shiny Express
  • More testing in general
  • API examples (currently shiny/api-examples/theme/app-core.py includes a POC-level tests/example)
  • Merge pending PRs in shiny/bslib and update scripts/_pkg-sources.R
  • feat!: Use shiny.ui.Theme py-shinyswatch#39

Introduction

This is a large PR; please read the PR description.

The primary goal of this PR is a low-level interface to customizable Shiny themes via shiny.ui.Theme(). In #1334, we introduced a theme argument for all page functions, e.g. ui.page_sidebar() and express.ui.page_opts(). This can now be combined with shiny.ui.Theme() to create a custom theme for your Shiny app:

from shiny import App, render, ui

app_ui = ui.page_sidebar(
    # app contents...
    title="Theme Example",
    theme=(
        ui.Theme("shiny")
        .add_defaults(
            headings_color="red",
            bar_color="purple",
            select_color_text="green",
            bslib_dashboard_design=False,
        )
        .add_rules(
            """
            strong { color: $primary; }
            .sidebar-title { color: $danger; }
            """
        )
    ),
)

Note

Note that this API is aimed at advanced users who are comfortable with Bootstrap's Sass, as well as our own internal usage. With brand.yml, #1226, we intend to provide a higher-level API for theming that is more user-friendly.

Internal Dependency Changes

The biggest internal changes is around how we handle page-level depenendencies. First I've renamed bootstrap_theme_deps() to shiny_page_theme_deps(). This function now:

  1. Returns a complete bundle of dependencies for Bootstrap, and shiny/bslib components like selectize, ionRangeSlider, the datepicker and bslib's components.

    Importantly, the styles for all of these base-Shiny components are now included in bootstrap.min.css, which can be thought of as a complete Shiny theme.

  2. The component dependencies are still attached directly to the components that use them, e.g. selectize still adds the selectize dependencies.

    In these contexts, the component styles are rendered against a base Boostrap 5 theme. Within Shiny apps, shiny_page_theme_deps() are loaded by the page and appear first. Component dependencies are suppressed unless the component is used outside of a Shiny page, such as in an interactive Quarto document.

    This usage explains both the component-specific dependency as well as the use of default Bootstrap styles for these components.

Dependency Sourcing

The dependency sourcing in scripts/htmlDependencies.R has been completely refactored. There are two levels to the change:

  1. First, to bring in the component dependencies from shiny and bslib as before, but this time pre-rendering component-specific styles against a base Bootstrap 5 theme.

  2. Second, to create shiny/www/shared/sass containing the complete set of Sass .scss files to compile Bootstrap 5, as well as all component styles from shiny and bslib, into a single file.

    The end result is that we have a set of Sass files in shiny/www/shared/sass/preset/{preset}/ that compile into a single preset.min.css file and include all of the dependencies in shiny_page_theme_deps() in a single stylesheet.

    These Sass files draw from source styles in shiny/www/shared/sass/shiny and shiny/www/shared/sass/bslib. We only include Sass stylesheets that are needed, by recursively tracking imports.

I did some very heavy refactoring of the existing scripts/htmlDependencies.R to make this work. The biggest refactor of existing code happened in e7c6189 and at every step I made sure that the existing functionality was preserved by running the script and ensuring that only the source code changed. In general, any commit using refactor: in the commit message is a refactoring commit where only the source files changed.

Initially in this PR, the Sass prep and compilation was a separate step, but it's now integrated into the scripts/htmlDependencies.R script. A big part of this refactoring included moving everything into smaller functions so that the script is easier to read as a workflow of steps.

Getting the individual Sass files for all of the components required changes in shiny and bslib. In both packages, we had component dependency functions that took a theme object and returned a dependency passed through bslib::bs_dependency() or bslib::bs_dependency_defer(), which rendered the Sass into minified CSS. The two PRs below refactored out the Sass rules or files so that we can call those directly to avoid the Sass compilation:

Sass Compilation

For a Theme object, you can use the .to_sass() method to get the Sass code for the theme. This reveals the structure of the Sass code that is generated by the Theme object:

>>> from shiny import ui
>>> shiny_sass = ui.Theme("shiny") \
...     .add_functions("/* user functions here */") \
...     .add_defaults("/* user defaults here */") \
...     .add_mixins("/* user mixins here */") \
...     .add_rules("/* user rules here */") \
...     .to_sass()
>>> print(shiny_sass)
@import "/Users/garrick/work/posit-dev/py-shiny/shiny/www/shared/sass/preset/shiny/_01_functions.scss";
/* user functions here */
/* user defaults here */
@import "/Users/garrick/work/posit-dev/py-shiny/shiny/www/shared/sass/preset/shiny/_02_defaults.scss";
@import "/Users/garrick/work/posit-dev/py-shiny/shiny/www/shared/sass/preset/shiny/_03_mixins.scss";
/* user mixins here */
@import "/Users/garrick/work/posit-dev/py-shiny/shiny/www/shared/sass/preset/shiny/_04_rules.scss";
/* user rules here */

We place user Sass code after the preset theme Sass code, except for defaults which are placed before theme defaults. This follows the logic of sass::sass_layer_file() and the layering order of Quarto. We only accept user Sass code in the theme, however, so we don't handle multiple Sass layers.

To compile a custom theme to CSS, use the .to_css() method. This method caches the compiled CSS in the theme object, so that it only needs to be compiled once. If you use any of the .add_*() methods after calling .to_css(), or if you change {theme}.preset, the CSS will be recompiled.

The theme object is not a Tagifiable object but it does have a .tagify() method so that we can throw an error if the theme is tagified. Tagifying the theme object is a signal that the theme was used in the UI. Instead, as the error recommends, users should pass the theme object to the theme argument of any page_*() function (or shiny.express.page_opts()).

We include pre-compiled versions of the "shiny" and "bootstrap" themes, so no compilation or temporary folders are required for ui.Theme("shiny") or ui.Theme("bootstrap") until they have been customized.

Before this PR, the shiny wheel was 3.38 MB (as reported by make dist); with the new Sass files and two pre-compiled themes it is now 3.75MB. If we included the pre-compiled Bootswatch themes, the wheel would be 4.91 MB.

shiny/ui/_theme.py Outdated Show resolved Hide resolved
shiny/ui/_theme.py Outdated Show resolved Hide resolved
shiny/ui/_theme.py Outdated Show resolved Hide resolved
@cpsievert
Copy link
Collaborator

cpsievert commented Jun 26, 2024

A slightly more involved suggestion to fix/simplify some of the sass.compile() logic:

sass-compile.patch

Co-authored-by: Carson Sievert <carson@posit.co>
shiny/_app.py Outdated Show resolved Hide resolved
shiny/ui/_theme.py Outdated Show resolved Hide resolved
CHANGELOG.md Outdated Show resolved Hide resolved
shiny/_app.py Outdated Show resolved Hide resolved
@gadenbuie gadenbuie added this pull request to the merge queue Jun 27, 2024
Merged via the queue into main with commit 358b17c Jun 27, 2024
31 checks passed
@gadenbuie gadenbuie deleted the feat/dynamic-theming branch June 27, 2024 19:02
schloerke added a commit to machow/py-shiny that referenced this pull request Jul 2, 2024
* main:
  fix(tests): dynamically determine the path to the shiny app (posit-dev#1485)
  tests(deploys): use a stable version of html tools instead of main branch (posit-dev#1483)
  feat(data frame): Support basic cell styling (posit-dev#1475)
  fix: support static files on pyodide / py.cafe under a prefix (posit-dev#1486)
  feat: Dynamic theming (posit-dev#1358)
  Add return type for `_task()` (posit-dev#1484)
  tests(controls): Change API from controls to controller (posit-dev#1481)
  fix(docs): Update path to reflect correct one (posit-dev#1478)
  docs(testing): Add quarto page for testing (posit-dev#1461)
  fix(test): Remove unused testrail reporting from nightly builds (posit-dev#1476)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants