diff --git a/examples/guis/imgui_basic.py b/examples/guis/imgui_basic.py index eac39121..9511a1b0 100644 --- a/examples/guis/imgui_basic.py +++ b/examples/guis/imgui_basic.py @@ -14,7 +14,7 @@ import fastplotlib as fpl # subclass from EdgeWindow to make a custom ImGUI Window to place inside the figure! -from fastplotlib.ui import EdgeWindow +from fastplotlib.ui import EdgeWindow, ChangeFlag from imgui_bundle import imgui # make some initial data @@ -48,41 +48,44 @@ def __init__(self, figure, size, location, title): # sigma for gaussian noise self._sigma = 0.0 + # a flag that once True, always remains True + self._color_changed = ChangeFlag(False) + self._data_changed = ChangeFlag(False) + + def update(self): - # the UI will be used to modify the line - self._line = figure[0, 0]["sine-wave"] + # force flag values to reset + self._color_changed.force_value(False) + self._data_changed.force_value(False) # get the current line RGB values rgb_color = self._line.colors[:-1] # make color picker - changed_color, rgb = imgui.color_picker3("color", col=rgb_color) + self._color_changed.value, rgb = imgui.color_picker3("color", col=rgb_color) # get current line color alpha value alpha = self._line.colors[-1] # make float slider - changed_alpha, new_alpha = imgui.slider_float("alpha", v=alpha, v_min=0.0, v_max=1.0) + self._color_changed.value, new_alpha = imgui.slider_float("alpha", v=alpha, v_min=0.0, v_max=1.0) - # if RGB or alpha changed - if changed_color | changed_alpha: + # if RGB or alpha flag indicates a change + if self._color_changed: # set new color along with alpha self._line.colors = [*rgb, new_alpha] - # example of a slider, you can also use input_float - changed, amplitude = imgui.slider_float("amplitude", v=self._amplitude, v_max=10, v_min=0.1) - if changed: - # set y values - self._amplitude = amplitude - self._set_data() - # slider for thickness changed, thickness = imgui.slider_float("thickness", v=self._line.thickness, v_max=50.0, v_min=2.0) if changed: self._line.thickness = thickness + # example of a slider, you can also use input_float + self._data_changed.value, self._amplitude = imgui.slider_float("amplitude", v=self._amplitude, v_max=10, v_min=0.1) + # slider for gaussian noise - changed, sigma = imgui.slider_float("noise-sigma", v=self._sigma, v_max=1.0, v_min=0.0) - if changed: - self._sigma = sigma + self._data_changed.value, self._sigma = imgui.slider_float("noise-sigma", v=self._sigma, v_max=1.0, v_min=0.0) + + # data flag indicates change + if self._data_changed: self._set_data() # reset button diff --git a/examples/guis/imgui_decorator.py b/examples/guis/imgui_decorator.py new file mode 100644 index 00000000..8e51e9a0 --- /dev/null +++ b/examples/guis/imgui_decorator.py @@ -0,0 +1,74 @@ +""" +ImGUI Decorator +=============== + +Create imgui UIs quickly using a decorator! + +See the imgui docs for extensive examples on how to create all UI elements: https://pyimgui.readthedocs.io/en/latest/reference/imgui.core.html#imgui.core.begin_combo +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + + +import numpy as np +import imageio.v3 as iio +import fastplotlib as fpl +from imgui_bundle import imgui + +img_data = iio.imread(f"imageio:camera.png").astype(np.float32) + +xs = np.linspace(0, 2 * np.pi, 100) +ys = np.sin(xs) +line_data = np.column_stack([xs, ys]) + +figure = fpl.Figure(shape=(2, 1), size=(700, 660)) +figure[0, 0].add_image(img_data, name="image") +figure[1, 0].add_line(line_data, name="sine") + +noise_sigma = 0.0 + +img_options = ["camera.png", "astronaut.png", "chelsea.png"] +img_index = 0 +@figure.add_gui(location="right", title="window", size=300) +def gui(fig_local): # figure is the only argument, so you can use it within the local scope of the GUI function + global img_data + global img_index + + global noise_sigma + + clicked, img_index = imgui.combo("image", img_index, img_options) + if clicked: + fig_local[0, 0].delete_graphic(fig_local[0, 0]["image"]) + img_data = iio.imread(f"imageio:{img_options[img_index]}") + fig_local[0, 0].add_image(img_data, name="image") + + change, noise_sigma = imgui.slider_float("noise sigma", v=noise_sigma, v_min=0.0, v_max=100) + if change or clicked: + fig_local[0, 0]["image"].data = img_data + np.random.normal( + loc=0.0, + scale=noise_sigma, + size=img_data.size + ).reshape(img_data.shape) + + +# You can put all the GUI elements within on GUI function +# or split them across multiple functions and use the `append_gui` decorator +freq = 1.0 +@figure.append_gui(location="right") +def gui2(fig_local): + global freq + change, freq = imgui.slider_float("freq", v=freq, v_min=0.1, v_max=10) + if change: + ys = np.sin(xs * freq) + fig_local[1, 0]["sine"].data[:, 1] = ys + + +figure.show() +figure[1, 0].camera.maintain_aspect = False + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/fastplotlib/layouts/_imgui_figure.py b/fastplotlib/layouts/_imgui_figure.py index c5489023..406d1f0d 100644 --- a/fastplotlib/layouts/_imgui_figure.py +++ b/fastplotlib/layouts/_imgui_figure.py @@ -1,3 +1,6 @@ +from __future__ import annotations +from collections.abc import Callable +from functools import partial from pathlib import Path from typing import Literal, Iterable @@ -150,34 +153,215 @@ def _draw_imgui(self) -> imgui.ImDrawData: return imgui.get_draw_data() - def add_gui(self, gui: EdgeWindow): + def add_gui( + self, + gui: EdgeWindow = None, + location: Literal["right", "bottom"] = "right", + title="GUI Window", + size: int = 200, + window_flags: imgui.WindowFlags_ = imgui.WindowFlags_.no_collapse + | imgui.WindowFlags_.no_resize, + ): """ Add a GUI to the Figure. GUIs can be added to the left or bottom edge. + Can also be used as a decorator, see examples docstring and examples gallery. + + For a list of imgui elements see: https://pyimgui.readthedocs.io/en/latest/reference/imgui.core.html#imgui.core.begin_combo + + Note that the API docs for ``pyimgui`` do not match up exactly with ``imgui-bundle`` which we use in + fastplotlib. Unfortunately the API docs for imgui-bundle are nonexistent (as far as we know). See the + "imgui" section in the docs User Guide which includes tips on how to develop imgui UIs. + Parameters ---------- gui: EdgeWindow - A GUI EdgeWindow instance + A GUI EdgeWindow instance, if not decorating + + location: str, "right" | "bottom" + window location, used if decorating + + title: str + window title, used if decorating + + size: int + width or height of the window depending on location, used if decorating + + window_flags: imgui.WindowFlags_, default imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_resize, + imgui.WindowFlags_ enum, used if decorating + + 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_gui(location="right", title="yay", size=100) + def gui(fig): # figure is the only argument, so you can use it within the local scope of the GUI function + if imgui.button("reset data"): + fig[0, 0].graphics[0].data[:, 1] = np.random.rand(100) + + figure.show(maintain_aspect=False) + + Subclass EdgeWindow:: + + import numpy as np + import fastplotlib as fpl + from fastplotlib.ui import EdgeWindow + + figure = fpl.Figure() + figure[0, 0].add_line(np.sin(np.linspace(0, np.pi * 4, 0.1)), name="sine") + + class GUI(EdgeWindow): + def __init__(self, figure, location="right", size=200, title="My GUI", amplitude=1.0) + self._figure = figure + + self._amplitude = 1 + + def compute_data(self): + ampl = self._amplitude + new_data = ampl * np.sin(np.linspace(0, np.pi * 4, 0.1)) + self._figure[0, 0]["sine"].data[:, 1] = new_data + + def update(self): + # gui update function + changed, amplitude = imgui.slider_float("amplitude", v=self._amplitude, v_max=10, v_min=0.1) + if changed: + self._amplitude = amplitude + self.compute_data() + + # create GUI instance and add to the figure + gui = GUI(figure) + figure.add_gui(gui) """ - if not isinstance(gui, EdgeWindow): - raise TypeError( - f"GUI must be of type: {EdgeWindow} you have passed a {type(gui)}" - ) - location = gui.location + def decorator(_gui: EdgeWindow | Callable): + if not callable(_gui) and not isinstance(_gui, EdgeWindow): + raise TypeError( + "figure.add_gui() must be used as a decorator, or `gui` must be an `EdgeWindow` instance" + ) + + if location not in GUI_EDGES: + raise ValueError( + f"GUI does not have a valid location, valid locations are: {GUI_EDGES}, you have passed: {location}" + ) + + if self.guis[location] is not None: + raise ValueError( + f"GUI already exists in the desired location: {location}" + ) + + if not isinstance(gui, EdgeWindow): + # being used as a decorator, create an EdgeWindow + edge_window = EdgeWindow( + figure=self, + size=size, + location=location, + title=title, + update_call=partial( + _gui, self + ), # provide figure instance in scope of the gui function + window_flags=window_flags, + ) + window_location = location # creating this reference is required + else: + edge_window = _gui # creating this reference is required + window_location = _gui.location # creating this reference is required + + # store the gui + self.guis[window_location] = edge_window + + # redo the layout + self._fpl_reset_layout() + + # return function being decorated + return _gui + + if gui is None: + # assume decorating + return decorator + + # EdgeWindow instance passed + decorator(gui) + + def append_gui(self, gui: Callable = None, location: str = None): + """ + Append to an existing GUI. Can also be used as a decorator. + + Parameters + ---------- + gui: Callable + function that creates imgui elements + + location: str, "right" or "bottom" + the existing GUI window to append more UI elements to + + """ + + if location is None: + raise ValueError("Must provide GUI location to append to.") if location not in GUI_EDGES: raise ValueError( f"GUI does not have a valid location, valid locations are: {GUI_EDGES}, you have passed: {location}" ) - if self.guis[location] is not None: - raise ValueError(f"GUI already exists in the desired location: {location}") + if self.guis[location] is None: + raise ValueError( + f"No GUI at given location to append to: {location}" + ) + + def decorator(_gui: Callable): + gui = self.guis[location] + if isinstance(gui._update_call, list): + gui._update_call.append(_gui) + else: + gui._update_call = [gui._update_call] + gui._update_call.append(partial(_gui, self)) + + return _gui + + if gui is None: + return decorator + + decorator(gui) + + def remove_gui(self, location: str) -> EdgeWindow: + """ + Remove an imgui UI + + Parameters + ---------- + location: str + "right" | "bottom" + + Returns + ------- + EdgeWindow | Callable + The removed EdgeWindow instance + + """ + + if location not in GUI_EDGES: + raise ValueError( + f"location not valid, valid locations are: {GUI_EDGES}, you have passed: {location}" + ) + + gui = self.guis.pop(location) - self.guis[location] = gui + # reset to None for this location + self.guis[location] = None - self._fpl_reset_layout() + # return EdgeWindow instance, it can be added again later + return gui def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]: """ diff --git a/fastplotlib/ui/__init__.py b/fastplotlib/ui/__init__.py index a1e57a9c..0ff1d513 100644 --- a/fastplotlib/ui/__init__.py +++ b/fastplotlib/ui/__init__.py @@ -1,3 +1,4 @@ from ._base import BaseGUI, Window, EdgeWindow, Popup, GUI_EDGES from ._subplot_toolbar import SubplotToolbar from .right_click_menus import StandardRightClickMenu, ColormapPicker +from ._utils import ChangeFlag \ No newline at end of file diff --git a/fastplotlib/ui/_base.py b/fastplotlib/ui/_base.py index e31dd8d4..f6e43544 100644 --- a/fastplotlib/ui/_base.py +++ b/fastplotlib/ui/_base.py @@ -1,5 +1,5 @@ +from collections.abc import Callable from typing import Literal -import numpy as np from imgui_bundle import imgui @@ -42,8 +42,9 @@ def __init__( size: int, location: Literal["bottom", "right"], title: str, - window_flags: int = imgui.WindowFlags_.no_collapse + window_flags: imgui.WindowFlags_ = imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_resize, + update_call: Callable = None, *args, **kwargs, ): @@ -64,7 +65,7 @@ def __init__( title: str window title - window_flags: int + window_flags: imgui.WindowFlags_ window flag enum, valid flags are: .. code-block:: py @@ -94,10 +95,10 @@ def __init__( imgui.WindowFlags_.no_inputs *args - additional args for the GUI + additional args **kwargs - additional kwargs for teh GUI + additional kwargs """ super().__init__() @@ -115,6 +116,11 @@ def __init__( self._figure.canvas.add_event_handler(self._set_rect, "resize") + if update_call is None: + self._update_call = self.update + else: + self._update_call = update_call + @property def size(self) -> int | None: """width or height of the edge window""" @@ -185,11 +191,8 @@ def get_rect(self) -> tuple[int, int, int, int]: def draw_window(self): """helps simplify using imgui by managing window creation & position, and pushing/popping the ID""" # window position & size - x, y, w, h = self.get_rect() imgui.set_next_window_size((self.width, self.height)) imgui.set_next_window_pos((self.x, self.y)) - # imgui.set_next_window_pos((x, y)) - # imgui.set_next_window_size((w, h)) flags = self._window_flags # begin window @@ -198,8 +201,12 @@ def draw_window(self): # push ID to prevent conflict between multiple figs with same UI imgui.push_id(self._id_counter) - # draw stuff from subclass into window - self.update() + # draw imgui UI elements into window + if isinstance(self._update_call, list): + for update_call in self._update_call: + update_call() + else: + self._update_call() # pop ID imgui.pop_id() diff --git a/fastplotlib/ui/_utils.py b/fastplotlib/ui/_utils.py new file mode 100644 index 00000000..32c6f9f6 --- /dev/null +++ b/fastplotlib/ui/_utils.py @@ -0,0 +1,46 @@ +class ChangeFlag: + """ + A flag that helps detect whether an imgui UI has been changed by the user. + Basically, once True, always True. + + Example:: + + changed = ChangeFlag(False) + + changed.value, bah = (False, False) + + print(changed.value) + + changed.value, bah = (True, False) + + print(changed.value) + + changed.value, bah = (False, False) + + print(changed.value) + + """ + + def __init__(self, value: bool): + self._value = bool(value) + + @property + def value(self) -> bool: + return self._value + + @value.setter + def value(self, value: bool): + if value: + self._value = True + + def __bool__(self): + return self.value + + def __or__(self, other): + return self._value | other + + def __eq__(self, other): + return self.value == other + + def force_value(self, value): + self._value = value