|
61 | 61 | COLOR_OFFSET = 3 |
62 | 62 |
|
63 | 63 | ##==================================================================================== |
64 | | -## MATLAB-style nargout emulation |
| 64 | +## handle dict that does not echo in Jupyter / IPython |
65 | 65 | ##==================================================================================== |
66 | 66 |
|
67 | 67 |
|
| 68 | +class PlotHandle(dict): |
| 69 | + """ |
| 70 | + A dict of plot handles (keys ``'fig'``, ``'ax'``, ``'obj'``) returned by the |
| 71 | + plotting functions. It behaves exactly like a normal dict -- indexing, |
| 72 | + ``print()`` and ``repr()`` all work -- but declares an empty rich display so |
| 73 | + that a bare ``plotmesh(...)`` as the last line of a Jupyter/Colab/IPython |
| 74 | + cell does not echo the (often huge) handle dict. Capture it (``h = |
| 75 | + plotmesh(...)``) to use the handles as before. |
| 76 | +
|
| 77 | + This mechanism is environment-independent (unlike bytecode/``nargout`` |
| 78 | + inspection) and works across notebook frontends and Python versions. |
| 79 | + """ |
| 80 | + |
| 81 | + def _ipython_display_(self): |
| 82 | + # Defining this method makes IPython treat display as "handled"; by |
| 83 | + # doing nothing we suppress the automatic Out[...] echo entirely, while |
| 84 | + # repr()/print() and dict access keep working normally. |
| 85 | + pass |
| 86 | + |
| 87 | + |
68 | 88 | def _return_value_used(): |
69 | 89 | """ |
70 | | - Best-effort emulation of MATLAB's ``nargout``: inspect the caller's bytecode |
71 | | - at the call site and report whether the returned value is actually used. |
| 90 | + Best-effort emulation of MATLAB's ``nargout`` for *non-notebook* callers: |
| 91 | + inspect the caller's bytecode and report whether the return value is used. |
72 | 92 |
|
73 | 93 | Returns False when the result is discarded -- a bare ``plotmesh(...)`` |
74 | | - statement in a script (``POP_TOP``) or auto-displayed as the last expression |
75 | | - in a Jupyter/IPython cell or REPL (``PRINT_EXPR``) -- so the caller can avoid |
76 | | - returning (and thereby echoing) the handle dict. Returns True otherwise, or |
77 | | - if the call site cannot be inspected, preserving the documented return value. |
| 94 | + statement (``POP_TOP``) or auto-displayed as the last expression in a plain |
| 95 | + Python REPL (``PRINT_EXPR`` on <=3.11, ``CALL_INTRINSIC_1``/print on 3.12+). |
| 96 | + Returns True otherwise, or if the call site cannot be inspected. In notebooks |
| 97 | + the suppression is handled robustly by :class:`PlotHandle` instead, so a |
| 98 | + misfire here is harmless. |
78 | 99 | """ |
79 | 100 | try: |
80 | 101 | frame = sys._getframe(2) # 0: this fn, 1: decorated wrapper, 2: caller |
81 | 102 | lasti = frame.f_lasti |
82 | 103 | nxt = next( |
83 | | - ( |
84 | | - ins.opname |
85 | | - for ins in dis.get_instructions(frame.f_code) |
86 | | - if ins.offset > lasti |
87 | | - ), |
| 104 | + (ins for ins in dis.get_instructions(frame.f_code) if ins.offset > lasti), |
88 | 105 | None, |
89 | 106 | ) |
90 | | - return nxt not in ("POP_TOP", "PRINT_EXPR") |
| 107 | + if nxt is None: |
| 108 | + return True |
| 109 | + if nxt.opname in ("POP_TOP", "PRINT_EXPR"): |
| 110 | + return False |
| 111 | + # Python 3.12 replaced PRINT_EXPR with CALL_INTRINSIC_1(INTRINSIC_PRINT) |
| 112 | + if nxt.opname == "CALL_INTRINSIC_1" and "PRINT" in str(nxt.argval): |
| 113 | + return False |
| 114 | + return True |
91 | 115 | except Exception: |
92 | 116 | return True |
93 | 117 |
|
94 | 118 |
|
95 | 119 | def _nargout_aware(func): |
96 | 120 | """ |
97 | | - Decorator that returns ``None`` (instead of the handle dict) when the wrapped |
98 | | - plotting function's return value is discarded by the caller, so an |
99 | | - interactive ``plotmesh(...)`` does not print its handle dict. |
| 121 | + Decorator for the plotting functions. Wraps the returned handle dict in a |
| 122 | + :class:`PlotHandle` (so it never echoes in notebooks) and, for plain |
| 123 | + scripts/REPLs, returns ``None`` when the caller discards the value. |
100 | 124 | """ |
101 | 125 |
|
102 | 126 | @functools.wraps(func) |
103 | 127 | def wrapper(*args, **kwargs): |
104 | 128 | result = func(*args, **kwargs) |
| 129 | + if isinstance(result, dict) and not isinstance(result, PlotHandle): |
| 130 | + result = PlotHandle(result) |
105 | 131 | return result if _return_value_used() else None |
106 | 132 |
|
107 | 133 | return wrapper |
@@ -778,6 +804,11 @@ def _plot_options(kwargs, default_cmap): |
778 | 804 | "show_edges": kwargs.pop("show_edges", True), |
779 | 805 | "edgecolor": kwargs.pop("edgecolor", kwargs.pop("edgecolors", "black")), |
780 | 806 | "jupyter_backend": kwargs.pop("jupyter_backend", None), |
| 807 | + # plotly: disable wheel-zoom by default so the page/output can scroll |
| 808 | + # (esp. in Colab's short output frame); zoom stays available via the |
| 809 | + # modebar zoom button, or set scrollzoom=True to re-enable wheel zoom. |
| 810 | + "scrollzoom": kwargs.pop("scrollzoom", False), |
| 811 | + "config": kwargs.pop("config", None), |
781 | 812 | "show": kwargs.pop("show", not hold), |
782 | 813 | } |
783 | 814 |
|
@@ -940,7 +971,12 @@ def finalize(self, fig, cfg): |
940 | 971 | margin=dict(l=0, r=0, t=0, b=0), |
941 | 972 | ) |
942 | 973 | if cfg["show"]: |
943 | | - fig.show() |
| 974 | + # scrollZoom off by default lets the notebook/page scroll instead of |
| 975 | + # the wheel zooming the plot; zoom via the modebar zoom button or |
| 976 | + # pass scrollzoom=True. A user-supplied config takes precedence. |
| 977 | + config = dict(cfg.get("config") or {}) |
| 978 | + config.setdefault("scrollZoom", bool(cfg["scrollzoom"])) |
| 979 | + fig.show(config=config) |
944 | 980 |
|
945 | 981 | @staticmethod |
946 | 982 | def _add(fig, h, trace): |
@@ -1038,13 +1074,28 @@ def finalize(self, pl, cfg): |
1038 | 1074 | if not cfg["show"]: |
1039 | 1075 | return |
1040 | 1076 | # In a Jupyter notebook pyvista renders a *static* image unless an |
1041 | | - # interactive backend (e.g. 'trame') is active; forward jupyter_backend |
1042 | | - # so users can request live rotation/zoom. Outside notebooks it opens an |
1043 | | - # interactive native window as usual. |
1044 | | - kw = {} |
1045 | | - if cfg.get("jupyter_backend"): |
1046 | | - kw["jupyter_backend"] = cfg["jupyter_backend"] |
1047 | | - pl.show(**kw) |
| 1077 | + # interactive backend is active; forward jupyter_backend so users can |
| 1078 | + # request live rotation/zoom. Outside notebooks it opens an interactive |
| 1079 | + # native window as usual. Google Colab cannot use the websocket-based |
| 1080 | + # 'trame' server backend, so default to the self-contained client-side |
| 1081 | + # 'html' backend there, which is rotatable without a server. |
| 1082 | + jb = cfg.get("jupyter_backend") |
| 1083 | + if jb is None and "google.colab" in sys.modules: |
| 1084 | + jb = "html" |
| 1085 | + try: |
| 1086 | + pl.show(jupyter_backend=jb) if jb else pl.show() |
| 1087 | + except Exception as e: |
| 1088 | + import warnings |
| 1089 | + |
| 1090 | + warnings.warn( |
| 1091 | + f"pyvista interactive backend '{jb}' failed ({e}); falling back " |
| 1092 | + "to the default. For rotatable plots in Google Colab, run once:\n" |
| 1093 | + " !apt-get -qq install xvfb libgl1-mesa-glx\n" |
| 1094 | + " import pyvista as pv; pv.start_xvfb()\n" |
| 1095 | + " pv.set_jupyter_backend('html')\n" |
| 1096 | + "or use backend='plotly', which is interactive out of the box." |
| 1097 | + ) |
| 1098 | + pl.show() |
1048 | 1099 |
|
1049 | 1100 |
|
1050 | 1101 | def _plotmesh_pyvista(node, *args, **kwargs): |
|
0 commit comments