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 44 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
2 changes: 2 additions & 0 deletions docs/_quartodoc-core.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ quartodoc:
- ui.include_js
- ui.insert_ui
- ui.remove_ui
- ui.busy_indicators.use
- ui.busy_indicators.spinner_options
- ui.fill.as_fillable_container
- ui.fill.as_fill_item
- ui.fill.remove_all_fill
Expand Down
2 changes: 2 additions & 0 deletions docs/_quartodoc-express.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ quartodoc:
- name: express.ui.tags # uses tags.rst template
children: embedded
- express.ui.TagList # uses class.rst template
- express.ui.busy_indicators.use
- express.ui.busy_indicators.spinner_options
# TODO: should these be included?
# - express.ui.fill.as_fillable_container
# - express.ui.fill.as_fill_item
Expand Down
111 changes: 111 additions & 0 deletions examples/busy_indicators/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# pyright:basic
import asyncio
import concurrent.futures
import time

import matplotlib.pyplot as mpl
import numpy as np
import seaborn as sns
from faicons import icon_svg

from shiny import App, module, reactive, render, ui

# Execute each extended task on a different thread.
pool = concurrent.futures.ThreadPoolExecutor()

# Use Agg backend for matplotlib so that it doesn't try to open a window.
mpl.switch_backend("Agg")


# -- Reusable card module --
@module.ui
def card_ui(title="Tadpole", spinner_type="tadpole"):
return ui.card(
ui.card_header(
title,
ui.input_task_button("simulate", "Simulate", icon=icon_svg("shuffle")),
class_="d-flex justify-content-between align-items-center",
),
ui.busy_indicators.spinner_options(
spinner_type, css_selector="." + spinner_type
),
ui.output_plot("plot"),
class_=spinner_type,
)


@module.server
def card_server(input, output, session, length, simulate_all):

def do_plot(wait_time=0.5):
time.sleep(wait_time)

# Without ax, seaborn works through side effects, so supply the ax to avoid
# one figure from affecting another.
fig, ax = mpl.subplots(1, 1)
sns.lineplot(x=np.arange(100), y=np.random.randn(100), ax=ax)
return fig

@ui.bind_task_button(button_id="simulate")
@reactive.extended_task
async def plot_task(wait_time=1):
loop = asyncio.get_event_loop()
return await loop.run_in_executor(pool, do_plot, wait_time)

@reactive.effect
@reactive.event(input.simulate, ignore_none=False)
def _():
plot_task(1 if input.simulate() == 0 else length())

@reactive.effect
@reactive.event(simulate_all)
def _():
plot_task(length())

@render.plot
def plot():
return plot_task.result()


# -- Main app --
app_ui = ui.page_sidebar(
ui.sidebar(
ui.input_task_button("simulate_all", "Simulate all", icon=icon_svg("shuffle")),
ui.input_selectize(
"indicator_types",
"Busy indicator types",
["spinners", "pulse", "cursor"],
multiple=True,
selected=["spinners", "pulse"],
),
ui.input_slider("length", "Simulation length", 0, 500, 5),
),
ui.layout_columns(
card_ui("a", "Tadpole", "tadpole"),
card_ui("b", "Disc", "disc"),
card_ui("c", "Dots", "dots"),
card_ui("d", "Dot track", "dot-track"),
col_widths=[6, 6],
),
card_ui("e", "Ball", "bounce"),
ui.output_ui("indicator_types_ui"),
fillable=True,
title="Busy indicators + extended tasks = ❤️",
)


def server(input, output, session):

@render.ui
def indicator_types_ui():
return ui.busy_indicators.use(input.indicator_types())

card_server("a", length=input.length, simulate_all=input.simulate_all)
card_server("b", length=input.length, simulate_all=input.simulate_all)
card_server("c", length=input.length, simulate_all=input.simulate_all)
card_server("d", length=input.length, simulate_all=input.simulate_all)
card_server("e", length=input.length, simulate_all=input.simulate_all)


app = App(app_ui, server)
app.on_shutdown(pool.shutdown)
8 changes: 8 additions & 0 deletions js/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ const opts: Array<BuildOptions> = [
plugins: [sassPlugin({ type: "css", sourceMap: false })],
metafile: true,
},
{
entryPoints: {
"busy-indicators/busy-indicators": "busy-indicators/busy-indicators.scss",
},
loader: { ".svg": "dataurl" },
plugins: [sassPlugin({ type: "css", sourceMap: false })],
metafile: true,
},
];

// Run function to avoid top level await
Expand Down
3 changes: 3 additions & 0 deletions js/busy-indicators/ball.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
181 changes: 181 additions & 0 deletions js/busy-indicators/busy-indicators.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/*
CSS for shiny.ui.busy_indicators module.

The general idea is to leverage the `recalculating` class that shiny.js adds to each
output when it's recalculating and add a spinner to the `::after` pseudo-element.
*/

/* "Built-in" spinner types */
/* Part of the reason why they're all here is so we can embed the SVGs in the CSS */
:root {
--_shiny-spinner-type-tadpole: url(tadpole-spinner.svg);
--_shiny-spinner-type-disc: url(disc-spinner.svg);
--_shiny-spinner-type-dots: url(dots-spinner.svg);
--_shiny-spinner-type-dot-track: url(dot-track-spinner.svg);
--_shiny-spinner-type-bounce: url(ball.svg);
}

/* Mixin for pulsing banner since we need to include in several different contexts */
@mixin shiny-page-busy {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
content: ""; /* Used in a ::after context */
animation-name: busy-page-pulse;
animation-duration: 20s;
animation-iteration-count: infinite;
background: linear-gradient(45deg, #fff, #505050, #007bc2, #74149c, #007bc2, #505050, #fff);
background-size: 800%;
}

[data-shiny-busy-indicator-types*="spinners"] {
.recalculating {
position: relative;

&::after {
position: absolute;

/* Spinner options */
--_shiny-spinner-mask-img: var(--shiny-spinner-mask-img, var(--_shiny-spinner-type-tadpole));
--_shiny-spinner-color: var(--shiny-spinner-color, var(--bs-primary, #007bc2));
--_shiny-spinner-size: var(--shiny-spinner-size, 40px);
--_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.5s);
--_shiny-spinner-animation: var(--shiny-spinner-animation, shiny-busy-spinner-spin);

/* Apply the spinner type as a mask */
mask-image: var(--_shiny-spinner-mask-img);
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
-webkit-mask-image: var(--_shiny-spinner-mask-img);
-webkit-mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;

/* Color, sizing, & positioning */
background: var(--_shiny-spinner-color);
width: var(--_shiny-spinner-size);
height: var(--_shiny-spinner-size);
inset: calc(50% - var(--_shiny-spinner-size) / 2);

/* Animation */
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);

content: "";
scale: 0;
}

/*
shiny.css puts `opacity: 0.3` on .recalculating, which unfortunately applies to
the spinner. Undo that, but still apply (smaller) opacity to immediate children
that aren't recalculating.
*/
opacity: 1;
> *:not(.recalculating) {
opacity: 0.2;
}

/*
Disable spinner on uiOutput() mainly because (for other reasons) it has
`display:contents`, which breaks the ::after positioning.
Note that, even if we could position it, we'd probably want to disable it
if it has recalculating children.
*/
&.shiny-html-output::after {
display: none;
}
}
}

/* Pulsing page-level banner */
[data-shiny-busy-indicator-types*="pulse"] {
&.shiny-busy,
&.shiny-not-yet-idle,
&:has(.recalculating) {
&::after {
@include shiny-page-busy;
}
}
}

/****************************************
In cursor mode, if the page has anything recalculating, show progress cursor
*****************************************/
[data-shiny-busy-indicator-types*="cursor"] {
&.shiny-busy,
&.shiny-not-yet-idle,
&:has(.recalculating) {
cursor: progress;
}

/* Show a page-level spinner if on mobile */
@media (max-width: 767px) {
&.shiny-busy,
&.shiny-not-yet-idle,
&:has(.recalculating) {
&::after {
@include shiny-page-busy;
}
}
}
}

/* Keyframes behind most spinner types */
@keyframes shiny-busy-spinner-spin {
0% {
scale: 1;
rotate: 0deg;
}
100% {
scale: 1;
rotate: 360deg;
}
}

/* For busy_indicators.spinner_options(type="bounce") */
@keyframes shiny-busy-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;
}
}

/* Keyframes for the pulsing banner */
@keyframes busy-page-pulse {
0% {
background-position: 0px 50%;
}

50% {
background-position: 100% 50%;
}

100% {
background-position: 0px 50%;
}
}
5 changes: 5 additions & 0 deletions js/busy-indicators/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 js/busy-indicators/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 js/busy-indicators/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.
5 changes: 5 additions & 0 deletions js/busy-indicators/tadpole-spinner.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading