from __future__ import annotations
from typing import Literal, Union
import numpy as np
import pygfx
from rendercanvas import BaseRenderCanvas
from ..graphics import TextGraphic
from ._utils import create_camera, create_controller
from ._plot_area import PlotArea
from ._frame import Frame
from ..axes import Axes
from ..utils import global_config
[docs]
@global_config.register
class Subplot(PlotArea):
@global_config.declare("toolbar", "background_color", "frame_kwargs")
def __init__(
self,
parent,
camera: Literal["2d", "3d"] | pygfx.PerspectiveCamera,
controller: pygfx.Controller | str,
canvas: BaseRenderCanvas | pygfx.Texture,
rect: np.ndarray = None,
extent: np.ndarray = None,
resizeable: bool = True,
toolbar: bool = True,
renderer: pygfx.WgpuRenderer = None,
name: str = None,
background_color: str | tuple[float, ...] | pygfx.Color = ["black"],
frame_kwargs: dict | None = None,
):
"""
Subplot class.
.. important::
``Subplot`` is not meant to be constructed directly, it only exists as part of a ``Figure``
Parameters
----------
parent: 'Figure'
parent Figure instance
camera: str or pygfx.PerspectiveCamera, default '2d'
indicates the FOV for the camera, '2d' sets ``fov = 0``, '3d' sets ``fov = 50``.
``fov`` can be changed at any time.
controller: str or pygfx.Controller, optional
| if ``None``, uses a PanZoomController for "2d" camera or FlyController for "3d" camera.
| if ``str``, must be one of: `"panzoom", "fly", "trackball", or "orbit"`.
| also accepts a pygfx.Controller instance
canvas: BaseRenderCanvas, or a pygfx.Texture
Provides surface on which a scene will be rendered.
renderer: WgpuRenderer
object used to render scenes using wgpu
name: str, optional
name of the subplot, will appear as ``TextGraphic`` above the subplot
background_color: tuple[str | pygfx.Color, ...], default ["black"]
background color, upto 4 colors, one for each corner
frame_kwargs: dict | None, default None
options for the Subplot Frame. May contain any of the keys ``"spacing"``,
``"title_kwargs"``, and ``"plane_color"``. Each value is itself a dict that is
merged with the defaults, so only the entries you want to change need to be passed.
**"spacing"**: dict, spacing of the frame elements in pixels
- ``"x0"``: int, default 1, offset of the frame from the left edge
- ``"sides"``: int, default 2, padding at the left and right sides
- ``"title_flanks"``: int, default 8, space above and below the title text
- ``"resize_handle_space"``: int, default 13, space reserved for the resize handle
- ``"bottom"``: int, default 8, padding along the bottom edge
**"title_kwargs"**: dict, options for the title ``TextGraphic``
- ``"font_size"``: float, default 16
- ``"face_color"``: str | tuple[float, ...] | pygfx.Color, default "w"
**"plane_color"**: dict, colors of the frame plane for each interaction state,
used to construct a ``SelectorColorStates``. Each value is a
str | tuple[float, ...] | pygfx.Color.
- ``"idle"``: color when the frame is not being interacted with
- ``"highlight"``: color when the frame is hovered
- ``"action"``: color while the frame is being moved or resized
Example::
frame_kwargs = {
"spacing": {"bottom": 12},
"title_kwargs": {"font_size": 20},
"plane_color": {"idle": "w", "highlight": "gray"},
}
"""
camera = create_camera(camera)
controller = create_controller(controller_type=controller, camera=camera)
self._docks = dict()
toolbar_visible = "Imgui" in parent.__class__.__name__ and toolbar
super().__init__(
parent=parent,
camera=camera,
controller=controller,
scene=pygfx.Scene(),
canvas=canvas,
renderer=renderer,
name=name,
background_color=background_color,
)
for pos in ["left", "top", "right", "bottom"]:
dv = Dock(self, size=0)
dv.name = pos
self.docks[pos] = dv
self.children.append(dv)
# imgui windows confined to this subplot, keyed by location
self._imgui_windows = {
loc: None for loc in ["left", "right", "top", "bottom", "toolbar"]
}
self._imgui_right_click = None
self._axes = Axes(self)
self.scene.add(self.axes.world_object)
if frame_kwargs is None:
frame_kwargs = {}
self._frame = Frame(
viewport=self.viewport,
rect=rect,
extent=extent,
resizeable=resizeable,
title=name,
docks=self.docks,
imgui_windows=self._imgui_windows,
toolbar_visible=toolbar_visible,
canvas_rect=parent.get_pygfx_render_area(),
**frame_kwargs,
)
@property
def axes(self) -> Axes:
"""Axes object"""
return self._axes
@property
def name(self) -> str:
"""Subplot name"""
return self._name
@name.setter
def name(self, name: str):
if name is None:
self._name = None
return
for subplot in self.get_figure(self):
if (subplot is self) or (subplot is None):
continue
if subplot.name == name:
raise ValueError("subplot names must be unique")
self._name = name
@property
def docks(self) -> dict:
"""
The docks of this plot area. Each ``dock`` is basically just a PlotArea too.
The docks are: ["left", "top", "right", "bottom"]
Returns
-------
Dict[str, Dock]
{dock_name: Dock}
"""
return self._docks
@property
def toolbar(self) -> bool:
"""show/hide toolbar"""
return self.frame.toolbar_visible
@toolbar.setter
def toolbar(self, visible: bool):
self.frame.toolbar_visible = visible
self.frame.reset_viewport()
def _render(self):
self.axes.update_using_camera()
super()._render()
@property
def title(self) -> TextGraphic:
"""subplot title"""
return self._frame.title_graphic
@title.setter
def title(self, text: str):
text = str(text)
self.title.text = text
@property
def frame(self) -> Frame:
"""Frame that the subplot lives in"""
return self._frame
@property
def frame_spacing(self) -> dict:
return self._frame.spacing
@property
def imgui_windows(self) -> dict:
"""
The imgui windows of this subplot, keyed by location.
The locations are the four edges ["left", "right", "top", "bottom"] and "toolbar"
Returns
-------
dict[str, ImguiWindow]
{location: ImguiWindow}
"""
return self._imgui_windows
[docs]
def add_imgui_window(
self,
window=None,
*,
location: str = None,
size: int = None,
title: str = None,
window_flags=None,
):
"""
Add an imgui window confined to this subplot. Can also be used as a decorator, see the
``Figure.add_imgui_window`` examples.
Edge windows ("left", "right", "top", "bottom") reserve space outboard of the subplot dock on that edge.
The "toolbar" location replaces the subplot toolbar. An existing window at a ``location`` is replaced.
Parameters
----------
window: ImguiWindow | ImguiContainer, optional
an ``ImguiWindow`` instance, such as a ``Legend``, or an ``ImguiContainer`` such as an
``ImguiColorbar``, which is given a window of its own. Omit when decorating.
location: str, "left" | "right" | "top" | "bottom" | "toolbar"
edge windows reserve canvas space, "toolbar" replaces the subplot toolbar
size: int
edge or toolbar thickness in pixels, required for edge windows
title: str, optional
window title, drawn as a title bar for edge windows. If ``None`` no title bar is drawn.
window_flags: ``imgui.WindowFlags_``, optional
imgui window flags, used when decorating, uses the ``ImguiWindow`` default flags if not provided
"""
figure = self.get_figure()
if "Imgui" not in figure.__class__.__name__:
raise TypeError(
"imgui windows can only be added to a subplot of an ImguiFigure"
)
from ..ui._base import ImguiContainer, ImguiWindow, EDGES, _wrap_update_call
valid = EDGES + ["toolbar"]
if location not in valid:
raise ValueError(
f"subplot imgui window location must be one of: {valid}, you have passed: {location}"
)
if location in EDGES and size is None:
raise ValueError(
f"must provide `size` for an edge window, location: {location}"
)
hook_kwargs = dict(
figure=figure, subplot=self, location=location, size=size, title=title
)
if window_flags is not None:
hook_kwargs["window_flags"] = window_flags
def decorator(_window):
if isinstance(_window, ImguiWindow):
win = _window
elif isinstance(_window, ImguiContainer):
# a container is drawn inline, give it a window of its own
_window._fpl_add_hook(figure)
win = ImguiWindow(update_call=_window.draw)
elif callable(_window):
win = ImguiWindow(update_call=_wrap_update_call(_window, self))
else:
raise TypeError(
"add_imgui_window() must be used as a decorator on a function, or given an `ImguiWindow` or "
"`ImguiContainer` instance"
)
win._fpl_add_hook(**hook_kwargs)
self._imgui_windows[location] = win
# edge windows reserve space, reset the layout
if location in EDGES:
figure._fpl_reset_layout()
return _window
if window is None:
return decorator
decorator(window)
return window
[docs]
def append_imgui_window(self, gui=None, *, location: str = None):
"""
Append imgui elements to an existing window of this subplot. Can also be used as a decorator. Useful for
appending elements to the subplot toolbar with ``location="toolbar"``.
Parameters
----------
gui: callable | ImguiContainer, optional
function that draws imgui elements, or an ``ImguiContainer`` such as an ``ImguiColorbar``. Omit when
decorating.
location: str, "left" | "right" | "top" | "bottom" | "toolbar"
location of the existing window to append to
"""
from ..ui._base import ImguiContainer, _wrap_update_call
window = self._imgui_windows.get(location)
if window is None:
raise ValueError(f"no imgui window at location to append to: {location}")
def decorator(_gui):
if isinstance(_gui, ImguiContainer):
_gui._fpl_add_hook(self.get_figure())
window._update_calls.append(_gui.draw)
else:
window._update_calls.append(_wrap_update_call(_gui, self))
return _gui
if gui is None:
return decorator
return decorator(gui)
[docs]
def remove_imgui_window(self, location: str):
"""
Remove and return the imgui window at the given location
Parameters
----------
location: str
"left" | "right" | "top" | "bottom" | "toolbar"
Returns
-------
ImguiWindow
the removed window, it can be added again later
"""
from ..ui._base import EDGES
window = self._imgui_windows.get(location)
self._imgui_windows[location] = None
# edge windows reserve space, reset the layout
if location in EDGES:
self.get_figure()._fpl_reset_layout()
return window
@property
def imgui_right_click(self):
"""
The imgui popup that is opened by a right-click within this subplot.
Returns
-------
ImguiPopup | None
"""
return self._imgui_right_click
[docs]
def set_imgui_right_click(self, popup=None, *, window_flags=None):
"""
Set the imgui popup that is opened by a right-click within this subplot, replaces the Figure's popup within
this subplot. Can also be used as a decorator, see the ``ImguiFigure.set_imgui_right_click`` examples.
Parameters
----------
popup: ImguiPopup | callable, optional
an ``ImguiPopup`` instance, or a function that draws imgui elements. Omit when decorating.
window_flags: ``imgui.WindowFlags_``, optional
imgui window flags for the popup
"""
figure = self.get_figure()
if "Imgui" not in figure.__class__.__name__:
raise TypeError(
"imgui right-click popups can only be set on a subplot of an ImguiFigure"
)
from ..ui._base import ImguiPopup, _wrap_update_call
def decorator(_popup):
if isinstance(_popup, ImguiPopup):
p = _popup
elif callable(_popup):
p = ImguiPopup(update_call=_wrap_update_call(_popup, self))
else:
raise TypeError(
"set_imgui_right_click() must be used as a decorator, or given an `ImguiPopup` instance or a "
"function that draws imgui elements"
)
p._fpl_add_hook(figure=figure, parent=self, window_flags=window_flags)
self._imgui_right_click = p
return _popup
if popup is None:
return decorator
decorator(popup)
return popup
[docs]
def append_imgui_right_click(self, gui=None):
"""
Append imgui elements to the right-click popup of this subplot. Can also be used as a decorator.
Parameters
----------
gui: callable, optional
function that draws imgui elements, omit when decorating
"""
from ..ui._base import _wrap_update_call
popup = self._imgui_right_click
if popup is None:
raise ValueError(
"no imgui right-click popup set on this subplot to append to, set one using "
"`subplot.set_imgui_right_click()`"
)
def decorator(_gui):
popup._update_calls.append(_wrap_update_call(_gui, self))
return _gui
if gui is None:
return decorator
return decorator(gui)
[docs]
def remove_imgui_right_click(self):
"""
Remove and return the right-click popup of this subplot
Returns
-------
ImguiPopup
the removed popup, it can be set again later
"""
popup = self._imgui_right_click
self._imgui_right_click = None
return popup
@global_config.register
class Dock(PlotArea):
def __init__(
self,
parent: Subplot,
size: int,
):
self._size = size
super().__init__(
parent=parent,
camera=pygfx.OrthographicCamera(),
controller=pygfx.PanZoomController(),
scene=pygfx.Scene(),
canvas=parent.canvas,
renderer=parent.renderer,
background_color=parent.background_color,
)
@property
def size(self) -> int:
"""Get or set the size of this dock"""
return self._size
@size.setter
def size(self, s: int):
self._size = s
self.get_figure()._fpl_reset_layout()
def _render(self):
if self.size == 0:
return
super()._render()