Skip to content

Commit 915d642

Browse files
author
deeleeramone
committed
fix plotly template merge
1 parent 691d3cc commit 915d642

13 files changed

Lines changed: 1422 additions & 41 deletions

File tree

pywry/docs/docs/guides/plotly.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,45 @@ To change theme dynamically:
206206
handle.emit("pywry:update-theme", {"theme": "light"})
207207
```
208208

209+
### Custom Per-Theme Templates
210+
211+
By default, PyWry applies the built-in `plotly_dark` or `plotly_white` template based on the current theme. To customize chart colors *per theme* while preserving automatic switching, use `template_dark` and `template_light` on `PlotlyConfig`:
212+
213+
```python
214+
from pywry import PlotlyConfig
215+
216+
config = PlotlyConfig(
217+
template_dark={
218+
"layout": {
219+
"paper_bgcolor": "#1a1a2e",
220+
"plot_bgcolor": "#16213e",
221+
"font": {"color": "#e0e0e0"},
222+
}
223+
},
224+
template_light={
225+
"layout": {
226+
"paper_bgcolor": "#ffffff",
227+
"plot_bgcolor": "#f0f0f0",
228+
"font": {"color": "#222222"},
229+
}
230+
},
231+
)
232+
233+
handle = app.show_plotly(fig, config=config)
234+
```
235+
236+
**How it works:**
237+
238+
- Your overrides are **deep-merged** on top of the built-in base template (`plotly_dark` or `plotly_white`).
239+
- **User values always win** on conflict. Anything you don't set is inherited from the base.
240+
- Both templates are stored on the chart and automatically selected when the theme toggles.
241+
- Arrays (e.g., colorways) are replaced entirely, not element-merged.
242+
243+
You can also set only one side — e.g., `template_dark` alone — and the other theme will use the unmodified base.
244+
245+
!!! tip
246+
Use `template_dark` / `template_light` instead of setting `fig.update_layout(template=...)` directly. The latter gets overwritten on theme switch; the former survives toggles.
247+
209248
## Next Steps
210249

211250
- **[`PlotlyConfig` Reference](../reference/plotly-config.md)** — All configuration options

pywry/docs/docs/guides/theming.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,40 @@ Target specific components by their `component_id`:
230230

231231
## Plotly Theming
232232

233-
PyWry automatically switches Plotly figures between `plotly_dark` and `plotly_white` templates when the theme changes. For custom Plotly styling, set layout properties on the figure:
233+
PyWry automatically switches Plotly figures between `plotly_dark` and `plotly_white` templates when the theme changes.
234+
235+
### Custom Per-Theme Templates
236+
237+
To customize chart colors for each theme — while still getting automatic switching — use `template_dark` and `template_light` on `PlotlyConfig`:
238+
239+
```python
240+
from pywry import PlotlyConfig
241+
242+
config = PlotlyConfig(
243+
template_dark={
244+
"layout": {
245+
"paper_bgcolor": "#1a1a2e",
246+
"plot_bgcolor": "#16213e",
247+
"font": {"color": "#e0e0e0"},
248+
}
249+
},
250+
template_light={
251+
"layout": {
252+
"paper_bgcolor": "#ffffff",
253+
"plot_bgcolor": "#f0f0f0",
254+
"font": {"color": "#222222"},
255+
}
256+
},
257+
)
258+
259+
app.show_plotly(fig, config=config)
260+
```
261+
262+
Your overrides are **deep-merged** on top of the built-in base template — user values always win, and anything you don't set is inherited from the base. Both templates are stored on the chart element and automatically selected when the theme toggles via `pywry:update-theme`.
263+
264+
### Transparent Backgrounds
265+
266+
For charts that should use the window background color directly:
234267

235268
```python
236269
import plotly.graph_objects as go

pywry/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pywry"
3-
version = "2.0.0rc2"
3+
version = "2.0.0rc3"
44
description = "A lightweight and blazingly fast, cross-platform, WebView rendering engine and desktop UI toolkit for Python."
55
authors = [{ name = "PyWry", email = "pywry2@gmail.com" }]
66
license = { text = "Apache 2.0" }

pywry/pywry/frontend/src/chat-handlers.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1850,6 +1850,10 @@ function initChatHandlers(container, pywry) {
18501850
var templates = window.PYWRY_PLOTLY_TEMPLATES || {};
18511851
if (!layout.template) {
18521852
layout.template = templates['plotly_dark'] || null;
1853+
} else if (typeof layout.template === 'object') {
1854+
// User provided custom template - merge with dark theme base (user wins)
1855+
var base = templates['plotly_dark'] || {};
1856+
layout.template = window.__pywryDeepMerge ? window.__pywryDeepMerge(base, layout.template) : layout.template;
18531857
}
18541858
layout.autosize = true;
18551859
fig.layout = layout;

pywry/pywry/frontend/src/plotly-defaults.js

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,79 @@ if (!window.__PYWRY_COMPONENTS__) {
1010
window.__PYWRY_COMPONENTS__ = {};
1111
}
1212

13+
/**
14+
* Deep-merge two plain objects. Values in `overrides` win on conflict.
15+
* Arrays are NOT deep-merged — the override array replaces the base.
16+
* Used to layer user template customizations on top of a theme template.
17+
*/
18+
window.__pywryDeepMerge = function deepMerge(base, overrides) {
19+
if (!overrides || typeof overrides !== 'object') return base ? JSON.parse(JSON.stringify(base)) : {};
20+
if (!base || typeof base !== 'object') return JSON.parse(JSON.stringify(overrides));
21+
var result = JSON.parse(JSON.stringify(base));
22+
var keys = Object.keys(overrides);
23+
for (var i = 0; i < keys.length; i++) {
24+
var key = keys[i];
25+
var val = overrides[key];
26+
if (val !== null && typeof val === 'object' && !Array.isArray(val)
27+
&& result[key] !== null && typeof result[key] === 'object' && !Array.isArray(result[key])) {
28+
result[key] = deepMerge(result[key], val);
29+
} else {
30+
result[key] = (val !== null && typeof val === 'object') ? JSON.parse(JSON.stringify(val)) : val;
31+
}
32+
}
33+
return result;
34+
};
35+
36+
/**
37+
* Build a merged Plotly template: theme base + user overrides (user always wins).
38+
* Supports dual templates (separate dark/light overrides) — on theme toggle, the
39+
* correct per-theme user override is deep-merged on top of the built-in base.
40+
*
41+
* User overrides are persisted on the plot div so they survive theme switches.
42+
*
43+
* @param {HTMLElement} plotDiv - The Plotly chart DOM element.
44+
* @param {string} themeTemplateName - 'plotly_dark' or 'plotly_white'.
45+
* @param {object|null} userTemplate - Single user template to store (legacy / layout.template).
46+
* @param {object|null} userTemplateDark - User overrides specific to dark mode.
47+
* @param {object|null} userTemplateLight - User overrides specific to light mode.
48+
* @returns {object} The merged template.
49+
*/
50+
window.__pywryMergeThemeTemplate = function(plotDiv, themeTemplateName, userTemplate, userTemplateDark, userTemplateLight) {
51+
var templates = window.PYWRY_PLOTLY_TEMPLATES || {};
52+
var baseTemplate = templates[themeTemplateName] || {};
53+
54+
// Store dual templates if provided (first call / figure update)
55+
if (userTemplateDark && typeof userTemplateDark === 'object' && Object.keys(userTemplateDark).length > 0) {
56+
plotDiv.__pywry_user_template_dark__ = JSON.parse(JSON.stringify(userTemplateDark));
57+
}
58+
if (userTemplateLight && typeof userTemplateLight === 'object' && Object.keys(userTemplateLight).length > 0) {
59+
plotDiv.__pywry_user_template_light__ = JSON.parse(JSON.stringify(userTemplateLight));
60+
}
61+
62+
// Store single/legacy template if no dual templates given
63+
if (userTemplate && typeof userTemplate === 'object' && Object.keys(userTemplate).length > 0
64+
&& !userTemplateDark && !userTemplateLight) {
65+
plotDiv.__pywry_user_template__ = JSON.parse(JSON.stringify(userTemplate));
66+
}
67+
68+
// Pick the right user override for this theme mode
69+
var isDark = themeTemplateName.indexOf('dark') !== -1;
70+
var overrides = null;
71+
if (isDark && plotDiv.__pywry_user_template_dark__) {
72+
overrides = plotDiv.__pywry_user_template_dark__;
73+
} else if (!isDark && plotDiv.__pywry_user_template_light__) {
74+
overrides = plotDiv.__pywry_user_template_light__;
75+
} else {
76+
// Fallback to single/legacy template (applies to both modes)
77+
overrides = plotDiv.__pywry_user_template__;
78+
}
79+
80+
if (!overrides) return JSON.parse(JSON.stringify(baseTemplate));
81+
82+
// Deep-merge: base theme first, then user overrides on top (user wins)
83+
return window.__pywryDeepMerge(baseTemplate, overrides);
84+
};
85+
1386
/**
1487
* Register a Plotly chart instance with PyWry.
1588
* @param {string} chartId - The unique ID for this chart.
@@ -198,8 +271,27 @@ window.registerPyWryChart = registerPyWryChart;
198271
if (plotDiv && window.Plotly) {
199272
var figData = data.figure ? data.figure.data : data.data;
200273
var figLayout = data.figure ? data.figure.layout : data.layout;
201-
var config = Object.assign({displaylogo: false}, processPlotlyConfig(data.config || {}));
274+
var rawConfig = data.config || {};
275+
276+
// Extract per-theme templates before passing config to Plotly
277+
var userTemplateDark = rawConfig.templateDark || null;
278+
var userTemplateLight = rawConfig.templateLight || null;
279+
delete rawConfig.templateDark;
280+
delete rawConfig.templateLight;
281+
282+
var config = Object.assign({displaylogo: false}, processPlotlyConfig(rawConfig));
202283
if (figData) {
284+
// Merge user template with theme base (user always wins)
285+
var userTemplate = null;
286+
if (figLayout && figLayout.template && typeof figLayout.template === 'object') {
287+
userTemplate = figLayout.template;
288+
figLayout.template = null;
289+
}
290+
var themeName = plotDiv.__pywry_theme_template__ || 'plotly_dark';
291+
if (userTemplate || userTemplateDark || userTemplateLight) {
292+
figLayout = figLayout || {};
293+
figLayout.template = window.__pywryMergeThemeTemplate(plotDiv, themeName, userTemplate, userTemplateDark, userTemplateLight);
294+
}
203295
window.Plotly.react(plotDiv, figData, figLayout || {}, config);
204296
}
205297
} else {
@@ -305,15 +397,13 @@ window.registerPyWryChart = registerPyWryChart;
305397
container.classList.add(isDark ? 'pywry-theme-dark' : 'pywry-theme-light');
306398
}
307399

308-
// Re-render chart with new template
400+
// Re-render chart with merged template (theme base + user overrides)
309401
var plotDiv = findPlotDiv();
310402
if (plotDiv && window.Plotly && plotDiv.data && window.PYWRY_PLOTLY_TEMPLATES) {
311403
var templateName = isDark ? 'plotly_dark' : 'plotly_white';
312-
var template = window.PYWRY_PLOTLY_TEMPLATES[templateName];
313-
if (template) {
314-
var newLayout = Object.assign({}, plotDiv.layout || {}, { template: template });
315-
window.Plotly.newPlot(plotDiv, plotDiv.data, newLayout, plotDiv._fullLayout && plotDiv._fullLayout._config || {});
316-
}
404+
var mergedTemplate = window.__pywryMergeThemeTemplate(plotDiv, templateName);
405+
var newLayout = Object.assign({}, plotDiv.layout || {}, { template: mergedTemplate });
406+
window.Plotly.newPlot(plotDiv, plotDiv.data, newLayout, plotDiv._fullLayout && plotDiv._fullLayout._config || {});
317407
}
318408
}
319409
});

pywry/pywry/frontend/src/plotly-widget.js

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -581,10 +581,16 @@ function render({ model, el }) {
581581
const plotDiv = container.querySelector('.js-plotly-plot');
582582
if (plotDiv && window.Plotly && plotDiv.data) {
583583
const templateName = isDark ? 'plotly_dark' : 'plotly_white';
584-
const template = window.PYWRY_PLOTLY_TEMPLATES?.[templateName];
585-
if (template) {
586-
const newLayout = Object.assign({}, plotDiv.layout || {}, { template: template });
584+
if (window.__pywryMergeThemeTemplate) {
585+
const mergedTemplate = window.__pywryMergeThemeTemplate(plotDiv, templateName);
586+
const newLayout = Object.assign({}, plotDiv.layout || {}, { template: mergedTemplate });
587587
window.Plotly.newPlot(plotDiv, plotDiv.data, newLayout, plotDiv._fullLayout?._config || {});
588+
} else {
589+
const template = window.PYWRY_PLOTLY_TEMPLATES?.[templateName];
590+
if (template) {
591+
const newLayout = Object.assign({}, plotDiv.layout || {}, { template: template });
592+
window.Plotly.newPlot(plotDiv, plotDiv.data, newLayout, plotDiv._fullLayout?._config || {});
593+
}
588594
}
589595
}
590596
}
@@ -712,12 +718,39 @@ function render({ model, el }) {
712718
const figData = JSON.parse(figureJson);
713719
const config = figData.config || {};
714720

721+
// Extract per-theme user template overrides (PyWry extension)
722+
const userTemplateDark = config.templateDark || null;
723+
const userTemplateLight = config.templateLight || null;
724+
delete config.templateDark;
725+
delete config.templateLight;
726+
727+
// Extract single/legacy template from layout
728+
let userTemplate = null;
729+
const templates = window.PYWRY_PLOTLY_TEMPLATES || {};
730+
const isDark = model.get('theme') === 'dark';
731+
const themeTemplateName = isDark ? 'plotly_dark' : 'plotly_white';
732+
if (figData.layout && typeof figData.layout.template === 'string' && templates[figData.layout.template]) {
733+
if (figData.layout.template !== themeTemplateName) {
734+
userTemplate = templates[figData.layout.template];
735+
}
736+
figData.layout.template = null;
737+
} else if (figData.layout && figData.layout.template && typeof figData.layout.template === 'object') {
738+
userTemplate = figData.layout.template;
739+
figData.layout.template = null;
740+
}
741+
715742
// Process modebar buttons using shared helper
716743
processPlotlyConfig(config);
717744

718745
const finalConfig = Object.assign({responsive: true, displaylogo: false}, config);
719746

720747
window.Plotly.newPlot(chartEl, figData.data, figData.layout, finalConfig).then(function() {
748+
// Apply merged theme template (user always wins)
749+
if (window.__pywryMergeThemeTemplate) {
750+
const merged = window.__pywryMergeThemeTemplate(chartEl, themeTemplateName, userTemplate, userTemplateDark, userTemplateLight);
751+
window.Plotly.relayout(chartEl, { template: merged });
752+
}
753+
chartEl.__pywry_theme_template__ = themeTemplateName;
721754
setupPlotlyEvents(chartEl);
722755
}).catch(function(err) {
723756
console.error('[PyWry Plotly] Plotly.newPlot failed:', err);

pywry/pywry/inline.py

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3138,6 +3138,26 @@ def generate_plotly_html(
31383138
const figData = {figure_json};
31393139
const plotlyConfig = figData.config || {{}};
31403140
3141+
// Extract per-theme user template overrides from config (PyWry extension)
3142+
const userTemplateDark = plotlyConfig.templateDark || null;
3143+
const userTemplateLight = plotlyConfig.templateLight || null;
3144+
delete plotlyConfig.templateDark;
3145+
delete plotlyConfig.templateLight;
3146+
3147+
// Extract single legacy template from layout
3148+
let userTemplate = null;
3149+
const templates = window.PYWRY_PLOTLY_TEMPLATES || {{}};
3150+
const themeTemplate = '{"plotly_dark" if theme == "dark" else "plotly_white"}';
3151+
if (figData.layout && typeof figData.layout.template === 'string' && templates[figData.layout.template]) {{
3152+
if (figData.layout.template !== themeTemplate) {{
3153+
userTemplate = templates[figData.layout.template];
3154+
}}
3155+
figData.layout.template = null;
3156+
}} else if (figData.layout && figData.layout.template && typeof figData.layout.template === 'object') {{
3157+
userTemplate = figData.layout.template;
3158+
figData.layout.template = null;
3159+
}}
3160+
31413161
// Convert string functions to actual functions in modeBarButtonsToAdd
31423162
// Also generate click handlers for buttons with 'event' property
31433163
if (plotlyConfig.modeBarButtonsToAdd) {{
@@ -3204,6 +3224,13 @@ def generate_plotly_html(
32043224
PlotlyLib.newPlot('chart', figData.data, figData.layout, finalConfig).then(function() {{
32053225
const chartEl = document.getElementById('chart');
32063226
3227+
// Apply merged theme template (theme base + user overrides, user wins)
3228+
if (window.__pywryMergeThemeTemplate) {{
3229+
const merged = window.__pywryMergeThemeTemplate(chartEl, themeTemplate, userTemplate, userTemplateDark, userTemplateLight);
3230+
PlotlyLib.relayout(chartEl, {{ template: merged }});
3231+
}}
3232+
chartEl.__pywry_theme_template__ = themeTemplate;
3233+
32073234
// Extract point data - include all primitive values and simple arrays
32083235
function extractPointData(p) {{
32093236
const point = {{}};
@@ -3377,13 +3404,13 @@ def generate_plotly_html(
33773404
document.documentElement.classList.remove('dark', 'light');
33783405
document.documentElement.classList.add(prefersDark ? 'dark' : 'light');
33793406
3380-
// Update Plotly if present
3407+
// Update Plotly if present (deep-merge theme + user overrides)
33813408
const plotDiv = document.querySelector('.js-plotly-plot');
33823409
if (plotDiv && window.Plotly && plotDiv.data) {{
33833410
const templateName = prefersDark ? 'plotly_dark' : 'plotly_white';
3384-
const template = window.PYWRY_PLOTLY_TEMPLATES?.[templateName];
3385-
if (template) {{
3386-
const newLayout = Object.assign({{}}, plotDiv.layout || {{}}, {{ template: template }});
3411+
if (window.__pywryMergeThemeTemplate) {{
3412+
const merged = window.__pywryMergeThemeTemplate(plotDiv, templateName);
3413+
const newLayout = Object.assign({{}}, plotDiv.layout || {{}}, {{ template: merged }});
33873414
window.Plotly.newPlot(plotDiv, plotDiv.data, newLayout, plotDiv._fullLayout?._config || {{}});
33883415
}}
33893416
}}
@@ -3437,14 +3464,13 @@ def generate_plotly_html(
34373464
}}
34383465
}}
34393466
3440-
// Update Plotly figure template using PYWRY_PLOTLY_TEMPLATES
3467+
// Update Plotly figure template — deep-merge theme base + user overrides
34413468
const plotDiv = document.querySelector('.js-plotly-plot');
34423469
if (plotDiv && window.Plotly && plotDiv.data) {{
34433470
const templateName = isLight ? 'plotly_white' : 'plotly_dark';
3444-
const template = window.PYWRY_PLOTLY_TEMPLATES?.[templateName];
3445-
if (template) {{
3446-
// Use newPlot to fully re-render with new template
3447-
const newLayout = Object.assign({{}}, plotDiv.layout || {{}}, {{ template: template }});
3471+
if (window.__pywryMergeThemeTemplate) {{
3472+
const merged = window.__pywryMergeThemeTemplate(plotDiv, templateName);
3473+
const newLayout = Object.assign({{}}, plotDiv.layout || {{}}, {{ template: merged }});
34483474
window.Plotly.newPlot(plotDiv, plotDiv.data, newLayout, plotDiv._fullLayout?._config || {{}});
34493475
}}
34503476
}}

0 commit comments

Comments
 (0)