From 911a79c88f41d896b8bc529a11183d20b18a7d33 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 11 Jul 2024 05:54:33 -0400 Subject: [PATCH 1/9] imgui prototype, works in qt windows and gridplots --- fastplotlib/layouts/_figure.py | 46 ++-- fastplotlib/layouts/_subplot.py | 21 +- .../layouts/output/_ipywidget_toolbar.py | 202 ------------------ fastplotlib/layouts/output/_qt_toolbar.py | 125 ----------- .../layouts/output/_qtoolbar_template.py | 61 ------ fastplotlib/layouts/output/_toolbar.py | 45 ---- fastplotlib/layouts/output/jupyter_output.py | 22 +- fastplotlib/layouts/output/qt_output.py | 16 +- fastplotlib/layouts/output/qtoolbar.ui | 89 -------- fastplotlib/layouts/ui/__init__.py | 0 fastplotlib/layouts/ui/_toolbar.py | 52 +++++ 11 files changed, 107 insertions(+), 572 deletions(-) delete mode 100644 fastplotlib/layouts/output/_ipywidget_toolbar.py delete mode 100644 fastplotlib/layouts/output/_qt_toolbar.py delete mode 100644 fastplotlib/layouts/output/_qtoolbar_template.py delete mode 100644 fastplotlib/layouts/output/_toolbar.py delete mode 100644 fastplotlib/layouts/output/qtoolbar.ui create mode 100644 fastplotlib/layouts/ui/__init__.py create mode 100644 fastplotlib/layouts/ui/_toolbar.py diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index d330c6928..8cf5efe03 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -9,9 +9,13 @@ from inspect import getfullargspec from warnings import warn +import imgui_bundle +from imgui_bundle import imgui, icons_fontawesome_6 as fa, imgui_ctx + import pygfx from wgpu.gui import WgpuCanvasBase +from wgpu.utils.imgui import ImguiRenderer from ._video_writer import VideoWriterAV from ._utils import make_canvas_and_renderer, create_controller, create_camera @@ -111,6 +115,21 @@ def __init__( canvas, renderer = make_canvas_and_renderer(canvas, renderer) + self.imgui_renderer = ImguiRenderer(renderer.device, canvas) + + path = str(Path(imgui_bundle.__file__).parent.joinpath("assets", "fonts", "Font_Awesome_6_Free-Solid-900.otf")) + + io = imgui.get_io() + + self._imgui_icons = io.fonts.add_font_from_file_ttf( + path, + 24, + glyph_ranges_as_int_list=[fa.ICON_MIN_FA, fa.ICON_MAX_FA] + ) + + io.fonts.build() + self.imgui_renderer.backend.create_fonts_texture() + if isinstance(cameras, str): # create the array representing the views for each subplot in the grid cameras = np.array([cameras] * len(self)).reshape(self.shape) @@ -398,6 +417,19 @@ def render(self): subplot.render() self.renderer.flush() + + # begin making new frame data for imgui + imgui.new_frame() + + # update the toolbars + for subplot in self: + subplot.toolbar.update() + + # render new UI frame + imgui.end_frame() + imgui.render() + self.imgui_renderer.render(imgui.get_draw_data()) + self.canvas.request_draw() # call post-render animate functions @@ -415,7 +447,6 @@ def show( toolbar: bool = True, sidecar: bool = False, sidecar_kwargs: dict = None, - add_widgets: list = None, ): """ Begins the rendering event loop and shows the plot in the desired output context (jupyter, qt or glfw). @@ -438,9 +469,6 @@ def show( kwargs for sidecar instance to display plot i.e. title, layout - add_widgets: list of widgets - a list of ipywidgets or QWidget that are vertically stacked below the plot - Returns ------- OutputContext @@ -455,12 +483,6 @@ def show( self.start_render() - if sidecar_kwargs is None: - sidecar_kwargs = dict() - - if add_widgets is None: - add_widgets = list() - # flip y-axis if ImageGraphics are present for subplot in self: for g in subplot.graphics: @@ -484,17 +506,15 @@ def show( self._output = JupyterOutputContext( frame=self, - make_toolbar=toolbar, use_sidecar=sidecar, sidecar_kwargs=sidecar_kwargs, - add_widgets=add_widgets, ) elif self.canvas.__class__.__name__ == "QWgpuCanvas": from .output.qt_output import QOutputContext # noqa - inline import self._output = QOutputContext( - frame=self, make_toolbar=toolbar, add_widgets=add_widgets + frame=self, ) else: # assume GLFW, the output context is just the canvas diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index 059307e6b..f57689065 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -10,6 +10,7 @@ from ._utils import make_canvas_and_renderer, create_camera, create_controller from ._plot_area import PlotArea from ._graphic_methods_mixin import GraphicMethodsMixin +from .ui._toolbar import SubplotToolbar class Subplot(PlotArea, GraphicMethodsMixin): @@ -116,6 +117,13 @@ def __init__( if self.name is not None: self.set_title(self.name) + self.toolbar = SubplotToolbar(self, self.parent._imgui_icons) + + # self.add_animations(self.render_imgui) + # + # def render_imgui(self): + # self.parent.imgui_renderer.render(self.toolbar.update()) + @property def name(self) -> str: return self._name @@ -171,14 +179,22 @@ def get_rect(self): row_ix, col_ix = self.position width_canvas, height_canvas = self.renderer.logical_size + # spacings for imgui toolbar + height_canvas -= 50 + if row_ix > 0: + top_spacing = 50 + else: + top_spacing = 0 + x_pos = ( (width_canvas / self.ncols) + ((col_ix - 1) * (width_canvas / self.ncols)) ) + self.spacing y_pos = ( (height_canvas / self.nrows) + ((row_ix - 1) * (height_canvas / self.nrows)) - ) + self.spacing + ) + self.spacing + top_spacing + width_subplot = (width_canvas / self.ncols) - self.spacing - height_subplot = (height_canvas / self.nrows) - self.spacing + height_subplot = (height_canvas / self.nrows) - self.spacing - top_spacing rect = np.array([x_pos, y_pos, width_subplot, height_subplot]) @@ -246,6 +262,7 @@ def get_rect(self, *args): row_ix_parent, col_ix_parent = self.parent.position width_canvas, height_canvas = self.parent.renderer.logical_size + height_canvas -= 60 spacing = 2 # spacing in pixels diff --git a/fastplotlib/layouts/output/_ipywidget_toolbar.py b/fastplotlib/layouts/output/_ipywidget_toolbar.py deleted file mode 100644 index 787c8d442..000000000 --- a/fastplotlib/layouts/output/_ipywidget_toolbar.py +++ /dev/null @@ -1,202 +0,0 @@ -import traceback -from datetime import datetime -from itertools import product -from math import copysign -from pathlib import Path - -from ipywidgets.widgets import ( - HBox, - ToggleButton, - Dropdown, - Layout, - Button, - Image, -) - -from ...graphics.selectors import PolygonSelector -from ._toolbar import ToolBar -from ...utils import config - - -class IpywidgetToolBar(HBox, ToolBar): - """Basic toolbar using ipywidgets""" - - def __init__(self, figure): - ToolBar.__init__(self, figure) - - self._auto_scale_button = Button( - value=False, - disabled=False, - icon="expand-arrows-alt", - layout=Layout(width="auto"), - tooltip="auto-scale scene", - ) - self._center_scene_button = Button( - value=False, - disabled=False, - icon="align-center", - layout=Layout(width="auto"), - tooltip="auto-center scene", - ) - self._panzoom_controller_button = ToggleButton( - value=True, - disabled=False, - icon="hand-pointer", - layout=Layout(width="auto"), - tooltip="panzoom controller", - ) - self._maintain_aspect_button = ToggleButton( - value=True, - disabled=False, - description="1:1", - layout=Layout(width="auto"), - tooltip="maintain aspect", - ) - self._maintain_aspect_button.style.font_weight = "bold" - - self._y_direction_button = Button( - value=False, - disabled=False, - icon="arrow-up", - layout=Layout(width="auto"), - tooltip="y-axis direction", - ) - - self._record_button = ToggleButton( - value=False, - disabled=False, - icon="video", - layout=Layout(width="auto"), - tooltip="record", - ) - - self._add_polygon_button = Button( - value=False, - disabled=False, - icon="draw-polygon", - layout=Layout(width="auto"), - tooltip="add PolygonSelector", - ) - - widgets = [ - self._auto_scale_button, - self._center_scene_button, - self._panzoom_controller_button, - self._maintain_aspect_button, - self._y_direction_button, - self._add_polygon_button, - self._record_button, - ] - - if config.party_parrot: - gif_path = Path(__file__).parent.parent.parent.joinpath("assets", "egg.gif") - with open(gif_path, "rb") as f: - gif = f.read() - - image = Image( - value=gif, - format="png", - width=35, - height=25, - ) - widgets.append(image) - - positions = list( - product(range(self.figure.shape[0]), range(self.figure.shape[1])) - ) - values = list() - for pos in positions: - if self.figure[pos].name is not None: - values.append(self.figure[pos].name) - else: - values.append(str(pos)) - - self._dropdown = Dropdown( - options=values, - disabled=False, - description="Subplots:", - layout=Layout(width="200px"), - ) - - self.figure.renderer.add_event_handler(self.update_current_subplot, "click") - - widgets.append(self._dropdown) - - self._panzoom_controller_button.observe(self.panzoom_handler, "value") - self._auto_scale_button.on_click(self.auto_scale_handler) - self._center_scene_button.on_click(self.center_scene_handler) - self._maintain_aspect_button.observe(self.maintain_aspect_handler, "value") - self._y_direction_button.on_click(self.y_direction_handler) - self._add_polygon_button.on_click(self.add_polygon) - self._record_button.observe(self.record_plot, "value") - - # set initial values for some buttons - self._maintain_aspect_button.value = self.current_subplot.camera.maintain_aspect - - if copysign(1, self.current_subplot.camera.local.scale_y) == -1: - self._y_direction_button.icon = "arrow-down" - else: - self._y_direction_button.icon = "arrow-up" - - super().__init__(widgets) - - def _get_subplot_dropdown_value(self) -> str: - return self._dropdown.value - - def auto_scale_handler(self, obj): - self.current_subplot.auto_scale( - maintain_aspect=self.current_subplot.camera.maintain_aspect - ) - - def center_scene_handler(self, obj): - self.current_subplot.center_scene() - - def panzoom_handler(self, obj): - self.current_subplot.controller.enabled = self._panzoom_controller_button.value - - def maintain_aspect_handler(self, obj): - for camera in self.current_subplot.controller.cameras: - camera.maintain_aspect = self._maintain_aspect_button.value - - def y_direction_handler(self, obj): - # flip every camera under the same controller - for camera in self.current_subplot.controller.cameras: - camera.local.scale_y *= -1 - - if copysign(1, self.current_subplot.camera.local.scale_y) == -1: - self._y_direction_button.icon = "arrow-down" - else: - self._y_direction_button.icon = "arrow-up" - - def update_current_subplot(self, ev): - for subplot in self.figure: - pos = subplot.map_screen_to_world((ev.x, ev.y)) - if pos is not None: - # update self.dropdown - if subplot.name is None: - self._dropdown.value = str(subplot.position) - else: - self._dropdown.value = subplot.name - self._panzoom_controller_button.value = subplot.controller.enabled - self._maintain_aspect_button.value = subplot.camera.maintain_aspect - - if copysign(1, subplot.camera.local.scale_y) == -1: - self._y_direction_button.icon = "arrow-down" - else: - self._y_direction_button.icon = "arrow-up" - - def record_plot(self, obj): - if self._record_button.value: - try: - self.figure.recorder.start( - f"./{datetime.now().isoformat(timespec='seconds').replace(':', '_')}.mp4" - ) - except Exception: - traceback.print_exc() - self._record_button.value = False - else: - self.figure.recorder.stop() - - def add_polygon(self, obj): - ps = PolygonSelector(edge_width=3, edge_color="magenta") - self.current_subplot.add_graphic(ps, center=False) diff --git a/fastplotlib/layouts/output/_qt_toolbar.py b/fastplotlib/layouts/output/_qt_toolbar.py deleted file mode 100644 index 4334f1369..000000000 --- a/fastplotlib/layouts/output/_qt_toolbar.py +++ /dev/null @@ -1,125 +0,0 @@ -from datetime import datetime -from math import copysign -import traceback - -from ...utils.gui import QtWidgets -from ...graphics.selectors import PolygonSelector -from ._toolbar import ToolBar -from ._qtoolbar_template import Ui_QToolbar - - -class QToolbar( - ToolBar, QtWidgets.QWidget -): # inheritance order MUST be Toolbar first, QWidget second! Else breaks - """Toolbar for Qt context""" - - def __init__(self, output_context, figure): - QtWidgets.QWidget.__init__(self, parent=output_context) - ToolBar.__init__(self, figure) - - # initialize UI - self.ui = Ui_QToolbar() - self.ui.setupUi(self) - - # connect button events - self.ui.auto_scale_button.clicked.connect(self.auto_scale_handler) - self.ui.center_button.clicked.connect(self.center_scene_handler) - self.ui.panzoom_button.toggled.connect(self.panzoom_handler) - self.ui.maintain_aspect_button.toggled.connect(self.maintain_aspect_handler) - self.ui.y_direction_button.clicked.connect(self.y_direction_handler) - - # subplot labels update when a user click on subplots - subplot = self.figure[0, 0] - # set label from first subplot name - if subplot.name is not None: - name = subplot.name - else: - name = str(subplot.position) - - # here we will just use a simple label, not a dropdown like ipywidgets - # the dropdown implementation is tedious with Qt - self.ui.current_subplot = QtWidgets.QLabel(parent=self) - self.ui.current_subplot.setText(name) - self.ui.horizontalLayout.addWidget(self.ui.current_subplot) - - # update the subplot label when a subplot is clicked into - self.figure.renderer.add_event_handler(self.update_current_subplot, "click") - - self.setMaximumHeight(35) - - # set the initial values for buttons - self.ui.maintain_aspect_button.setChecked( - self.current_subplot.camera.maintain_aspect - ) - self.ui.panzoom_button.setChecked(self.current_subplot.controller.enabled) - - if copysign(1, self.current_subplot.camera.local.scale_y) == -1: - self.ui.y_direction_button.setText("v") - else: - self.ui.y_direction_button.setText("^") - - def update_current_subplot(self, ev): - """update the text label for the current subplot""" - for subplot in self.figure: - pos = subplot.map_screen_to_world((ev.x, ev.y)) - if pos is not None: - if subplot.name is not None: - name = subplot.name - else: - name = str(subplot.position) - self.ui.current_subplot.setText(name) - - # set buttons w.r.t. current subplot - self.ui.panzoom_button.setChecked(subplot.controller.enabled) - self.ui.maintain_aspect_button.setChecked( - subplot.camera.maintain_aspect - ) - - if copysign(1, subplot.camera.local.scale_y) == -1: - self.ui.y_direction_button.setText("v") - else: - self.ui.y_direction_button.setText("^") - - def _get_subplot_dropdown_value(self) -> str: - return self.ui.current_subplot.text() - - def auto_scale_handler(self, *args): - self.current_subplot.auto_scale( - maintain_aspect=self.current_subplot.camera.maintain_aspect - ) - - def center_scene_handler(self, *args): - self.current_subplot.center_scene() - - def panzoom_handler(self, value: bool): - self.current_subplot.controller.enabled = value - - def maintain_aspect_handler(self, value: bool): - for camera in self.current_subplot.controller.cameras: - camera.maintain_aspect = value - - def y_direction_handler(self, *args): - # flip every camera under the same controller - for camera in self.current_subplot.controller.cameras: - camera.local.scale_y *= -1 - - if copysign(1, self.current_subplot.camera.local.scale_y) == -1: - self.ui.y_direction_button.setText("v") - else: - self.ui.y_direction_button.setText("^") - - def record_handler(self, ev): - if self.ui.record_button.isChecked(): - try: - self.figure.record_start( - f"./{datetime.now().isoformat(timespec='seconds').replace(':', '_')}.mp4" - ) - except Exception: - traceback.print_exc() - self.ui.record_button.setChecked(False) - else: - self.figure.record_stop() - - def add_polygon(self, *args): - ps = PolygonSelector(edge_width=3, edge_color="mageneta") - self.current_subplot.add_graphic(ps, center=False) diff --git a/fastplotlib/layouts/output/_qtoolbar_template.py b/fastplotlib/layouts/output/_qtoolbar_template.py deleted file mode 100644 index d2311c595..000000000 --- a/fastplotlib/layouts/output/_qtoolbar_template.py +++ /dev/null @@ -1,61 +0,0 @@ -# Form implementation generated from reading ui file 'qtoolbar.ui' -# -# Created by: PyQt6 UI code generator 6.5.3 -# -# WARNING: Any manual changes made to this file will be lost when pyuic6 is -# run again. Do not edit this file unless you know what you are doing. - -from ...utils.gui import QtGui, QtCore, QtWidgets - - -class Ui_QToolbar(object): - def setupUi(self, QToolbar): - QToolbar.setObjectName("QToolbar") - QToolbar.resize(638, 48) - self.horizontalLayout_2 = QtWidgets.QHBoxLayout(QToolbar) - self.horizontalLayout_2.setObjectName("horizontalLayout_2") - self.horizontalLayout = QtWidgets.QHBoxLayout() - self.horizontalLayout.setObjectName("horizontalLayout") - self.auto_scale_button = QtWidgets.QPushButton(parent=QToolbar) - self.auto_scale_button.setObjectName("auto_scale_button") - self.horizontalLayout.addWidget(self.auto_scale_button) - self.center_button = QtWidgets.QPushButton(parent=QToolbar) - self.center_button.setObjectName("center_button") - self.horizontalLayout.addWidget(self.center_button) - self.panzoom_button = QtWidgets.QPushButton(parent=QToolbar) - self.panzoom_button.setCheckable(True) - self.panzoom_button.setObjectName("panzoom_button") - self.horizontalLayout.addWidget(self.panzoom_button) - self.maintain_aspect_button = QtWidgets.QPushButton(parent=QToolbar) - font = QtGui.QFont() - font.setBold(True) - font.setWeight(QtGui.QFont.Weight.Bold) - self.maintain_aspect_button.setFont(font) - self.maintain_aspect_button.setCheckable(True) - self.maintain_aspect_button.setObjectName("maintain_aspect_button") - self.horizontalLayout.addWidget(self.maintain_aspect_button) - self.y_direction_button = QtWidgets.QPushButton(parent=QToolbar) - self.y_direction_button.setObjectName("y_direction_button") - self.horizontalLayout.addWidget(self.y_direction_button) - self.add_polygon_button = QtWidgets.QPushButton(parent=QToolbar) - self.add_polygon_button.setObjectName("add_polygon_button") - self.horizontalLayout.addWidget(self.add_polygon_button) - self.record_button = QtWidgets.QPushButton(parent=QToolbar) - self.record_button.setCheckable(True) - self.record_button.setObjectName("record_button") - self.horizontalLayout.addWidget(self.record_button) - self.horizontalLayout_2.addLayout(self.horizontalLayout) - - self.retranslateUi(QToolbar) - QtCore.QMetaObject.connectSlotsByName(QToolbar) - - def retranslateUi(self, QToolbar): - _translate = QtCore.QCoreApplication.translate - QToolbar.setWindowTitle(_translate("QToolbar", "Form")) - self.auto_scale_button.setText(_translate("QToolbar", "autoscale")) - self.center_button.setText(_translate("QToolbar", "center")) - self.panzoom_button.setText(_translate("QToolbar", "panzoom")) - self.maintain_aspect_button.setText(_translate("QToolbar", "1:1")) - self.y_direction_button.setText(_translate("QToolbar", "^")) - self.add_polygon_button.setText(_translate("QToolbar", "polygon")) - self.record_button.setText(_translate("QToolbar", "record")) diff --git a/fastplotlib/layouts/output/_toolbar.py b/fastplotlib/layouts/output/_toolbar.py deleted file mode 100644 index 5edd201fa..000000000 --- a/fastplotlib/layouts/output/_toolbar.py +++ /dev/null @@ -1,45 +0,0 @@ -from .._subplot import Subplot - - -class ToolBar: - def __init__(self, figure): - self.figure = figure - - def _get_subplot_dropdown_value(self) -> str: - raise NotImplemented - - @property - def current_subplot(self) -> Subplot: - """Returns current subplot""" - if hasattr(self.figure, "_subplots"): - # parses dropdown or label value as plot name or position - current = self._get_subplot_dropdown_value() - if current[0] == "(": - # str representation of int tuple to tuple of int - current = tuple(int(i) for i in current.strip("()").split(",")) - return self.figure[current] - else: - return self.figure[current] - else: - return self.figure - - def panzoom_handler(self, ev): - raise NotImplemented - - def maintain_aspect_handler(self, ev): - raise NotImplemented - - def y_direction_handler(self, ev): - raise NotImplemented - - def auto_scale_handler(self, ev): - raise NotImplemented - - def center_scene_handler(self, ev): - raise NotImplemented - - def record_handler(self, ev): - raise NotImplemented - - def add_polygon(self, ev): - raise NotImplemented diff --git a/fastplotlib/layouts/output/jupyter_output.py b/fastplotlib/layouts/output/jupyter_output.py index 9ebf0941d..60735cdf1 100644 --- a/fastplotlib/layouts/output/jupyter_output.py +++ b/fastplotlib/layouts/output/jupyter_output.py @@ -2,8 +2,6 @@ from sidecar import Sidecar from IPython.display import display -from ._ipywidget_toolbar import IpywidgetToolBar - class JupyterOutputContext(VBox): """ @@ -15,10 +13,8 @@ class JupyterOutputContext(VBox): def __init__( self, frame, - make_toolbar: bool, use_sidecar: bool, sidecar_kwargs: dict, - add_widgets: list[Widget], ): """ @@ -30,27 +26,14 @@ def __init__( sidecar_kwargs: dict optional kwargs passed to Sidecar - add_widgets: List[Widget] - list of ipywidgets to stack below the plot and toolbar """ self.frame = frame self.toolbar = None self.sidecar = None - # verify they are all valid ipywidgets - if False in [isinstance(w, Widget) for w in add_widgets]: - raise TypeError( - f"add_widgets must be list of ipywidgets, you have passed:\n{add_widgets}" - ) - self.use_sidecar = use_sidecar - if not make_toolbar: # just stack canvas and the additional widgets, if any - self.output = (frame.canvas, *add_widgets) - - if make_toolbar: # make toolbar and stack canvas, toolbar, add_widgets - self.toolbar = IpywidgetToolBar(frame) - self.output = (frame.canvas, self.toolbar, *add_widgets) + self.output = (frame.canvas,) if use_sidecar: # instantiate sidecar if desired self.sidecar = Sidecar(**sidecar_kwargs) @@ -74,9 +57,6 @@ def close(self): """Closes the output context, cleanup all the stuff""" self.frame.canvas.close() - if self.toolbar is not None: - self.toolbar.close() - if self.sidecar is not None: self.sidecar.close() diff --git a/fastplotlib/layouts/output/qt_output.py b/fastplotlib/layouts/output/qt_output.py index 20aaef2d1..8d8f35c46 100644 --- a/fastplotlib/layouts/output/qt_output.py +++ b/fastplotlib/layouts/output/qt_output.py @@ -1,5 +1,4 @@ from ...utils.gui import QtWidgets -from ._qt_toolbar import QToolbar class QOutputContext(QtWidgets.QWidget): @@ -12,8 +11,6 @@ class QOutputContext(QtWidgets.QWidget): def __init__( self, frame, - make_toolbar, - add_widgets, ): """ @@ -36,22 +33,13 @@ def __init__( # add canvas to layout self.vlayout.addWidget(self.frame.canvas) - if make_toolbar: # make toolbar and add to layout - self.toolbar = QToolbar(output_context=self, figure=frame) - self.vlayout.addWidget(self.toolbar) - - for w in add_widgets: # add any additional widgets to layout - w.setParent(self) - self.vlayout.addWidget(w) - self.setLayout(self.vlayout) - self.resize(*self.frame._starting_size) - self.show() + self.resize(*self.frame._starting_size) + def close(self): """Cleanup and close the output context""" self.frame.canvas.close() - self.toolbar.close() super().close() # QWidget cleanup diff --git a/fastplotlib/layouts/output/qtoolbar.ui b/fastplotlib/layouts/output/qtoolbar.ui deleted file mode 100644 index 6c9aadae8..000000000 --- a/fastplotlib/layouts/output/qtoolbar.ui +++ /dev/null @@ -1,89 +0,0 @@ - - - QToolbar - - - - 0 - 0 - 638 - 48 - - - - Form - - - - - - - - autoscale - - - - - - - center - - - - - - - panzoom - - - true - - - - - - - - 75 - true - - - - 1:1 - - - true - - - - - - - ^ - - - - - - - polygon - - - - - - - record - - - true - - - - - - - - - - diff --git a/fastplotlib/layouts/ui/__init__.py b/fastplotlib/layouts/ui/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastplotlib/layouts/ui/_toolbar.py b/fastplotlib/layouts/ui/_toolbar.py new file mode 100644 index 000000000..16e2d41c4 --- /dev/null +++ b/fastplotlib/layouts/ui/_toolbar.py @@ -0,0 +1,52 @@ +from imgui_bundle import imgui, icons_fontawesome_6 as fa, imgui_ctx + +from .._plot_area import PlotArea + + +class SubplotToolbar: + def __init__(self, subplot: PlotArea, icons: imgui.ImFont): + self._subplot = subplot + self.icons = icons + + @property + def subplot(self): + return self._subplot + + def update(self): + # imgui.new_frame() + + x, y, width, height = self.subplot.get_rect() + + pos = (x, y + height) + + imgui.set_next_window_size((width, 0)) + imgui.set_next_window_pos(pos) + flags = imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_title_bar + + imgui.begin(f"Toolbar-{self._subplot.position}", p_open=None, flags=flags) + + imgui.push_font(self.icons) + with imgui_ctx.begin_horizontal(f"toolbar-{self._subplot.position}"): + if imgui.button(fa.ICON_FA_MAXIMIZE): + self._subplot.auto_scale() + imgui.pop_font() + + if imgui.is_item_hovered(0): + imgui.set_tooltip("autoscale scene") + + imgui.push_font(self.icons) + + if imgui.button(fa.ICON_FA_ALIGN_CENTER): + self._subplot.center_scene() + + _, self._subplot.controller.enabled = imgui.checkbox(fa.ICON_FA_COMPUTER_MOUSE, self._subplot.controller.enabled) + _, self._subplot.camera.maintain_aspect = imgui.checkbox(fa.ICON_FA_EXPAND, self._subplot.camera.maintain_aspect) + + imgui.pop_font() + + imgui.end() + # + # imgui.end_frame() + # imgui.render() + + # return imgui.get_draw_data() From 2265255c755e724de1a6960d69c0cf91fffe3d10 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 11 Jul 2024 19:17:53 -0400 Subject: [PATCH 2/9] works in multiple figs and canvas :D --- fastplotlib/layouts/_figure.py | 1 + fastplotlib/layouts/ui/_toolbar.py | 24 +++++++++++++++--------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 8cf5efe03..d8244fffb 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -418,6 +418,7 @@ def render(self): self.renderer.flush() + imgui.set_current_context(self.imgui_renderer.imgui_context) # begin making new frame data for imgui imgui.new_frame() diff --git a/fastplotlib/layouts/ui/_toolbar.py b/fastplotlib/layouts/ui/_toolbar.py index 16e2d41c4..13e345efe 100644 --- a/fastplotlib/layouts/ui/_toolbar.py +++ b/fastplotlib/layouts/ui/_toolbar.py @@ -1,20 +1,27 @@ from imgui_bundle import imgui, icons_fontawesome_6 as fa, imgui_ctx +from uuid import uuid4 from .._plot_area import PlotArea +ID_COUNTER = 0 + + class SubplotToolbar: def __init__(self, subplot: PlotArea, icons: imgui.ImFont): self._subplot = subplot self.icons = icons + global ID_COUNTER + ID_COUNTER += 1 + + self.id = ID_COUNTER + @property def subplot(self): return self._subplot def update(self): - # imgui.new_frame() - x, y, width, height = self.subplot.get_rect() pos = (x, y + height) @@ -26,27 +33,26 @@ def update(self): imgui.begin(f"Toolbar-{self._subplot.position}", p_open=None, flags=flags) imgui.push_font(self.icons) + + imgui.push_id(self.id) with imgui_ctx.begin_horizontal(f"toolbar-{self._subplot.position}"): if imgui.button(fa.ICON_FA_MAXIMIZE): self._subplot.auto_scale() - imgui.pop_font() + imgui.pop_font() if imgui.is_item_hovered(0): imgui.set_tooltip("autoscale scene") - imgui.push_font(self.icons) + imgui.push_font(self.icons) if imgui.button(fa.ICON_FA_ALIGN_CENTER): self._subplot.center_scene() _, self._subplot.controller.enabled = imgui.checkbox(fa.ICON_FA_COMPUTER_MOUSE, self._subplot.controller.enabled) _, self._subplot.camera.maintain_aspect = imgui.checkbox(fa.ICON_FA_EXPAND, self._subplot.camera.maintain_aspect) + imgui.pop_id() + imgui.pop_font() imgui.end() - # - # imgui.end_frame() - # imgui.render() - - # return imgui.get_draw_data() From d9da6ebb681c9749bb2139732bf2bd815f3d2fe9 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 11 Jul 2024 21:57:40 -0400 Subject: [PATCH 3/9] imagewidget sliders --- fastplotlib/layouts/_figure.py | 13 ++++ fastplotlib/layouts/_subplot.py | 7 +++ fastplotlib/layouts/ui/_toolbar.py | 27 +++++---- fastplotlib/widgets/__init__.py | 2 +- fastplotlib/widgets/image_widget/__init__.py | 0 fastplotlib/widgets/image_widget/_toolbar.py | 59 +++++++++++++++++++ .../widgets/{ => image_widget}/image.py | 46 +++++---------- 7 files changed, 111 insertions(+), 43 deletions(-) create mode 100644 fastplotlib/widgets/image_widget/__init__.py create mode 100644 fastplotlib/widgets/image_widget/_toolbar.py rename fastplotlib/widgets/{ => image_widget}/image.py (96%) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index d8244fffb..94eb4db41 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -130,6 +130,10 @@ def __init__( io.fonts.build() self.imgui_renderer.backend.create_fonts_texture() + # number of pixels in the width, height we reserve for imgui at the right and bottom edges of the canvas + # used for imagewidget sliders or other imgui stuff + self.imgui_reserved_canvas = [0, 0] + if isinstance(cameras, str): # create the array representing the views for each subplot in the grid cameras = np.array([cameras] * len(self)).reshape(self.shape) @@ -350,6 +354,8 @@ def __init__( else: self.recorder = None + self._imgui_updaters: list[callable] = list() + @property def toolbar(self): """ipywidget or QToolbar instance""" @@ -409,6 +415,9 @@ def __getitem__(self, index: tuple[int, int] | str) -> Subplot: else: return self._subplots[index[0], index[1]] + def add_imgui_ui(self, func: callable): + self._imgui_updaters.append(func) + def render(self): # call the animation functions before render self._call_animate_functions(self._animate_funcs_pre) @@ -426,6 +435,10 @@ def render(self): for subplot in self: subplot.toolbar.update() + # call any other imgui updaters + for func in self._imgui_updaters: + func() + # render new UI frame imgui.end_frame() imgui.render() diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index f57689065..6e672c759 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -179,6 +179,9 @@ def get_rect(self): row_ix, col_ix = self.position width_canvas, height_canvas = self.renderer.logical_size + width_canvas -= self.parent.imgui_reserved_canvas[0] + height_canvas -= self.parent.imgui_reserved_canvas[1] + # spacings for imgui toolbar height_canvas -= 50 if row_ix > 0: @@ -262,6 +265,10 @@ def get_rect(self, *args): row_ix_parent, col_ix_parent = self.parent.position width_canvas, height_canvas = self.parent.renderer.logical_size + + width_canvas -= self.parent.parent.imgui_reserved_canvas[0] + height_canvas -= self.parent.parent.imgui_reserved_canvas[1] + height_canvas -= 60 spacing = 2 # spacing in pixels diff --git a/fastplotlib/layouts/ui/_toolbar.py b/fastplotlib/layouts/ui/_toolbar.py index 13e345efe..feb6ede89 100644 --- a/fastplotlib/layouts/ui/_toolbar.py +++ b/fastplotlib/layouts/ui/_toolbar.py @@ -1,5 +1,4 @@ from imgui_bundle import imgui, icons_fontawesome_6 as fa, imgui_ctx -from uuid import uuid4 from .._plot_area import PlotArea @@ -12,17 +11,14 @@ def __init__(self, subplot: PlotArea, icons: imgui.ImFont): self._subplot = subplot self.icons = icons + # required to prevent conflict with multiple Figures global ID_COUNTER ID_COUNTER += 1 self.id = ID_COUNTER - @property - def subplot(self): - return self._subplot - def update(self): - x, y, width, height = self.subplot.get_rect() + x, y, width, height = self._subplot.get_rect() pos = (x, y + height) @@ -34,25 +30,36 @@ def update(self): imgui.push_font(self.icons) - imgui.push_id(self.id) + imgui.push_id(self.id) # push ID to prevent conflict between multiple figs with same UI with imgui_ctx.begin_horizontal(f"toolbar-{self._subplot.position}"): + # autoscale button if imgui.button(fa.ICON_FA_MAXIMIZE): self._subplot.auto_scale() - imgui.pop_font() if imgui.is_item_hovered(0): imgui.set_tooltip("autoscale scene") - + # center scene imgui.push_font(self.icons) if imgui.button(fa.ICON_FA_ALIGN_CENTER): self._subplot.center_scene() + # checkbox controller _, self._subplot.controller.enabled = imgui.checkbox(fa.ICON_FA_COMPUTER_MOUSE, self._subplot.controller.enabled) + + # checkbox maintain_apsect _, self._subplot.camera.maintain_aspect = imgui.checkbox(fa.ICON_FA_EXPAND, self._subplot.camera.maintain_aspect) + imgui.pop_font() + + _, flip_y = imgui.checkbox("flip-y", self._subplot.camera.local.scale_y < 0) + if flip_y and self._subplot.camera.local.scale_y > 0: + self._subplot.camera.local.scale_y *= -1 + elif not flip_y and self._subplot.camera.local.scale_y < 0: + self._subplot.camera.local.scale_y *= -1 + imgui.pop_id() - imgui.pop_font() + # imgui.pop_font() imgui.end() diff --git a/fastplotlib/widgets/__init__.py b/fastplotlib/widgets/__init__.py index 30a68d672..8dd7a5cb0 100644 --- a/fastplotlib/widgets/__init__.py +++ b/fastplotlib/widgets/__init__.py @@ -1,3 +1,3 @@ -from .image import ImageWidget +from fastplotlib.widgets.image_widget.image import ImageWidget __all__ = ["ImageWidget"] diff --git a/fastplotlib/widgets/image_widget/__init__.py b/fastplotlib/widgets/image_widget/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastplotlib/widgets/image_widget/_toolbar.py b/fastplotlib/widgets/image_widget/_toolbar.py new file mode 100644 index 000000000..e31eb4059 --- /dev/null +++ b/fastplotlib/widgets/image_widget/_toolbar.py @@ -0,0 +1,59 @@ +from imgui_bundle import imgui, icons_fontawesome_6 as fa, imgui_ctx + + +ID_COUNTER = 0 + + +class ImageWidgetToolbar: + def __init__(self, image_widget, icons: imgui.ImFont): + self._image_widget = image_widget + self.icons = icons + + # required to prevent conflict with multiple Figures + global ID_COUNTER + ID_COUNTER += 1 + + self.id = ID_COUNTER + + def update(self): + width, height = self._image_widget.figure.renderer.logical_size + width -= self._image_widget.figure.imgui_reserved_canvas[0] + height -= self._image_widget.figure.imgui_reserved_canvas[1] + + pos = (0, height) + + imgui.set_next_window_size((width, 0)) + imgui.set_next_window_pos(pos) + flags = imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_title_bar + + imgui.begin(f"ImageWidget controls", p_open=None, flags=flags) + + # imgui.push_font(self.icons) + + new_index = dict() + flag_index_changed = False + imgui.push_id(self.id) # push ID to prevent conflict between multiple figs with same UI + for dim in self._image_widget.slider_dims: + val = self._image_widget.current_index[dim] + vmax = self._image_widget._dims_max_bounds[dim] - 1 + + imgui.text(f"{dim}: ") + imgui.same_line() + imgui.set_next_item_width(width * 0.8) # so that sliders occupies full width + changed, index = imgui.slider_int(f"{dim}", v=val, v_min=0, v_max=vmax) + + new_index[dim] = index + + flag_index_changed |= changed + + # pad bottom of figure + self._image_widget.figure.imgui_reserved_canvas[1] = max(self._image_widget.n_scrollable_dims) * 40 + + if flag_index_changed: + self._image_widget.current_index = new_index + + imgui.pop_id() + + # imgui.pop_font() + + imgui.end() diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image_widget/image.py similarity index 96% rename from fastplotlib/widgets/image.py rename to fastplotlib/widgets/image_widget/image.py index df9b46b55..643d50154 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image_widget/image.py @@ -1,12 +1,13 @@ -from typing import Any, Literal, Callable +from typing import Any, Callable from warnings import warn import numpy as np -from ..layouts import Figure -from ..graphics import ImageGraphic -from ..utils import calculate_figure_shape -from .histogram_lut import HistogramLUT +from ...layouts import Figure +from ...graphics import ImageGraphic +from ...utils import calculate_figure_shape +from ..histogram_lut import HistogramLUT +from ._toolbar import ImageWidgetToolbar # Number of dimensions that represent one image/one frame. For grayscale shape will be [x, y], i.e. 2 dims, for RGB(A) @@ -170,11 +171,6 @@ def n_scrollable_dims(self) -> list[int]: """ return self._n_scrollable_dims - @property - def sliders(self) -> dict[str, Any]: - """the ipywidget IntSlider or QSlider instances used by the widget for indexing the desired dimensions""" - return self._image_widget_toolbar.sliders - @property def slider_dims(self) -> list[str]: """the dimensions that the sliders index""" @@ -272,12 +268,6 @@ def current_index(self, index: dict[str, int]): self._current_index.update(index) - # can make a callback_block decorator later - self.block_sliders = True - for k in index.keys(): - self.sliders[k].value = index[k] - self.block_sliders = False - for i, (ig, data) in enumerate(zip(self.managed_graphics, self.data)): frame = self._process_indices(data, self._current_index) frame = self._process_frame_apply(frame, i) @@ -537,7 +527,13 @@ def __init__( subplot.docks["right"].controller.enabled = False self.block_sliders = False - self._image_widget_toolbar = None + + # TODO: Can probably have a base class for all imgui UIs that fastplotlib + # supports and Figure() will take it as an instance and call update(). + # Figure will give it the font and any other config, maybe the ID_COUNTER too? + self.figure.imgui_reserved_canvas = [0, 75] + self._image_widget_toolbar = ImageWidgetToolbar(self, icons=self._figure._imgui_icons) + self.figure.add_imgui_ui(self._image_widget_toolbar.update) @property def frame_apply(self) -> dict | None: @@ -799,8 +795,6 @@ def set_data( if reset_indices: for key in self.current_index: self.current_index[key] = 0 - for key in self.sliders: - self.sliders[key].value = 0 # set slider max according to new data max_lengths = dict() @@ -879,9 +873,7 @@ def set_data( # set slider maxes # TODO: maybe make this stuff a property, like ndims, n_frames etc. and have it set the sliders - for key in self.sliders.keys(): - self.sliders[key].max = max_lengths[key] - self._dims_max_bounds[key] = max_lengths[key] + self._dims_max_bounds[key] = max_lengths[key] # force graphics to update self.current_index = self.current_index @@ -897,21 +889,11 @@ def show( OutputContext ImageWidget just uses the Gridplot output context """ - if self.figure.canvas.__class__.__name__ == "JupyterWgpuCanvas": - from ._image_widget_ipywidget_toolbar import IpywidgetImageWidgetToolbar - - self._image_widget_toolbar = IpywidgetImageWidgetToolbar(self) - - elif self.figure.canvas.__class__.__name__ == "QWgpuCanvas": - from ._image_widget_qt_toolbar import QToolbarImageWidget - - self._image_widget_toolbar = QToolbarImageWidget(self) self._output = self.figure.show( toolbar=toolbar, sidecar=sidecar, sidecar_kwargs=sidecar_kwargs, - add_widgets=[self._image_widget_toolbar], ) return self._output From a8e566a1e5592dc1959a38f2ce21d786a0029396 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 13 Jul 2024 05:35:35 -0400 Subject: [PATCH 4/9] begin right click menu --- fastplotlib/layouts/_figure.py | 9 ++- fastplotlib/layouts/ui/_right_click_menu.py | 90 +++++++++++++++++++++ fastplotlib/layouts/ui/_toolbar.py | 2 - 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 fastplotlib/layouts/ui/_right_click_menu.py diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 94eb4db41..c2df90f97 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -1,4 +1,3 @@ -import os from itertools import product, chain from multiprocessing import Queue from pathlib import Path @@ -10,7 +9,7 @@ from warnings import warn import imgui_bundle -from imgui_bundle import imgui, icons_fontawesome_6 as fa, imgui_ctx +from imgui_bundle import imgui, icons_fontawesome_6 as fa import pygfx @@ -21,6 +20,7 @@ from ._utils import make_canvas_and_renderer, create_controller, create_camera from ._utils import controller_types as valid_controller_types from ._subplot import Subplot +from .ui._right_click_menu import RightClickMenu from .. import ImageGraphic @@ -356,6 +356,8 @@ def __init__( self._imgui_updaters: list[callable] = list() + self._right_click_menu = RightClickMenu(self) + @property def toolbar(self): """ipywidget or QToolbar instance""" @@ -439,6 +441,9 @@ def render(self): for func in self._imgui_updaters: func() + # make right click menu + self._right_click_menu.update() + # render new UI frame imgui.end_frame() imgui.render() diff --git a/fastplotlib/layouts/ui/_right_click_menu.py b/fastplotlib/layouts/ui/_right_click_menu.py new file mode 100644 index 000000000..3aa5c18b8 --- /dev/null +++ b/fastplotlib/layouts/ui/_right_click_menu.py @@ -0,0 +1,90 @@ +from imgui_bundle import imgui, imgui_ctx + +from .._utils import controller_types +from .._plot_area import PlotArea + + +class RightClickMenu: + def __init__(self, figure): + self.figure = figure + + self._last_right_click_pos = None + + self._is_open = False + + def get_subplot(self) -> PlotArea: + if self._last_right_click_pos is None: + return False + + for subplot in self.figure: + if subplot.viewport.is_inside(*self._last_right_click_pos): + return subplot + + def update(self): + # TODO: detect mouse click vs. just pointer_down + # which is what imgui actually does, issue with + # imgui.is_mouse_clicked is that it conflicts with + # controller right-click + drag + if imgui.is_mouse_double_clicked(1): + # if not imgui.is_mouse_dragging(1): + self._last_right_click_pos = imgui.get_mouse_pos() + + if self.get_subplot(): + # open only if right click was inside a subplot + imgui.open_popup(f"right-click-menu") + self._is_open = True + self.figure.renderer.disable_events() + self.get_subplot().controller._actions = {} # cancel any scheduled events + + if imgui.begin_popup(f"right-click-menu"): + if imgui.menu_item(f"Autoscale", None, False)[0]: + self.get_subplot().auto_scale() + + if imgui.menu_item(f"Center", None, False)[0]: + self.get_subplot().auto_scale() + + _, enabled_controller = imgui.menu_item( + "Controller Enabled", None, self.get_subplot().controller.enabled + ) + self.get_subplot().controller.enabled = enabled_controller + + _, maintain_aspect = imgui.menu_item( + "Maintain Aspect", None, self.get_subplot().camera.maintain_aspect + ) + self.get_subplot().camera.maintain_aspect = maintain_aspect + + # controller must be disabled for this + # orig_val = self.get_subplot().controller.enabled + # self.get_subplot().controller.enabled = False + changed, fov = imgui.slider_int( + "FOV", + v=int(self.get_subplot().camera.fov), + v_min=0, + v_max=180, + ) + if changed: + self.get_subplot().controller.update_fov( + fov - self.get_subplot().camera.fov, animate=False + ) + # self.get_subplot().controller.enabled = orig_val + + if imgui.begin_menu("Controller Type"): + for name, controller_type_iter in controller_types.items(): + current_type = type(self.get_subplot().controller) + + clicked, _ = imgui.menu_item( + label=name, + shortcut=None, + p_selected=current_type is controller_type_iter, + ) + + if clicked and (current_type is not controller_type_iter): + # menu item was clicked and the desired controller isn't the current one + self.get_subplot().controller = name + + imgui.end_menu() + imgui.end_popup() + + elif self._is_open: + # went from open -> closed + self.figure.renderer.enable_events() diff --git a/fastplotlib/layouts/ui/_toolbar.py b/fastplotlib/layouts/ui/_toolbar.py index feb6ede89..177cfb55b 100644 --- a/fastplotlib/layouts/ui/_toolbar.py +++ b/fastplotlib/layouts/ui/_toolbar.py @@ -60,6 +60,4 @@ def update(self): imgui.pop_id() - # imgui.pop_font() - imgui.end() From 844858bb9d2d530c37c64183576d54c2750c10e3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 17 Jul 2024 23:01:25 -0400 Subject: [PATCH 5/9] changes --- fastplotlib/layouts/ui/_right_click_menu.py | 41 ++++++++++----------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/fastplotlib/layouts/ui/_right_click_menu.py b/fastplotlib/layouts/ui/_right_click_menu.py index 3aa5c18b8..fb2d128a1 100644 --- a/fastplotlib/layouts/ui/_right_click_menu.py +++ b/fastplotlib/layouts/ui/_right_click_menu.py @@ -43,31 +43,11 @@ def update(self): if imgui.menu_item(f"Center", None, False)[0]: self.get_subplot().auto_scale() - _, enabled_controller = imgui.menu_item( - "Controller Enabled", None, self.get_subplot().controller.enabled - ) - self.get_subplot().controller.enabled = enabled_controller - _, maintain_aspect = imgui.menu_item( "Maintain Aspect", None, self.get_subplot().camera.maintain_aspect ) self.get_subplot().camera.maintain_aspect = maintain_aspect - # controller must be disabled for this - # orig_val = self.get_subplot().controller.enabled - # self.get_subplot().controller.enabled = False - changed, fov = imgui.slider_int( - "FOV", - v=int(self.get_subplot().camera.fov), - v_min=0, - v_max=180, - ) - if changed: - self.get_subplot().controller.update_fov( - fov - self.get_subplot().camera.fov, animate=False - ) - # self.get_subplot().controller.enabled = orig_val - if imgui.begin_menu("Controller Type"): for name, controller_type_iter in controller_types.items(): current_type = type(self.get_subplot().controller) @@ -83,8 +63,27 @@ def update(self): self.get_subplot().controller = name imgui.end_menu() + + # if imgui.begin_menu("Controller"): + # _, enabled_controller = imgui.menu_item( + # "Enabled", None, self.get_subplot().controller.enabled + # ) + # + # self.get_subplot().controller.enabled = enabled_controller + # + # changed, damping = imgui.slider_int( + # "damping", + # v=int(self.get_subplot().controller.damping), + # v_min=0, + # v_max=10, + # ) + # if changed: + # self.get_subplot().controller.damping = damping + + + imgui.end_popup() - elif self._is_open: + # elif self._is_open: # went from open -> closed self.figure.renderer.enable_events() From fdc1f95277854fb0b043e4182709ad71c845cadf Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 28 Jul 2024 07:16:12 -0400 Subject: [PATCH 6/9] very functional right-click menu, depends on my pygfx.WgpuRenderer events filter --- fastplotlib/layouts/_figure.py | 86 +++++++++--------- fastplotlib/layouts/ui/_right_click_menu.py | 99 +++++++++++++-------- 2 files changed, 108 insertions(+), 77 deletions(-) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index c2df90f97..eaff73268 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -26,29 +26,29 @@ class Figure: def __init__( - self, - shape: tuple[int, int] = (1, 1), - 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 | WgpuCanvasBase | pygfx.Texture = None, - renderer: pygfx.WgpuRenderer = None, - size: tuple[int, int] = (500, 300), - names: list | np.ndarray = None, + self, + shape: tuple[int, int] = (1, 1), + 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 | WgpuCanvasBase | pygfx.Texture = None, + renderer: pygfx.WgpuRenderer = None, + size: tuple[int, int] = (500, 300), + names: list | np.ndarray = None, ): """ A grid of subplots. @@ -234,7 +234,7 @@ def __init__( for i, sublist in enumerate(controller_ids): for name in sublist: ids_init[subplot_names == name] = -( - i + 1 + i + 1 ) # use negative numbers because why not controller_ids = ids_init @@ -357,6 +357,7 @@ def __init__( self._imgui_updaters: list[callable] = list() self._right_click_menu = RightClickMenu(self) + self.imgui_renderer.set_gui(self.update_imgui) @property def toolbar(self): @@ -429,8 +430,14 @@ def render(self): self.renderer.flush() - imgui.set_current_context(self.imgui_renderer.imgui_context) - # begin making new frame data for imgui + self.imgui_renderer.render() + + self.canvas.request_draw() + + # call post-render animate functions + self._call_animate_functions(self._animate_funcs_post) + + def update_imgui(self): imgui.new_frame() # update the toolbars @@ -446,13 +453,10 @@ def render(self): # render new UI frame imgui.end_frame() + imgui.render() - self.imgui_renderer.render(imgui.get_draw_data()) - self.canvas.request_draw() - - # call post-render animate functions - self._call_animate_functions(self._animate_funcs_post) + return imgui.get_draw_data() def start_render(self): """start render cycle""" @@ -460,12 +464,12 @@ def start_render(self): self.canvas.set_logical_size(*self._starting_size) def show( - self, - autoscale: bool = True, - maintain_aspect: bool = None, - toolbar: bool = True, - sidecar: bool = False, - sidecar_kwargs: dict = None, + self, + autoscale: bool = True, + maintain_aspect: bool = None, + toolbar: bool = True, + sidecar: bool = False, + sidecar_kwargs: dict = None, ): """ Begins the rendering event loop and shows the plot in the desired output context (jupyter, qt or glfw). @@ -560,10 +564,10 @@ def _call_animate_functions(self, funcs: list[callable]): fn() def add_animations( - self, - *funcs: callable, - pre_render: bool = True, - post_render: bool = False, + self, + *funcs: callable, + pre_render: bool = True, + post_render: bool = False, ): """ Add function(s) that are called on every render cycle. diff --git a/fastplotlib/layouts/ui/_right_click_menu.py b/fastplotlib/layouts/ui/_right_click_menu.py index fb2d128a1..4062ecd12 100644 --- a/fastplotlib/layouts/ui/_right_click_menu.py +++ b/fastplotlib/layouts/ui/_right_click_menu.py @@ -1,4 +1,6 @@ -from imgui_bundle import imgui, imgui_ctx +import numpy as np + +from imgui_bundle import imgui from .._utils import controller_types from .._plot_area import PlotArea @@ -12,6 +14,29 @@ def __init__(self, figure): self._is_open = False + self._mouse_down: bool = False + + self.figure.renderer.event_filters["right-click-menu"] = np.array([ + [-1, -1], + [-1, -1] + ]) + + self.figure.renderer.event_filters["controller-menu"] = np.array([ + [-1, -1], + [-1, -1] + ]) + + def reset_event_filters(self): + for k in ["right-click-menu", "controller-menu"]: + self.figure.renderer.event_filters[k][:] = [-1, -1], [-1, -1] + + def set_event_filter(self, name: str): + x1, y1 = imgui.get_window_pos() + width, height = imgui.get_window_size() + x2, y2 = x1 + width, y1 + height + + self.figure.renderer.event_filters[name][:] = [x1 - 1, y1 - 1], [x2 + 4, y2 + 4] + def get_subplot(self) -> PlotArea: if self._last_right_click_pos is None: return False @@ -21,22 +46,26 @@ def get_subplot(self) -> PlotArea: return subplot def update(self): - # TODO: detect mouse click vs. just pointer_down - # which is what imgui actually does, issue with - # imgui.is_mouse_clicked is that it conflicts with - # controller right-click + drag - if imgui.is_mouse_double_clicked(1): - # if not imgui.is_mouse_dragging(1): + if imgui.is_mouse_down(1) and not self._mouse_down: + self._mouse_down = True self._last_right_click_pos = imgui.get_mouse_pos() - if self.get_subplot(): - # open only if right click was inside a subplot - imgui.open_popup(f"right-click-menu") - self._is_open = True - self.figure.renderer.disable_events() - self.get_subplot().controller._actions = {} # cancel any scheduled events + if imgui.is_mouse_released(1) and self._mouse_down: + self._mouse_down = False + + if self._last_right_click_pos == imgui.get_mouse_pos(): + if self.get_subplot(): + # open only if right click was inside a subplot + imgui.open_popup(f"right-click-menu") + self._is_open = True + self.get_subplot().controller._actions = {} # cancel any scheduled events + + if not imgui.is_popup_open("right-click-menu"): + self.reset_event_filters() if imgui.begin_popup(f"right-click-menu"): + self.set_event_filter("right-click-menu") + if imgui.menu_item(f"Autoscale", None, False)[0]: self.get_subplot().auto_scale() @@ -48,7 +77,27 @@ def update(self): ) self.get_subplot().camera.maintain_aspect = maintain_aspect - if imgui.begin_menu("Controller Type"): + if imgui.begin_menu("Controller"): + self.set_event_filter("controller-menu") + _, enabled = imgui.menu_item( + "Enabled", None, self.get_subplot().controller.enabled + ) + + self.get_subplot().controller.enabled = enabled + + changed, damping = imgui.slider_float( + "Damping", + v=self.get_subplot().controller.damping, + v_min=0.0, + v_max=10.0, + ) + + if changed: + self.get_subplot().controller.damping = damping + + imgui.separator() + imgui.text("Controller type:") + for name, controller_type_iter in controller_types.items(): current_type = type(self.get_subplot().controller) @@ -64,26 +113,4 @@ def update(self): imgui.end_menu() - # if imgui.begin_menu("Controller"): - # _, enabled_controller = imgui.menu_item( - # "Enabled", None, self.get_subplot().controller.enabled - # ) - # - # self.get_subplot().controller.enabled = enabled_controller - # - # changed, damping = imgui.slider_int( - # "damping", - # v=int(self.get_subplot().controller.damping), - # v_min=0, - # v_max=10, - # ) - # if changed: - # self.get_subplot().controller.damping = damping - - - imgui.end_popup() - - # elif self._is_open: - # went from open -> closed - self.figure.renderer.enable_events() From 6573d7df194cdac47d414a5c6edec6f65709dbd4 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 29 Jul 2024 05:08:44 -0400 Subject: [PATCH 7/9] use WgpuRenderer subclass until pygfx refactors update propogation --- fastplotlib/layouts/_utils.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/fastplotlib/layouts/_utils.py b/fastplotlib/layouts/_utils.py index 85c35532c..b26692ed3 100644 --- a/fastplotlib/layouts/_utils.py +++ b/fastplotlib/layouts/_utils.py @@ -2,11 +2,33 @@ import pygfx from pygfx import WgpuRenderer, Texture, Renderer +from pygfx.renderers.wgpu.engine.renderer import EVENT_TYPE_MAP, PointerEvent from wgpu.gui import WgpuCanvasBase from ..utils import gui +# temporary until https://github.com/pygfx/pygfx/issues/495 +class WgpuRendererWithEventFilters(WgpuRenderer): + def __init__(self, target, *args, **kwargs): + super().__init__(target, *args, **kwargs) + self._event_filters = {} + + def convert_event(self, event: dict): + event_type = event["event_type"] + + if EVENT_TYPE_MAP[event_type] is PointerEvent: + for filt in self.event_filters.values(): + if filt[0, 0] < event["x"] < filt[1, 0] and filt[0, 1] < event["y"] < filt[1, 1]: + return + + super().convert_event(event) + + @property + def event_filters(self) -> dict: + return self._event_filters + + def make_canvas_and_renderer( canvas: str | WgpuCanvasBase | Texture | None, renderer: Renderer | None ): @@ -27,7 +49,7 @@ def make_canvas_and_renderer( ) if renderer is None: - renderer = WgpuRenderer(canvas) + renderer = WgpuRendererWithEventFilters(canvas) elif not isinstance(renderer, Renderer): raise TypeError( f"renderer option must be a pygfx.Renderer instance such as pygfx.WgpuRenderer" From ae0e53d9b651708334c1558c3ffbf04ba26cf212 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 29 Jul 2024 05:58:29 -0400 Subject: [PATCH 8/9] some fixes --- fastplotlib/layouts/_subplot.py | 8 ++++++-- fastplotlib/layouts/ui/_right_click_menu.py | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index 6e672c759..256cd5332 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -269,7 +269,11 @@ def get_rect(self, *args): width_canvas -= self.parent.parent.imgui_reserved_canvas[0] height_canvas -= self.parent.parent.imgui_reserved_canvas[1] - height_canvas -= 60 + height_canvas -= 50 + if row_ix_parent > 0: + top_spacing = 50 + else: + top_spacing = 0 spacing = 2 # spacing in pixels @@ -307,7 +311,7 @@ def get_rect(self, *args): y_pos = ( (height_canvas / self.parent.nrows) + ((row_ix_parent - 1) * (height_canvas / self.parent.nrows)) - ) + spacing + ) + spacing + top_spacing width_viewport = (width_canvas / self.parent.ncols) - spacing height_viewport = self.size diff --git a/fastplotlib/layouts/ui/_right_click_menu.py b/fastplotlib/layouts/ui/_right_click_menu.py index 4062ecd12..7278d1a65 100644 --- a/fastplotlib/layouts/ui/_right_click_menu.py +++ b/fastplotlib/layouts/ui/_right_click_menu.py @@ -1,6 +1,6 @@ import numpy as np -from imgui_bundle import imgui +from imgui_bundle import imgui, imgui_ctx from .._utils import controller_types from .._plot_area import PlotArea @@ -70,7 +70,7 @@ def update(self): self.get_subplot().auto_scale() if imgui.menu_item(f"Center", None, False)[0]: - self.get_subplot().auto_scale() + self.get_subplot().center_scene() _, maintain_aspect = imgui.menu_item( "Maintain Aspect", None, self.get_subplot().camera.maintain_aspect From 0b6bca349b74f62ad06561d91378137e72e79b75 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 30 Jul 2024 00:09:00 -0400 Subject: [PATCH 9/9] axis flipping and FOV options in right click menu --- fastplotlib/layouts/ui/_right_click_menu.py | 49 ++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/fastplotlib/layouts/ui/_right_click_menu.py b/fastplotlib/layouts/ui/_right_click_menu.py index 7278d1a65..e4145791a 100644 --- a/fastplotlib/layouts/ui/_right_click_menu.py +++ b/fastplotlib/layouts/ui/_right_click_menu.py @@ -1,11 +1,25 @@ import numpy as np -from imgui_bundle import imgui, imgui_ctx +from imgui_bundle import imgui from .._utils import controller_types from .._plot_area import PlotArea +def flip_axis(subplot: PlotArea, axis: str, flip: bool): + camera = subplot.camera + axis_attr = f"scale_{axis}" + scale = getattr(camera.local, axis_attr) + + if flip and scale > 0: + # flip is checked and axis is not already flipped + setattr(camera.local, axis_attr, scale * -1) + + elif not flip and scale < 0: + # flip not checked and axis is flipped + setattr(camera.local, axis_attr, scale * -1) + + class RightClickMenu: def __init__(self, figure): self.figure = figure @@ -77,6 +91,39 @@ def update(self): ) self.get_subplot().camera.maintain_aspect = maintain_aspect + imgui.separator() + + for axis in ["x", "y", "z"]: + scale = getattr(self.get_subplot().camera.local, f"scale_{axis}") + changed, flip = imgui.menu_item( + f"Flip {axis} axis", None, scale < 0 + ) + + if changed: + flip_axis(self.get_subplot(), axis, flip) + + imgui.separator() + + changed, fov = imgui.slider_float( + "FOV", + v=self.get_subplot().camera.fov, + v_min=0.0, + v_max=180.0 + ) + + imgui.separator() + + if changed: + # FOV between 0 and 1 is numerically unstable + if 0 < fov < 1: + fov = 1 + self.get_subplot().controller.update_fov( + fov - self.get_subplot().camera.fov, + animate=False, + ) + + imgui.separator() + if imgui.begin_menu("Controller"): self.set_event_filter("controller-menu") _, enabled = imgui.menu_item(