Skip to content

Commit 811f8c7

Browse files
nstrayercpsievert
andauthored
Add busy indicators (aka spinners) (#918)
Co-authored-by: Carson <cpsievert1@gmail.com>
1 parent 7cd9e5e commit 811f8c7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+521
-23
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
### New features
1313

14+
* Added busy indicators to provide users with a visual cue when the server is busy calculating outputs or otherwise serving requests to the client. More specifically, a spinner is shown on each calculating/recalculating output, and a pulsing banner is shown at the top of the page when the app is otherwise busy. Use the new `ui.busy_indicator.options()` function to customize the appearance of the busy indicators and `ui.busy_indicator.use()` to disable/enable them. (#918)
15+
1416
* Added support for creating modules using Shiny Express syntax, and using modules in Shiny Express apps. (#1220)
1517

1618
* `ui.page_*()` functions gain a `theme` argument that allows you to replace the Bootstrap CSS file with a new CSS file. `theme` can be a local CSS file, a URL, or a [shinyswatch](https://posit-dev.github.io/py-shinyswatch) theme. In Shiny Express apps, `theme` can be set via `express.ui.page_opts()`. (#1334)

docs/_quartodoc-core.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ quartodoc:
108108
- ui.include_js
109109
- ui.insert_ui
110110
- ui.remove_ui
111+
- ui.busy_indicators.use
112+
- ui.busy_indicators.options
111113
- ui.fill.as_fillable_container
112114
- ui.fill.as_fill_item
113115
- ui.fill.remove_all_fill

docs/_quartodoc-express.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ quartodoc:
165165
- name: express.ui.tags # uses tags.rst template
166166
children: embedded
167167
- express.ui.TagList # uses class.rst template
168+
- express.ui.busy_indicators.use
169+
- express.ui.busy_indicators.options
168170
# TODO: should these be included?
169171
# - express.ui.fill.as_fillable_container
170172
# - express.ui.fill.as_fill_item

examples/busy_indicators/app.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# pyright:basic
2+
import time
3+
4+
import numpy as np
5+
import seaborn as sns
6+
7+
from shiny import App, module, reactive, render, ui
8+
9+
10+
# -- Reusable card module --
11+
@module.ui
12+
def card_ui(spinner_type):
13+
return ui.card(
14+
ui.busy_indicators.options(spinner_type=spinner_type),
15+
ui.card_header("Spinner: " + spinner_type),
16+
ui.output_plot("plot"),
17+
)
18+
19+
20+
@module.server
21+
def card_server(input, output, session, rerender):
22+
@render.plot
23+
def plot():
24+
rerender()
25+
time.sleep(1)
26+
sns.lineplot(x=np.arange(100), y=np.random.randn(100))
27+
28+
29+
# -- Main app --
30+
app_ui = ui.page_fillable(
31+
ui.input_task_button("rerender", "Re-render"),
32+
ui.layout_columns(
33+
card_ui("ring", "ring"),
34+
card_ui("bars", "bars"),
35+
card_ui("dots", "dots"),
36+
card_ui("pulse", "pulse"),
37+
col_widths=6,
38+
),
39+
)
40+
41+
42+
def server(input, output, session):
43+
44+
@reactive.calc
45+
def rerender():
46+
return input.rerender()
47+
48+
card_server("ring", rerender=rerender)
49+
card_server("bars", rerender=rerender)
50+
card_server("dots", rerender=rerender)
51+
card_server("pulse", rerender=rerender)
52+
53+
54+
app = App(app_ui, server)

scripts/htmlDependencies.R

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,3 +344,29 @@ ignore <- system(
344344
paste0("cd ", js_path, " && npm install && npm run build"),
345345
intern = TRUE
346346
)
347+
348+
349+
# ------------------------------------------------------------------------------
350+
message("Save spinner types")
351+
spinner_types <- Sys.glob(file.path(www_shared, "busy-indicators", "spinners", "*.svg"))
352+
spinner_types <- tools::file_path_sans_ext(basename(spinner_types))
353+
354+
template <- r"(# Generated by scripts/htmlDependencies.R: do not edit by hand
355+
356+
from __future__ import annotations
357+
358+
from typing import Literal
359+
360+
BusySpinnerType = Literal[
361+
%s
362+
])"
363+
364+
py_lines <- function(x) {
365+
x <- paste(sprintf('"%s"', x), collapse = ",\n ")
366+
paste0(" ", x, ",")
367+
}
368+
369+
writeLines(
370+
sprintf(template, py_lines(spinner_types)),
371+
file.path(shiny_path, "ui", "_busy_spinner_types.py")
372+
)

shiny/_versions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
shiny_html_deps = "1.8.1.9000"
1+
shiny_html_deps = "1.8.1.9001"
22
bslib = "0.7.0.9000"
33
htmltools = "0.5.8.9000"
44
bootstrap = "5.3.1"
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import os
2+
import time
3+
4+
import numpy as np
5+
import seaborn as sns
6+
7+
from shiny import App, render, ui
8+
9+
app_ui = ui.page_sidebar(
10+
ui.sidebar(
11+
ui.input_selectize(
12+
"indicator_types",
13+
"Busy indicator types",
14+
["spinners", "pulse"],
15+
multiple=True,
16+
selected=["spinners", "pulse"],
17+
),
18+
ui.download_button("download", "Download source"),
19+
),
20+
ui.card(
21+
ui.card_header(
22+
"Plot that takes a few seconds to render",
23+
ui.input_task_button("simulate", "Simulate"),
24+
class_="d-flex justify-content-between align-items-center",
25+
),
26+
ui.output_plot("plot"),
27+
),
28+
ui.busy_indicators.options(spinner_type="bars3"),
29+
ui.output_ui("indicator_types_ui"),
30+
title="Busy indicators demo",
31+
)
32+
33+
34+
def server(input):
35+
36+
@render.plot
37+
def plot():
38+
input.simulate()
39+
time.sleep(3)
40+
sns.lineplot(x=np.arange(100), y=np.random.randn(100))
41+
42+
@render.ui
43+
def indicator_types_ui():
44+
return ui.busy_indicators.use(
45+
spinners="spinners" in input.indicator_types(),
46+
pulse="pulse" in input.indicator_types(),
47+
)
48+
49+
@render.download
50+
def download():
51+
time.sleep(3)
52+
path = os.path.join(os.path.dirname(__file__), "app-core.py")
53+
return path
54+
55+
56+
app = App(app_ui, server)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import os
2+
import time
3+
4+
import numpy as np
5+
import seaborn as sns
6+
7+
from shiny.express import input, render, ui
8+
9+
ui.page_opts(title="Busy spinner demo")
10+
11+
with ui.sidebar():
12+
ui.input_selectize(
13+
"indicator_types",
14+
"Busy indicator types",
15+
["spinners", "pulse"],
16+
multiple=True,
17+
selected=["spinners", "pulse"],
18+
)
19+
20+
@render.download
21+
def download():
22+
time.sleep(3)
23+
path = os.path.join(os.path.dirname(__file__), "app-express.py")
24+
return path
25+
26+
27+
with ui.card():
28+
ui.card_header(
29+
"Plot that takes a few seconds to render",
30+
ui.input_task_button("simulate", "Simulate"),
31+
class_="d-flex justify-content-between align-items-center",
32+
)
33+
34+
@render.plot
35+
def plot():
36+
input.simulate()
37+
time.sleep(3)
38+
sns.lineplot(x=np.arange(100), y=np.random.randn(100))
39+
40+
41+
ui.busy_indicators.options(spinner_type="bars3")
42+
43+
44+
@render.ui
45+
def indicator_types_ui():
46+
return ui.busy_indicators.use(
47+
spinners="spinners" in input.indicator_types(),
48+
pulse="pulse" in input.indicator_types(),
49+
)

shiny/express/ui/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
fill,
3434
)
3535

36+
from ...ui import (
37+
busy_indicators,
38+
)
39+
3640
from ...ui import (
3741
AccordionPanel,
3842
AnimationOptions,
@@ -176,6 +180,7 @@
176180
"strong",
177181
"tags",
178182
# Submodules
183+
"busy_indicators",
179184
"fill",
180185
# Imports from ...ui
181186
"AccordionPanel",

shiny/html_dependencies.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,26 @@
44

55
from htmltools import HTMLDependency
66

7+
from . import __version__
8+
from .ui._html_deps_py_shiny import busy_indicators_dep
9+
710

811
def shiny_deps() -> list[HTMLDependency]:
912
deps = [
1013
HTMLDependency(
1114
name="shiny",
12-
version="0.0.1",
15+
version=__version__,
1316
source={"package": "shiny", "subdir": "www/shared/"},
1417
script={"src": "shiny.js"},
1518
stylesheet={"href": "shiny.min.css"},
16-
)
19+
),
20+
busy_indicators_dep(),
1721
]
1822
if os.getenv("SHINY_DEV_MODE") == "1":
1923
deps.append(
2024
HTMLDependency(
2125
"shiny-devmode",
22-
version="0.0.1",
26+
version=__version__,
2327
head="<script>window.__SHINY_DEV_MODE__ = true;</script>",
2428
)
2529
)

shiny/session/_session.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,15 @@ async def uploadEnd(job_id: str, input_id: str) -> None:
784784
# ==========================================================================
785785
async def _handle_request(
786786
self, request: Request, action: str, subpath: Optional[str]
787+
) -> ASGIApp:
788+
self._send_message_sync({"busy": "busy"})
789+
try:
790+
return await self._handle_request_impl(request, action, subpath)
791+
finally:
792+
self._send_message_sync({"busy": "idle"})
793+
794+
async def _handle_request_impl(
795+
self, request: Request, action: str, subpath: Optional[str]
787796
) -> ASGIApp:
788797
if action == "upload" and request.method == "POST":
789798
if subpath is None or subpath == "":

shiny/ui/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
# Expose the fill module for extended usage: ex: ui.fill.as_fill_item(x).
3838
from . import fill
3939

40+
# Export busy_indicators module
41+
from . import busy_indicators
42+
4043
from ._accordion import (
4144
AccordionPanel,
4245
accordion,
@@ -355,6 +358,7 @@
355358
"em",
356359
"hr",
357360
# Submodules
361+
"busy_indicators",
358362
"fill",
359363
# utils
360364
"js_eval",

shiny/ui/_busy_spinner_types.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by scripts/htmlDependencies.R: do not edit by hand
2+
3+
from __future__ import annotations
4+
5+
from typing import Literal
6+
7+
BusySpinnerType = Literal[
8+
"bars",
9+
"bars2",
10+
"bars3",
11+
"dots",
12+
"dots2",
13+
"dots3",
14+
"pulse",
15+
"pulse2",
16+
"pulse3",
17+
"ring",
18+
"ring2",
19+
"ring3",
20+
]

shiny/ui/_html_deps_py_shiny.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from htmltools import HTMLDependency
44

55
from .. import __version__
6+
from . import busy_indicators
67

78
"""
89
HTML dependencies for internal dependencies such as dataframe or text area's autoresize.
@@ -52,3 +53,15 @@ def spin_dependency() -> HTMLDependency:
5253
source={"package": "shiny", "subdir": "www/shared/py-shiny/spin"},
5354
stylesheet={"href": "spin.css"},
5455
)
56+
57+
58+
def busy_indicators_dep() -> HTMLDependency:
59+
return HTMLDependency(
60+
"shiny-busy-indicators",
61+
__version__,
62+
source={"package": "shiny", "subdir": "www/shared/busy-indicators"},
63+
stylesheet={"href": "busy-indicators.css"},
64+
script={"src": "busy-indicators.js"},
65+
head=busy_indicators.use(), # Enable busy indicators by default.
66+
all_files=True,
67+
)

0 commit comments

Comments
 (0)