Skip to content

Add busy indicators (aka spinners) #918

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

Merged
merged 70 commits into from
May 10, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
b5dfe7e
Add function for including loading spinners in apps.
nstrayer Dec 15, 2023
fb0e218
Fix documentation formatting
nstrayer Dec 19, 2023
80c38b4
Add option for delay on the spinner animation. Also simplify animatio…
nstrayer Dec 20, 2023
fc69915
Refactor server function to control sleep behavior
nstrayer Dec 20, 2023
77deff3
Add TODO for toggling spinner growing
nstrayer Dec 20, 2023
2e076b9
Disable grow animation by default.
nstrayer Dec 20, 2023
11a4726
Add two new loading spinners and add always-on spinner to demo app
nstrayer Dec 20, 2023
dd54ced
Use literals for typing of type and format docstrings
nstrayer Dec 20, 2023
54a7f0d
Make disc spinner more balanced
nstrayer Dec 20, 2023
6733696
Remove set width and heights from svg as they are never actually used
nstrayer Dec 20, 2023
1fbb479
Add option for page-level spinner and modularize css a bit
nstrayer Dec 22, 2023
95a8df2
make the page-level spinner have a smaller default size
nstrayer Dec 22, 2023
14cbdc0
Add spinners as on by default and add response to classes to turn off…
nstrayer Dec 22, 2023
4a8a91e
Add ability to target a specific output with a spinner.
nstrayer Dec 22, 2023
b0f8576
Remove the no-longer-used spinners-common.css file.
nstrayer Dec 22, 2023
30ce38c
Merge branch 'main' into spinners
cpsievert Apr 2, 2024
bef3ae8
Fix small doc issues
cpsievert Apr 3, 2024
7977393
Bundle HTMLDependency() with shiny's core dependencies
cpsievert Apr 3, 2024
10cbc90
Update example to demonstrate some current problems better
cpsievert Apr 3, 2024
8bd3513
Return tag instead of HTMLDependency; simplify API
cpsievert Apr 3, 2024
b4f5cef
Fix/improve element level spinner behavior. Provide a disable()/enabl…
cpsievert Apr 3, 2024
f98e30e
Merge branch 'main' into spinners
cpsievert Apr 3, 2024
ff93b4a
Fix plot tasks effects other plots
cpsievert Apr 4, 2024
2826e34
Get rid of blur effect in favor of opacity dim
cpsievert Apr 4, 2024
f21f5ae
Introduce concept of indicator modes; leverage build-time Sass and tr…
cpsievert Apr 4, 2024
b5a268e
Execute the extended tasks concurrently
cpsievert Apr 5, 2024
7862ce1
Add page-level spinner when shiny is busy, and nothing in the UI is r…
cpsievert Apr 5, 2024
ce4760a
Add spinner mode
cpsievert Apr 5, 2024
5f38b79
Merge branch 'main' into spinners
cpsievert Apr 5, 2024
924ed38
rm yarn.lock
cpsievert Apr 8, 2024
93ef45b
Rename loading-indicators -> busy-indicators
cpsievert Apr 8, 2024
dfa0ab7
Activate page-level spinner up until the 1st idle.
cpsievert Apr 8, 2024
1cbe237
Add submodule to express
cpsievert Apr 8, 2024
618278c
Remove example commited by mistake
cpsievert Apr 8, 2024
391be77
Embed svgs in the css; simplify further
cpsievert Apr 8, 2024
dadf49a
Revert changes to package json since plugin is no longer needed
cpsievert Apr 8, 2024
eae88cb
Add to API reference, with an example
cpsievert Apr 8, 2024
ae9994a
Merge branch 'main' into spinners
cpsievert Apr 8, 2024
df4e90b
Increase delay to 0.5s
cpsievert Apr 9, 2024
afdb8d7
Introduce the concept of a pulsing page (instead of a page-wide spinner)
cpsievert Apr 10, 2024
cf3c5f6
Move example to more sensible location
cpsievert Apr 10, 2024
9279dc3
Remove disable spinner classes and just document size=0px as a way to…
cpsievert Apr 10, 2024
3fb4975
Merge branch 'main' into spinners
cpsievert Apr 10, 2024
684b25e
Remove example commited by mistake
cpsievert Apr 11, 2024
a40e0cd
Code review
cpsievert Apr 11, 2024
bbe4e1d
Improve pulse effect; add pulse_options()
cpsievert Apr 12, 2024
0f64d90
Small tweaks to pulse from Greg
cpsievert Apr 23, 2024
707f521
Bring in multi-color gradient
cpsievert Apr 23, 2024
f21d5c3
Merge branch 'main' into spinners
cpsievert Apr 23, 2024
ffad902
Merge branch 'main' into spinners
cpsievert Apr 23, 2024
520045b
Export pulse_options()
cpsievert Apr 23, 2024
e883193
Avoid inline script
cpsievert Apr 23, 2024
edb2245
Show output busy state on 1st draw; disable pulse when spinners are v…
cpsievert Apr 26, 2024
f646b9e
Fix lint
cpsievert Apr 26, 2024
2497552
Merge branch 'main' into spinners
cpsievert Apr 26, 2024
816a1ac
Update express example
cpsievert Apr 26, 2024
4a46170
Decrease default size; do more to avoid clipping; fix example
cpsievert Apr 29, 2024
698cae2
Clean up event handling logic
cpsievert Apr 29, 2024
4efa58b
Add more commentary
cpsievert Apr 30, 2024
7d833f1
Merge branch 'main' into spinners
cpsievert May 8, 2024
b822ee1
Bring in JS/CSS source from RShiny repo
cpsievert May 8, 2024
1285798
Add back spinner selector option
cpsievert May 8, 2024
77755ea
Merge branch 'main' into spinners
cpsievert May 10, 2024
335f059
Port over latest from RShiny
cpsievert May 10, 2024
2949437
Update
cpsievert May 10, 2024
b242cb0
Update
cpsievert May 10, 2024
1125f43
Fix base64 encoding
cpsievert May 10, 2024
23d526e
Fix/improve example
cpsievert May 10, 2024
63191a6
Update changelog
cpsievert May 10, 2024
86ca109
Better naming
cpsievert May 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions examples/loading-spinners/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# pyright:basic
import asyncio

import matplotlib.pyplot as plt
import numpy as np

from shiny import App, reactive, render, ui

app_ui = ui.page_sidebar(
ui.sidebar(
ui.input_slider("rows", "Rows", 0, 100, 20),
),
ui.use_loading_spinners(color="red"),
ui.layout_column_wrap(
ui.card(
# Add a spinner directly to an output.
ui.with_spinner(ui.output_plot("plot")),
),
ui.card(
ui.output_plot("plot2"),
),
height="1500px",
),
class_="dot-track-spinner",
)


def server(input, output, session):
first_render = reactive.value(True)

@render.plot
async def plot():
# Generate input.rows() random numbers
data = np.random.randn(input.rows())
plt.plot(data)
plt.ylabel("some numbers")

# Only sleep on subsequent renders so we can quickly start looking at the
# spinner
with reactive.isolate():
if not first_render.get():
# Sleep for a second to simulate a long running process
await asyncio.sleep(3)
else:
first_render.set(False)

return plt.gcf()

@render.plot
async def plot2():
# Generate input.rows() random numbers
data = np.random.randn(input.rows())
plt.plot(data)
plt.ylabel("some numbers")
# Sleep for a second to simulate a long running process

return plt.gcf()


app = App(app_ui, server)
8 changes: 8 additions & 0 deletions shiny/ui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@
)
from ._tooltip import tooltip

from ._loading_spinners import (
use_loading_spinners,
with_spinner,
)


from htmltools import (
TagList,
Expand Down Expand Up @@ -346,4 +351,7 @@
"strong",
"em",
"hr",
# Items related to loading spinners
"use_loading_spinners",
"with_spinner",
)
9 changes: 8 additions & 1 deletion shiny/ui/_html_deps_external.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .._versions import bootstrap as bootstrap_version
from .._versions import shiny_html_deps
from ..html_dependencies import jquery_deps
from ._loading_spinners import page_level_spinners_deps

"""
HTML dependencies for external dependencies Bootstrap, ionrangeslider, datepicker, selectize, and jQuery UI.
Expand All @@ -25,7 +26,13 @@ def bootstrap_deps() -> list[HTMLDependency]:
stylesheet={"href": "bootstrap.min.css"},
meta={"name": "viewport", "content": "width=device-width, initial-scale=1"},
)
deps = [jquery_deps(), dep]
deps = [
jquery_deps(),
dep,
# Place spinners css in as a dependency. Eventually this should be in the main
# shiny css.
page_level_spinners_deps,
]
return deps


Expand Down
140 changes: 140 additions & 0 deletions shiny/ui/_loading_spinners.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
from __future__ import annotations

from typing import Literal, Optional

from htmltools import HTML, HTMLDependency, Tag, head_content, tags

from .. import __version__


def use_loading_spinners(
type: Literal["tadpole", "disc", "dots", "dot-track", "bounce"] = "tadpole",
color: Optional[str] = None,
size: Optional[str] = None,
speed: Optional[str] = None,
delay: Optional[str] = None,
) -> HTMLDependency:
"""
Function to tweak loading spinner options for app.

This function allows you to tweak the style of the loading spinners used in your app.

When supplied in your app's UI, elements that are loading (e.g. plots or tables)
will have a spinner displayed over them. This is useful for when you have a
long-running computation and want to indicate to the user that something is
happening beyond the default grayed-out element.

Parameters
----------

type
The type of spinner to use. Options include "disc", "tadpole", "dots",
"dot-track", and "bounce". Defaults to "tadpole".
color
The color of the spinner. This can be any valid CSS color. Defaults to the
current app "primary" color (if using a theme) or light-blue if not.
size
The size of the spinner. This can be any valid CSS size. Defaults to "80px".
speed
The amount of time for the spinner to complete a single revolution. This can be
any valid CSS time. Defaults to "1s".
delay
The amount of time to wait before showing the spinner. This can be any valid CSS
time. Defaults to "0.5s". This is useful for not showing the spinner if the
computation finishes quickly.
page_level
Should the spinner be shown at the page level (i.e. one spinner per app) or at
the level of each output (default).

Returns
-------
:
An HTMLDependency

Notes
-----
This function is meant to be called a single time. If it is called multiple times
with different arguments then only the first call will be reflected.
"""

animation = None
easing = None

# Some of the spinners work better with linear easing and some with ease-in-out so
# we modify them together.
if type == "disc":
svg = "disc-spinner.svg"
easing = "linear"
elif type == "dots":
svg = "dots-spinner.svg"
easing = "linear"
elif type == "dot-track":
svg = "dot-track-spinner.svg"
easing = "linear"
elif type == "bounce":
svg = "ball.svg"
animation = "shiny-loading-spinner-bounce"
# Set speed variable to 0.8s if it hasnt been set by the user
speed = speed or "0.8s"
else:
svg = "tadpole-spinner.svg"
easing = "linear"

# We set options using css variables. Here we create the rule that updates the
# appropriate variables before being included in the head of the document with our
# html dep.
rule_contents = (
f"--shiny-spinner-svg: url({svg});"
+ (f"--shiny-spinner-easing: {easing};" if easing else "")
+ (f"--shiny-spinner-animation: {animation};" if animation else "")
+ (f"--shiny-spinner-color: {color};" if color else "")
+ (f"--shiny-spinner-size: {size};" if size else "")
+ (f"--shiny-spinner-speed: {speed};" if speed else "")
+ (f"--shiny-spinner-delay: {delay};" if delay else "")
)

return head_content(tags.style(HTML("body{" + rule_contents + "}</style>")))


page_level_spinners_deps = HTMLDependency(
"shiny-loading-spinners-css",
version=__version__,
source={
"package": "shiny",
"subdir": "www/shared/loading-spinners/",
},
stylesheet=[
{"href": "spinners-page-level.css"},
{"href": "spinners-common.css"},
],
all_files=True,
)


def with_spinner(el: Tag) -> Tag:
"""
Enable a loading spinner for a given output. These spinners will sit directly on the
output itself rather than in the upper corner.

Parameters
----------

el
The element to add the spinner to. Typically an output element like a
plot or table.

Returns
-------
:
Element with the class "show-spinner" added to it.

Examples
--------

```{python}
#|eval: false
ui.with_spinner(ui.output_plot("plot")),
```
"""
el.attrs["class"] = el.attrs.get("class", "") + " show-spinner"
return el
3 changes: 3 additions & 0 deletions shiny/www/shared/loading-spinners/ball.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions shiny/www/shared/loading-spinners/disc-spinner.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions shiny/www/shared/loading-spinners/dot-track-spinner.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions shiny/www/shared/loading-spinners/dots-spinner.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
75 changes: 75 additions & 0 deletions shiny/www/shared/loading-spinners/spinners-common.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
We replace the default loading/recalculating state of lowering the opacity with a
background blur effect. It's a bit more subtle and also doesn't affect spinners themselves
since they sit above the blur filter.
*/
.recalculating {
/* Used so that our psuedo-elements can be positioned with respect to the output */
position: relative;

/* Override the default .recalculating style */
opacity: 1;
}

.recalculating::before {
content: "";
position: absolute;
inset: 0;
z-index: 1000;
animation-name: shiny-loading-spinner-fade-in;
animation-duration: var(--_shiny-spinner-blur-speed);
animation-timing-function: var(--_shiny-spinner-blur-easing);
animation-delay: var(--_shiny-spinner-delay);
/* This makes it so the blur stays after animating in */
animation-fill-mode: forwards;
}

/*
Various keyframe animations available to use for spinner animations.
*/

@keyframes shiny-loading-spinner-fade-in {
from {
-webkit-backdrop-filter: blur(0px);
backdrop-filter: blur(0px);
}
to {
-webkit-backdrop-filter: blur(3px);
backdrop-filter: blur(3px);
}
}

@keyframes shiny-loading-spinner-spin {
0% {
scale: 1;
rotate: 0deg;
}
100% {
scale: 1;
rotate: 360deg;
}
}

@keyframes shiny-loading-spinner-bounce {
0% {
animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
translate: 0 calc(var(--_shiny-spinner-size) * (5 / 24));
scale: 1 1;
}
46.875% {
translate: 0 calc(var(--_shiny-spinner-size) * (20 / 24));
scale: 1 1;
}
50% {
animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
translate: 0 calc(var(--_shiny-spinner-size) * (20.5 / 24));
scale: 1.2 0.85;
}
53.125% {
scale: 1 1;
}
100% {
translate: 0 calc(var(--_shiny-spinner-size) * (5 / 24));
scale: 1 1;
}
}
39 changes: 39 additions & 0 deletions shiny/www/shared/loading-spinners/spinners-element-level.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* Override the default styles since they would make the spinner fade as well */
.recalculating {
--_shiny-spinner-color: var(
--shiny-spinner-color,
var(--bs-primary, #007bc2)
);
--_shiny-spinner-svg: var(--shiny-spinner-svg, url(tadpole-spinner.svg));
--_shiny-spinner-size: var(--shiny-spinner-size, 80px);
--_shiny-spinner-easing: var(--shiny-spinner-easing, ease-in-out);
--_shiny-spinner-speed: var(--shiny-spinner-speed, 2s);
--_shiny-spinner-delay: var(--shiny-spinner-delay, 0.1s);
--_shiny-spinner-animation: var(
--shiny-spinner-animation,
shiny-loading-spinner-spin
);

--_shiny-spinner-blur-speed: var(--shiny-spinner-blur-speed, 0.2s);
--_shiny-spinner-blur-easing: var(--shiny-spinner-blur-easing, ease-in);
}

.recalculating::after {
content: "";
background: var(--_shiny-spinner-color, gray);
-webkit-mask: var(--_shiny-spinner-svg) center/contain no-repeat;
mask: var(--_shiny-spinner-svg) center/contain no-repeat;
width: var(--_shiny-spinner-size);
height: var(--_shiny-spinner-size);
position: absolute;
inset: calc(50% - var(--_shiny-spinner-size) / 2);

scale: 0;
animation-name: var(--_shiny-spinner-animation);
animation-duration: var(--_shiny-spinner-speed);
animation-iteration-count: infinite;
animation-timing-function: var(--_shiny-spinner-easing);
animation-delay: var(--_shiny-spinner-delay);

z-index: 1001;
}
Loading