Skip to content

Commit a888ca2

Browse files
authored
Add support for filling layout (#115)
1 parent 9b2dbd2 commit a888ca2

File tree

11 files changed

+217
-60
lines changed

11 files changed

+217
-60
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [UNRELEASED]
99

10+
* Widgets now `fill` inside of a `fillable` container by default. For examples, see the [ipyleaflet](https://github.com/posit-dev/py-shinywidgets/blob/main/examples/ipyleaflet/app.py), [plotly](https://github.com/posit-dev/py-shinywidgets/blob/main/examples/plotly/app.py), or other [output](https://github.com/posit-dev/py-shinywidgets/blob/main/examples/outputs/app.py) examples. If this intelligent filling isn't desirable, either provide a `height` or `fillable=False` on `output_widget()`. (#115)
1011
* `as_widget()` uses the new `altair.JupyterChart()` to coerce `altair.Chart()` into a `ipywidgets.widgets.Widget` instance. (#120)
1112

1213
## [0.2.2] - 2023-10-31

examples/ipyleaflet/app.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
from shiny import *
2-
from shinywidgets import output_widget, register_widget, reactive_read
31
import ipyleaflet as L
42
from htmltools import css
3+
from shiny import *
4+
5+
from shinywidgets import output_widget, reactive_read, register_widget
56

6-
app_ui = ui.page_fluid(
7+
app_ui = ui.page_fillable(
78
ui.div(
89
ui.input_slider("zoom", "Map zoom level", value=4, min=1, max=10),
910
ui.output_text("map_bounds"),

examples/outputs/app.py

+40-32
Original file line numberDiff line numberDiff line change
@@ -4,42 +4,41 @@
44

55
from shinywidgets import *
66

7-
app_ui = ui.page_fluid(
8-
bokeh_dependency(),
9-
ui.layout_sidebar(
10-
ui.panel_sidebar(
11-
ui.input_radio_buttons(
12-
"framework",
13-
"Choose an ipywidget package",
14-
[
15-
"qgrid",
16-
"ipyleaflet",
17-
"pydeck",
18-
"altair",
19-
"plotly",
20-
"bokeh",
21-
"bqplot",
22-
"ipychart",
23-
"ipywebrtc",
24-
# TODO: fix me
25-
# "ipyvolume",
26-
],
27-
selected="ipyleaflet",
28-
)
29-
),
30-
ui.panel_main(
31-
ui.output_ui("figure"),
32-
),
7+
app_ui = ui.page_sidebar(
8+
ui.sidebar(
9+
ui.input_radio_buttons(
10+
"framework",
11+
"Choose a widget",
12+
[
13+
"altair",
14+
"plotly",
15+
"ipyleaflet",
16+
"pydeck",
17+
"ipysigma",
18+
"bokeh",
19+
"bqplot",
20+
"ipychart",
21+
"ipywebrtc",
22+
# TODO: fix ipyvolume, qgrid
23+
],
24+
selected="altair",
25+
)
3326
),
34-
title="ipywidgets in Shiny",
27+
bokeh_dependency(),
28+
ui.output_ui("figure", fill=True, fillable=True),
29+
title="Hello Jupyter Widgets in Shiny for Python",
3530
)
3631

3732

3833
def server(input: Inputs, output: Outputs, session: Session):
3934
@output(id="figure")
4035
@render.ui
4136
def _():
42-
return output_widget(input.framework())
37+
return ui.card(
38+
ui.card_header(input.framework()),
39+
output_widget(input.framework()),
40+
full_screen=True,
41+
)
4342

4443
@output(id="ipyleaflet")
4544
@render_widget
@@ -122,11 +121,12 @@ def _():
122121
@output(id="plotly")
123122
@render_widget
124123
def _():
125-
import plotly.graph_objects as go
124+
import plotly.express as px
126125

127-
return go.FigureWidget(
128-
data=[go.Bar(y=[2, 1, 3])],
129-
layout_title_text="A Figure Displayed with fig.show()",
126+
return px.scatter(
127+
x=np.random.randn(100),
128+
y=np.random.randn(100),
129+
color=np.random.randn(100),
130130
)
131131

132132
@output(id="bqplot")
@@ -212,6 +212,14 @@ def _():
212212
x, y, z, u, v, w = np.random.random((6, 1000)) * 2 - 1
213213
return quickquiver(x, y, z, u, v, w, size=5)
214214

215+
@output(id="ipysigma")
216+
@render_widget
217+
def _():
218+
import igraph as ig
219+
from ipysigma import Sigma
220+
g = ig.Graph.Famous('Zachary')
221+
return Sigma(g, node_size=g.degree, node_color=g.betweenness(), node_color_gradient='Viridis')
222+
215223
@output(id="pydeck")
216224
@render_widget
217225
def _():

examples/plotly/app.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import numpy as np
2+
import plotly.graph_objs as go
3+
from shiny import *
24
from sklearn.linear_model import LinearRegression
35

4-
from shiny import *
56
from shinywidgets import output_widget, register_widget
6-
import plotly.graph_objs as go
77

88
# Generate some data and fit a linear regression
99
n = 10000
@@ -13,7 +13,7 @@
1313
fit = LinearRegression().fit(x.reshape(-1, 1), dat[1])
1414
xgrid = np.linspace(start=min(x), stop=max(x), num=30)
1515

16-
app_ui = ui.page_fluid(
16+
app_ui = ui.page_fillable(
1717
ui.input_checkbox("show_fit", "Show fitted line", value=True),
1818
output_widget("scatterplot"),
1919
)

examples/pydeck/app.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import pydeck as pdk
2-
32
from shiny import *
3+
44
from shinywidgets import *
55

6-
app_ui = ui.page_fluid(
6+
app_ui = ui.page_fillable(
77
ui.input_slider("zoom", "Zoom", 0, 20, 6, step=1),
88
output_widget("pydeck")
99
)

js/src/output.ts

+59-13
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ class IPyWidgetOutput extends Shiny.OutputBinding {
7575
Shiny.unbindAll(el);
7676
this.renderError(el, err);
7777
}
78-
renderValue(el: HTMLElement, data): void {
78+
async renderValue(el: HTMLElement, data): Promise<void> {
7979

8080
// Allow for a None/null value to hide the widget (css inspired by htmlwidgets)
8181
if (!data) {
@@ -87,23 +87,69 @@ class IPyWidgetOutput extends Shiny.OutputBinding {
8787

8888
// At this time point, we should've already handled an 'open' message, and so
8989
// the model should be ready to use
90-
const model = manager.get_model(data.model_id);
90+
const model = await manager.get_model(data.model_id);
9191
if (!model) {
9292
throw new Error(`No model found for id ${data.model_id}`);
9393
}
9494

95-
model.then((m) => {
96-
const view = manager.create_view(m, {});
97-
view.then(v => {
98-
manager.display_view(v, {el: el}).then(() => {
99-
// TODO: It would be better to .close() the widget here, but
100-
// I'm not sure how to do that yet (at least client-side)
101-
while (el.childNodes.length > 1) {
102-
el.removeChild(el.childNodes[0]);
103-
}
104-
})
105-
});
95+
const view = await manager.create_view(model, {});
96+
await manager.display_view(view, {el: el});
97+
98+
// Don't allow more than one .lmWidget container, which can happen
99+
// when the view is displayed more than once
100+
// TODO: It's probably better to get view(s) from m.views and .remove() them
101+
while (el.childNodes.length > 1) {
102+
el.removeChild(el.childNodes[0]);
103+
}
104+
105+
// Only carry the potential to fill (i.e., add fill classes)
106+
// if `output_widget(fillable=True)`
107+
if (!el.classList.contains("html-fill-container")) return;
108+
109+
// And only fill if the `Widget.layout.height` isn't set
110+
if (!data.fill) return;
111+
112+
// Make ipywidgets container (.lmWidget) a fill carrier
113+
// el should already be a fill carrier (done during markup generation)
114+
const lmWidget = el.children[0] as HTMLElement;
115+
lmWidget?.classList.add("html-fill-container", "html-fill-item");
116+
117+
// lmWidget's children is the actual widget implementation.
118+
// Ideally this would be a single element, but some widget
119+
// implementations (e.g., pydeck, altair) have multiple direct children.
120+
// It seems relatively safe to make all of them fill items, but there's
121+
// at least one case where it's problematic (pydeck)
122+
lmWidget.childNodes.forEach((child) => {
123+
if (!(child instanceof HTMLElement)) return;
124+
if (child.classList.contains("deckgl-ui-elements-overlay")) return;
125+
child.classList.add("html-fill-item");
126+
});
127+
128+
this._maybeResize(lmWidget);
129+
}
130+
_maybeResize(lmWidget: HTMLElement): void {
131+
const impl = lmWidget.children[0];
132+
if (impl.children.length > 0) {
133+
return this._doResize(impl);
134+
}
135+
136+
// Some widget implementation (e.g., ipyleaflet, pydeck) won't actually
137+
// have rendered to the DOM at this point, so wait until they do
138+
const mo = new MutationObserver((mutations) => {
139+
if (impl.children.length > 0) {
140+
mo.disconnect();
141+
this._doResize(impl);
142+
}
106143
});
144+
145+
mo.observe(impl, {childList: true});
146+
}
147+
_doResize(impl: Element): void {
148+
// Trigger resize event to force layout (setTimeout() is needed for altair)
149+
// TODO: debounce this call?
150+
setTimeout(() => {
151+
window.dispatchEvent(new Event('resize'))
152+
}, 0);
107153
}
108154
}
109155

shinywidgets/_as_widget.py

+7
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ def as_widget_bokeh(x: object) -> Optional[Widget]:
4848
"Install the jupyter_bokeh package to use bokeh with shinywidgets."
4949
)
5050

51+
# TODO: ideally we'd do this in set_layout_defaults() but doing
52+
# `BokehModel(x)._model.sizing_mode = "stretch_both"`
53+
# there, but that doesn't seem to work??
54+
from bokeh.plotting import figure
55+
if isinstance(x, figure):
56+
x.sizing_mode = "stretch_both"
57+
5158
return BokehModel(x) # type: ignore
5259

5360

shinywidgets/_dependencies.py

+1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def output_binding_dependency() -> HTMLDependency:
5454
version=__version__,
5555
source={"package": "shinywidgets", "subdir": "static"},
5656
script={"src": "output.js"},
57+
stylesheet={"href": "shinywidgets.css"},
5758
)
5859

5960

0 commit comments

Comments
 (0)