Source code for fastplotlib.layouts._imgui_figure
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import Literal, Iterable
import numpy as np
import imgui_bundle
from imgui_bundle import imgui, icons_fontawesome_6 as fa
from wgpu.utils.imgui import ImguiRenderer, Stats
from rendercanvas import BaseRenderCanvas
import pygfx
from ._figure import Figure
from ._utils import IMGUI_TOOLBAR_HEIGHT
from ..ui import (
ImguiContainer,
ImguiWindow,
ImguiPopup,
SubplotToolbar,
StandardRightClickMenu,
EDGES,
)
from ..ui._base import _wrap_update_call
from ..utils import global_config
[docs]
@global_config.register
class ImguiFigure(Figure):
@global_config.declare("size")
def __init__(
self,
shape: tuple[int, int] = (1, 1),
rects: list[tuple | np.ndarray] = None,
extents: list[tuple | np.ndarray] = None,
cameras: (
Literal["2d", "3d"]
| Iterable[Iterable[Literal["2d", "3d"]]]
| pygfx.PerspectiveCamera
| Iterable[Iterable[pygfx.PerspectiveCamera]]
) = "2d",
controller_types: (
Iterable[Iterable[Literal["panzoom", "fly", "trackball", "orbit"]]]
| Iterable[Literal["panzoom", "fly", "trackball", "orbit"]]
) = None,
controller_ids: (
Literal["sync"]
| Iterable[int]
| Iterable[Iterable[int]]
| Iterable[Iterable[str]]
) = None,
controllers: pygfx.Controller | Iterable[Iterable[pygfx.Controller]] = None,
canvas: str | BaseRenderCanvas | pygfx.Texture = None,
renderer: pygfx.WgpuRenderer = None,
canvas_kwargs: dict = None,
size: tuple[int, int] = (500, 300),
names: list | np.ndarray = None,
):
# edge windows reserve canvas space, keyed by location; floating windows draw over the plots
self._edge_windows: dict[str, ImguiWindow] = {loc: None for loc in EDGES}
self._floating_windows: list[ImguiWindow] = []
# figure level right-click popup, and the popup opened by the most recent right-click
self._imgui_right_click: ImguiPopup = None
self._currently_open_imgui_right_click: ImguiPopup = None
self._right_click_press_pos: imgui.ImVec2 = None
super().__init__(
shape=shape,
rects=rects,
extents=extents,
cameras=cameras,
controller_types=controller_types,
controller_ids=controller_ids,
controllers=controllers,
canvas=canvas,
renderer=renderer,
canvas_kwargs=canvas_kwargs,
size=size,
names=names,
)
self._imgui_renderer = ImguiRenderer(self.renderer.device, self.canvas)
# This loads both the Roboto Font and FontAwesome 6 icons and creates and merged font
# allowing us to use both without pushing and popping to display icons or regular text
sans_serif_font = str(
Path(imgui_bundle.__file__).parent.joinpath(
"assets", "fonts", "Roboto", "Roboto-Regular.ttf"
)
)
fa_6_fonts_path = str(
Path(imgui_bundle.__file__).parent.joinpath(
"assets", "fonts", "Font_Awesome_6_Free-Solid-900.otf"
)
)
io = imgui.get_io()
self._default_imgui_font = io.fonts.add_font_from_file_ttf(
sans_serif_font, 14, imgui.ImFontConfig()
)
font_config = imgui.ImFontConfig()
font_config.merge_mode = True
self._default_imgui_font = io.fonts.add_font_from_file_ttf(
fa_6_fonts_path,
14,
font_config,
)
imgui.push_font(self._default_imgui_font, self._default_imgui_font.legacy_size)
self.imgui_renderer.set_gui(self._draw_imgui)
for subplot in self._subplots.ravel():
subplot.add_imgui_window(
SubplotToolbar(), location="toolbar", size=IMGUI_TOOLBAR_HEIGHT
)
self.set_imgui_right_click(StandardRightClickMenu())
self.imgui_show_fps = False
self._stats = Stats(self.renderer.device, self.canvas)
@property
def default_imgui_font(self) -> imgui.ImFont:
return self._default_imgui_font
@property
def imgui_windows(self) -> dict[str, ImguiWindow]:
"""edge imgui windows added to the Figure, keyed by location"""
return self._edge_windows
@property
def imgui_renderer(self) -> ImguiRenderer:
"""imgui renderer"""
return self._imgui_renderer
def _render(self, draw=False):
if self.imgui_show_fps:
with self._stats:
super()._render(draw)
else:
super()._render(draw)
self.imgui_renderer.render()
# needs to be here else events don't get processed
self.canvas.request_draw()
def _draw_imgui(self) -> imgui.ImDrawData:
# figure-level windows: edge windows then floating windows
for window in (*self._edge_windows.values(), *self._floating_windows):
if window is None:
continue
self._layout_imgui_window(window)
window.draw()
# subplot windows, edge window rects are set by Frame.reset_viewport
for subplot in self._subplots.ravel():
for location, window in subplot.imgui_windows.items():
if window is None:
continue
if location == "toolbar" and not subplot.toolbar:
continue
window.draw()
self._fpl_handle_right_click()
# the currently open popup is drawn first, opening it closes any other popup that is still open.
# it keeps being drawn after it closes so that it can also draw its own windows
popup = self._currently_open_imgui_right_click
if popup is not None:
popup.draw()
if self._imgui_right_click is not None and self._imgui_right_click is not popup:
self._imgui_right_click.draw()
[docs]
def add_imgui_window(
self,
window: ImguiWindow = None,
*,
location: Literal["left", "right", "top", "bottom", "floating"] = None,
size: int = None,
rect: tuple | np.ndarray = None,
extent: tuple | np.ndarray = None,
title: str = None,
window_flags: imgui.WindowFlags_ = None,
):
"""
Add an imgui window to the Figure. Can also be used as a decorator, see examples.
A window can be placed on an edge ("left", "right", "top", "bottom") where it reserves canvas space so it
does not cover the subplots, "floating" for an auto-sized draggable window, or at a fixed fractional or pixel
``rect`` or ``extent`` of the canvas. An existing window at an edge ``location`` is replaced.
For a list of imgui elements see the imgui docs and the "imgui" section in the fastplotlib user guide.
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" | "floating"
edge windows reserve canvas space, "floating" is auto-sized and draggable
size: int
edge window thickness in pixels, required for edge windows
rect: tuple[float, float, float, float], optional
fractional or pixel (x, y, w, h) rect for a fixed window. With ``location="floating"``
only its (x, y) is used, as the initial position of the auto-sized window.
extent: tuple[float, float, float, float], optional
fractional or pixel (xmin, xmax, ymin, ymax) extent for a fixed window. With
``location="floating"`` only its (xmin, ymin) is used, as the initial position of the
auto-sized window.
title: str, optional
window title, drawn as a title bar for edge windows. If ``None`` no title bar is drawn.
window_flags: ``imgui.WindowFlags_``
imgui window flags, used when decorating; if not provided, the default depends on placement — edge
windows use ``no_collapse | no_resize | no_title_bar | no_bring_to_front_on_focus`` (custom title bar,
stays behind overlays), floating windows use ``none`` (native title bar, collapsible and movable),
fixed rect/extent windows use ``no_collapse | no_move | no_resize`` (native title bar)
Examples
--------
As a decorator::
import numpy as np
import fastplotlib as fpl
from imgui_bundle import imgui
figure = fpl.Figure()
figure[0, 0].add_line(np.random.rand(100))
@figure.add_imgui_window(location="right", title="controls", size=200)
def gui(fig): # the figure is passed if the function takes an argument
if imgui.button("reset data"):
fig[0, 0].graphics[0].data[:, 1] = np.random.rand(100)
Instance::
figure.add_imgui_window(MyWindow(), location="bottom", size=100)
"""
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(self)
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(
figure=self,
subplot=None,
location=location,
size=size,
rect=rect,
extent=extent,
title=title,
window_flags=window_flags,
)
self._register_imgui_window(win)
return _window
if window is None:
return decorator
decorator(window)
return window
def _register_imgui_window(self, window: ImguiWindow):
"""store a figure-level window and reset the layout if it reserves canvas space"""
location = window.location
if location in EDGES:
if window.size is None:
raise ValueError(
f"must provide `size` for an edge window, location: {location}"
)
self._edge_windows[location] = window
self._fpl_reset_layout()
elif window._floating or window._rect_manager is not None:
self._floating_windows.append(window)
else:
raise ValueError(
"imgui window must have a valid `location` (an edge or 'floating'), or a `rect` or `extent`"
)
[docs]
def append_imgui_window(self, gui: Callable = None, *, location: str = None):
"""
Append imgui elements to an existing edge window. Can also be used as a decorator.
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"
location of the existing window to append to
"""
if location not in EDGES:
raise ValueError(
f"valid locations to append to are: {EDGES}, you have passed: {location}"
)
window = self._edge_windows[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)
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) -> ImguiWindow:
"""
Remove and return the edge imgui window at the given location
Parameters
----------
location: str
"left" | "right" | "top" | "bottom"
Returns
-------
ImguiWindow
the removed window, it can be added again later
"""
if location not in EDGES:
raise ValueError(
f"valid locations are: {EDGES}, you have passed: {location}"
)
window = self._edge_windows[location]
self._edge_windows[location] = None
self._fpl_reset_layout()
return window
def _edge_size(self, edge: str) -> int:
"""thickness in pixels reserved by the edge window at ``edge``, 0 if none"""
window = self._edge_windows[edge]
return window.size if window is not None else 0
def _layout_imgui_window(self, window: ImguiWindow):
"""compute and set the pixel rect of a figure-level imgui window"""
width, height = self.canvas.get_logical_size()
if window._rect_manager is not None:
# a fixed window uses the whole rect, a floating one is auto-sized by imgui and takes
# only its initial position from it
window._rect_manager.canvas_resized((0, 0, width, height))
window._fpl_set_rect(*(round(v) for v in window._rect_manager.rect))
return
if window._floating:
# imgui auto-sizes a floating window from its content, nothing to compute
return
# edge window, spans the full edge minus any perpendicular edge windows
sl, sr = self._edge_size("left"), self._edge_size("right")
st, sb = self._edge_size("top"), self._edge_size("bottom")
mid_y, mid_h = st, height - st - sb
match window.location:
case "top":
rect = (0, 0, width, st)
case "bottom":
rect = (0, height - sb, width, sb)
case "left":
rect = (0, mid_y, sl, mid_h)
case "right":
rect = (width - sr, mid_y, sr, mid_h)
window._fpl_set_rect(*(round(v) for v in rect))
[docs]
def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]:
"""
Get rect for the portion of the canvas that the pygfx renderer draws to,
i.e. non-imgui, part of canvas
Returns
-------
tuple[int, int, int, int]
x_pos, y_pos, width, height
"""
width, height = self.canvas.get_logical_size()
sl, sr = self._edge_size("left"), self._edge_size("right")
st, sb = self._edge_size("top"), self._edge_size("bottom")
x = sl
y = st
width = width - sl - sr
height = height - st - sb
return x, y, max(1, width), max(1, height)
@property
def imgui_right_click(self) -> ImguiPopup | None:
"""
The imgui popup that is opened by a right-click within a subplot, a ``StandardRightClickMenu`` by default.
A popup set on a subplot or graphic replaces it for that subplot or graphic.
"""
return self._imgui_right_click
[docs]
def set_imgui_right_click(
self,
popup: ImguiPopup | Callable = None,
*,
window_flags: imgui.WindowFlags_ = None,
):
"""
Set the imgui popup that is opened by a right-click within a subplot, replaces the standard right-click
menu. Can also be used as a decorator, see examples.
For a list of imgui elements see the imgui docs and the "imgui" section in the fastplotlib user guide.
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
Examples
--------
As a decorator::
import numpy as np
import fastplotlib as fpl
from imgui_bundle import imgui
figure = fpl.Figure()
figure[0, 0].add_line(np.random.rand(100))
@figure.set_imgui_right_click()
def popup(fig): # the figure is passed if the function takes an argument
if imgui.menu_item("autoscale", "", False)[0]:
fig.imgui_right_click.subplot.auto_scale()
Function, the same function can be set on any number of figures, subplots or graphics::
def popup(subplot):
imgui.text(f"subplot: {subplot.name}")
figure[0, 0].set_imgui_right_click(popup)
figure[0, 1].set_imgui_right_click(popup)
Instance::
figure.set_imgui_right_click(MyPopup())
"""
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=self, 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: Callable = None):
"""
Append imgui elements to the Figure's right-click popup, the standard right-click menu by default. Can also
be used as a decorator.
Parameters
----------
gui: callable, optional
function that draws imgui elements, omit when decorating
"""
popup = self._imgui_right_click
if popup is None:
raise ValueError(
"no imgui right-click popup set on this figure to append to, set one using "
"`figure.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) -> ImguiPopup:
"""
Remove and return the Figure's right-click popup
Returns
-------
ImguiPopup
the removed popup, it can be set again later
"""
popup = self._imgui_right_click
self._imgui_right_click = None
return popup
def _fpl_handle_right_click(self):
"""open the popup of the graphic, subplot or Figure that was right-clicked"""
if imgui.is_mouse_down(1):
if self._right_click_press_pos is None:
self._right_click_press_pos = imgui.get_mouse_pos()
return
press_pos = self._right_click_press_pos
self._right_click_press_pos = None
if press_pos is None or not imgui.is_mouse_released(1):
return
pos = imgui.get_mouse_pos()
if press_pos != pos:
# right-drag zooms the camera
return
if imgui.is_window_hovered(imgui.HoveredFlags_.any_window):
# pointer is over an imgui window, not the pygfx render area
return
for subplot in self._subplots.ravel():
if subplot.viewport.is_inside(pos.x, pos.y):
break
else:
return
pick_info = subplot.get_pick_info((pos.x, pos.y))
graphic = pick_info["graphic"] if pick_info is not None else None
# the most specific popup wins
if graphic is not None and graphic.imgui_right_click is not None:
popup = graphic.imgui_right_click
elif subplot.imgui_right_click is not None:
popup = subplot.imgui_right_click
else:
popup = self._imgui_right_click
if popup is not None:
self._fpl_open_imgui_right_click(popup, subplot=subplot, graphic=graphic)
def _fpl_open_imgui_right_click(self, popup: ImguiPopup, subplot, graphic):
"""set the popup that is drawn as the open popup, and open it"""
previous = self._currently_open_imgui_right_click
if previous is not None and previous is not popup:
previous._fpl_close()
self._currently_open_imgui_right_click = popup
popup._fpl_open(subplot=subplot, graphic=graphic)