Source code for fastplotlib.ui._base

from __future__ import annotations
import inspect
from collections.abc import Callable
from functools import partial
from typing import Literal
from warnings import warn
import weakref

from imgui_bundle import imgui

from ..layouts._rect import RectManager

# edges that reserve space, ordered as they are carved from the render area
EDGES = ["left", "right", "top", "bottom"]

# all valid keyed locations, "toolbar" is subplot only, "floating" uses auto-placement
LOCATIONS = EDGES + ["toolbar", "floating"]


def _wrap_update_call(func: Callable, parent) -> Callable:
    """
    Wrap an imgui draw function for use as a window or popup update call. The parent, a ``Figure``, ``Subplot`` or
    ``Graphic``, is passed as the only positional arg if the function accepts one, otherwise the function is called
    with no args.

    The parent is held weakly. The parent owns the window or popup, so a strong reference here would put them in a
    reference cycle and a graphic would only be released once the cyclic collector runs, not when the last
    reference to it is dropped.
    """
    params = inspect.signature(func).parameters.values()
    takes_arg = any(
        p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD, p.VAR_POSITIONAL)
        for p in params
    )
    if not takes_arg:
        return func

    parent_ref = weakref.ref(parent)

    def update_call():
        parent = parent_ref()
        if parent is not None:
            func(parent)

    return update_call


[docs] class ImguiBase: """ Base class for all ImGUI based GUIs, windows and popups The main purpose of this base is for setting a unique ID between multiple figs with identical UI elements This ID can be pushed in subclasses within the `update()` method """ ID_COUNTER: int = 0 def __init__(self): ImguiBase.ID_COUNTER += 1 self._id_counter = ImguiBase.ID_COUNTER
[docs] def draw(self): """must be implemented in subclass""" raise NotImplementedError
[docs] class ImguiContainer(ImguiBase): """ Base class for imgui GUIs that are drawn inline within a window, for example an ``ImguiColorbar`` or a ``LegendItem``. Subclass and implement ``update()`` to draw imgui elements. Containers are not added directly, pass one to ``Figure.add_imgui_window()`` or ``Subplot.add_imgui_window()`` for a window of its own, to ``append_imgui_window()`` to draw it in a window that already exists, or to ``Legend.add()``. """ def __init__(self): super().__init__() # the figure this container is drawn in, set by the host via _fpl_add_hook() self._figure = None @property def figure(self): """the figure this container is drawn in""" return self._figure def _fpl_add_hook(self, figure): """ Set the figure this container is drawn in, called by the host when the container is added. Parameters ---------- figure: ImguiFigure the figure this container is drawn in """ self._figure = figure
[docs] def draw(self): """pushes and pops the ID to keep identical containers unique within one frame""" imgui.push_id(self._id_counter) self.update() imgui.pop_id()
[docs] def update(self): """Implement your GUI here and it will be drawn within the host window""" raise NotImplementedError
[docs] class ImguiWindow(ImguiBase): def __init__(self, update_call: Callable = None): """ An imgui window drawn within a Figure. Subclass and implement ``update()`` to draw imgui elements, or pass a callable as ``update_call`` (this is what the ``add_imgui_window()`` decorator does). A bare ``ImguiWindow()`` does neither and draws only what is appended to it with ``append_imgui_window()``. Windows are not added directly, use ``Figure.add_imgui_window()`` or ``Subplot.add_imgui_window()`` which provide the host and placement, i.e. location, size, window flags, etc., via ``_fpl_add_hook()``. Parameters ---------- update_call: callable a callable that draws imgui elements, used instead of ``update()`` when decorating, see ``add_imgui_window`` """ super().__init__() # imgui element draw calls, run in order within the window on each render if update_call is not None: self._update_calls = [update_call] elif type(self).update is ImguiWindow.update: # update() is not implemented, this window draws only what is appended to it self._update_calls = list() else: self._update_calls = [self.update] # host and placement, set by the host in add_imgui_window() via _fpl_add_hook() self._figure = None self._subplot = None self._location = None self._size = None self._rect_manager = None self._floating = False self._title = None self._window_flags = ( imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_resize | imgui.WindowFlags_.no_title_bar ) # pixel rect, set by the host on each layout pass self._x, self._y, self._width, self._height = 0, 0, 0, 0 # resize and collapse state, only used by figure-level resizeable edge windows self._resize_cursor_set = False self._resize_blocked = False self._right_gui_resizing = False self._separator_thickness = 14.0 self._collapsed = False self._old_size = None def _fpl_add_hook( self, figure, subplot=None, location: Literal[ "left", "right", "top", "bottom", "toolbar", "floating" ] = None, size: int = None, rect: tuple = None, extent: tuple = None, title: str = None, window_flags: imgui.WindowFlags_ = None, ): """ Set the host and placement of this window, called by ``Figure.add_imgui_window()`` or ``Subplot.add_imgui_window()``. Parameters ---------- figure: ImguiFigure the figure this window is drawn in subplot: Subplot, optional the subplot this window is confined to, ``None`` for figure-level windows location: str, "left" | "right" | "top" | "bottom" | "toolbar" | "floating" edge and toolbar windows reserve canvas space, "floating" is auto-sized and draggable size: int edge or toolbar thickness in pixels 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_`` window flag enum, can be combined with the ``|`` operator. If not provided, the default depends on the placement: edge and toolbar windows use ``no_collapse | no_resize | no_title_bar | no_bring_to_front_on_focus`` (custom title bar, and they stay behind floating and fixed overlays); floating windows use ``none`` (native imgui title bar, collapsible and movable); fixed rect/extent windows use ``no_collapse | no_move | no_resize`` (native imgui title bar). Valid flags are: .. code-block:: py imgui.WindowFlags_.no_title_bar imgui.WindowFlags_.no_resize imgui.WindowFlags_.no_move imgui.WindowFlags_.no_scrollbar imgui.WindowFlags_.no_scroll_with_mouse imgui.WindowFlags_.no_collapse imgui.WindowFlags_.always_auto_resize imgui.WindowFlags_.no_background imgui.WindowFlags_.no_saved_settings imgui.WindowFlags_.no_mouse_inputs imgui.WindowFlags_.menu_bar imgui.WindowFlags_.horizontal_scrollbar imgui.WindowFlags_.no_focus_on_appearing imgui.WindowFlags_.no_bring_to_front_on_focus imgui.WindowFlags_.always_vertical_scrollbar imgui.WindowFlags_.always_horizontal_scrollbar imgui.WindowFlags_.no_nav_inputs imgui.WindowFlags_.no_nav_focus imgui.WindowFlags_.unsaved_document imgui.WindowFlags_.no_docking imgui.WindowFlags_.no_nav imgui.WindowFlags_.no_decoration imgui.WindowFlags_.no_inputs """ self._figure = figure self._subplot = subplot self._location = location self._size = int(size) if size is not None else None self._title = title self._floating = location == "floating" if rect is not None: width, height = figure.canvas.get_logical_size() self._rect_manager = RectManager(*rect, (0, 0, width, height)) elif extent is not None: width, height = figure.canvas.get_logical_size() self._rect_manager = RectManager.from_extent(extent, (0, 0, width, height)) if window_flags is None: # edge and toolbar windows draw their own title bar; floating and fixed windows use the native # imgui title bar so they can be collapsed, and floating windows can also be moved if location in EDGES or location == "toolbar": # reserved windows never come to front on focus, otherwise clicking one would bury a # floating or fixed overlay drawn over it and make the overlay inaccessible window_flags = ( imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_resize | imgui.WindowFlags_.no_title_bar | imgui.WindowFlags_.no_bring_to_front_on_focus ) elif location == "floating": window_flags = imgui.WindowFlags_.none else: # fixed rect or extent window window_flags = ( imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_move | imgui.WindowFlags_.no_resize ) self._window_flags = window_flags @property def location(self) -> str: """location of the window""" return self._location @property def size(self) -> int | None: """edge or toolbar thickness in pixels, ``None`` for floating and fractional windows""" return self._size @size.setter def size(self, value: int): if not isinstance(value, int): raise TypeError(f"{self.__class__.__name__}.size must be an <int>") self._size = value # reserving windows change the layout when resized if self._reserves and self._figure is not None: self._figure._fpl_reset_layout() @property def window_flags(self) -> imgui.WindowFlags_: """imgui window flags""" return self._window_flags @window_flags.setter def window_flags(self, flags: imgui.WindowFlags_): self._window_flags = flags @property def x(self) -> int: """canvas x position of the window""" return self._x @property def y(self) -> int: """canvas y position of the window""" return self._y @property def width(self) -> int: """width of the window""" return self._width @property def height(self) -> int: """height of the window""" return self._height @property def collapsed(self) -> bool: if self._location not in ("bottom", "right"): # TODO: for now only bottom and right UIs support collapsing due to legacy reasons, will fix later return False return self._collapsed @collapsed.setter def collapsed(self, val: bool): if self._location not in ("bottom", "right"): warn("only 'bottom' and 'right' locations support `collapsed`") return self._collapsed = val @property def _reserves(self) -> bool: """whether this window reserves canvas space, i.e. edge or toolbar windows""" return self._location in EDGES or self._location == "toolbar" def _fpl_set_rect(self, x: int, y: int, width: int, height: int): """set the pixel rect, called by the host on each layout pass""" self._x, self._y, self._width, self._height = x, y, width, height def _draw_resize_handle(self): if self._location not in ("bottom", "right"): return if self._location == "bottom": imgui.set_cursor_pos((0, 0)) imgui.invisible_button( "##resize_handle", imgui.ImVec2(imgui.get_window_width(), self._separator_thickness), ) hovered = imgui.is_item_hovered() active = imgui.is_item_active() # Get the actual screen rect of the button after it's been laid out rect_min = imgui.get_item_rect_min() rect_max = imgui.get_item_rect_max() elif self._location == "right": imgui.set_cursor_pos((0, 0)) screen_pos = imgui.get_cursor_screen_pos() win_height = imgui.get_window_height() mouse_pos = imgui.get_mouse_pos() rect_min = imgui.ImVec2(screen_pos.x, screen_pos.y) rect_max = imgui.ImVec2( screen_pos.x + self._separator_thickness, screen_pos.y + win_height ) hovered = ( rect_min.x <= mouse_pos.x <= rect_max.x and rect_min.y <= mouse_pos.y <= rect_max.y ) if hovered and imgui.is_mouse_clicked(0): self._right_gui_resizing = True if not imgui.is_mouse_down(0): self._right_gui_resizing = False active = self._right_gui_resizing imgui.set_cursor_pos((self._separator_thickness, 0)) if hovered and imgui.is_mouse_double_clicked(0): if not self._collapsed: self._old_size = self.size if self._location == "bottom": self.size = int(self._separator_thickness) elif self._location == "right": self.size = int(self._separator_thickness) self._collapsed = True else: self.size = self._old_size self._collapsed = False if hovered or active: if not self._resize_cursor_set: if self._location == "bottom": self._figure.canvas.set_cursor("ns_resize") elif self._location == "right": self._figure.canvas.set_cursor("ew_resize") self._resize_cursor_set = True imgui.set_tooltip("Drag to resize, double click to expand/collapse") elif self._resize_cursor_set: self._figure.canvas.set_cursor("default") self._resize_cursor_set = False if active and imgui.is_mouse_dragging(0): if self._location == "bottom": delta = imgui.get_mouse_drag_delta(0).y elif self._location == "right": delta = imgui.get_mouse_drag_delta(0).x imgui.reset_mouse_drag_delta(0) px, py, pw, ph = self._figure.get_pygfx_render_area() if self._location == "bottom": new_render_size = ph + delta elif self._location == "right": new_render_size = pw + delta # check if the new size would make the pygfx render area too small if (delta < 0) and (new_render_size < 150): print("not enough render area") self._resize_blocked = True if self._resize_blocked: # check if cursor has returned if self._location == "bottom": _min, pos, _max = rect_min.y, imgui.get_mouse_pos().y, rect_max.y elif self._location == "right": _min, pos, _max = rect_min.x, imgui.get_mouse_pos().x, rect_max.x if ((_min - 5) <= pos <= (_max + 5)) and delta > 0: # if the mouse cursor is back on the bar and the delta > 0, i.e. render area increasing self._resize_blocked = False if not self._resize_blocked: self.size = max(30, round(self.size - delta)) self._collapsed = False draw_list = imgui.get_window_draw_list() line_color = ( imgui.get_color_u32(imgui.ImVec4(0.9, 0.9, 0.9, 1.0)) if (hovered or active) else imgui.get_color_u32(imgui.ImVec4(0.5, 0.5, 0.5, 0.8)) ) bg_color = ( imgui.get_color_u32(imgui.ImVec4(0.2, 0.2, 0.2, 0.8)) if (hovered or active) else imgui.get_color_u32(imgui.ImVec4(0.15, 0.15, 0.15, 0.6)) ) # Background bar draw_list.add_rect_filled( imgui.ImVec2(rect_min.x, rect_min.y), imgui.ImVec2(rect_max.x, rect_max.y), bg_color, ) # Three grip dots centered on the line dot_spacing = 7.0 dot_radius = 2 if self._location == "bottom": mid_y = (rect_min.y + rect_max.y) * 0.5 center_x = (rect_min.x + rect_max.x) * 0.5 for i in (-1, 0, 1): cx = center_x + i * dot_spacing draw_list.add_circle_filled( imgui.ImVec2(cx, mid_y), dot_radius, line_color ) imgui.set_cursor_pos( (0, imgui.get_cursor_pos_y() - imgui.get_style().item_spacing.y) ) elif self._location == "right": mid_x = (rect_min.x + rect_max.x) * 0.5 center_y = (rect_min.y + rect_max.y) * 0.5 for i in (-1, 0, 1): cy = center_y + i * dot_spacing draw_list.add_circle_filled( imgui.ImVec2(mid_x, cy), dot_radius, line_color ) def _draw_title(self, title: str): padding = imgui.ImVec2(10, 4) text_size = imgui.calc_text_size(title) win_width = imgui.get_window_width() box_size = imgui.ImVec2(win_width, text_size.y + padding.y * 2) box_screen_pos = imgui.get_cursor_screen_pos() draw_list = imgui.get_window_draw_list() # Background — use imgui's default title bar color draw_list.add_rect_filled( imgui.ImVec2(box_screen_pos.x, box_screen_pos.y), imgui.ImVec2(box_screen_pos.x + box_size.x, box_screen_pos.y + box_size.y), imgui.get_color_u32(imgui.Col_.title_bg_active), ) # Centered text text_pos = imgui.ImVec2( box_screen_pos.x + (win_width - text_size.x) * 0.5, box_screen_pos.y + padding.y, ) draw_list.add_text( text_pos, imgui.get_color_u32(imgui.ImVec4(1, 1, 1, 1)), title ) imgui.dummy(imgui.ImVec2(win_width, box_size.y))
[docs] def draw(self): """helps simplify using imgui by managing window creation & position, and pushing/popping the ID""" # window position & size if self._floating: # floating windows are auto-sized by imgui, only set the initial position imgui.set_next_window_pos((self.x, self.y), imgui.Cond_.appearing) else: imgui.set_next_window_size((self.width, self.height)) imgui.set_next_window_pos((self.x, self.y)) # append the id to keep the window unique without changing the visible title expanded = imgui.begin( f"{self._title or ''}##{self._id_counter}", p_open=None, flags=self._window_flags, ) if self._reserves: # edge and toolbar windows draw a custom title bar and collapse via the resize handle # resize handle for right and bottom edge windows on the figure if self._subplot is None and self._location in ("bottom", "right"): self._draw_resize_handle() # push ID to prevent conflict between multiple figs with same UI imgui.push_id(self._id_counter) # collapse the UI if the separator state is collapsed # otherwise the UI renders partially on the separator for "right" guis and it looks weird main_height = 1.0 if self._collapsed else 0.0 imgui.begin_child("##main_ui", imgui.ImVec2(0, main_height)) if self._title is not None: self._draw_title(self._title) imgui.indent(6.0) # draw imgui elements from the subclass or decorated function(s) for update_call in self._update_calls: update_call() imgui.end_child() imgui.pop_id() elif expanded: # floating and fixed windows use the native imgui title bar; only draw when not collapsed imgui.push_id(self._id_counter) for update_call in self._update_calls: update_call() imgui.pop_id() # end the window imgui.end()
[docs] def update(self): """Implement your GUI here and it will be drawn within the window. See the GUI examples""" raise NotImplementedError
[docs] class ImguiPopup(ImguiBase): def __init__(self, update_call: Callable = None): """ An imgui popup drawn within a Figure, opened by a right-click. Subclass and implement ``update()`` to draw imgui elements, or pass a callable as ``update_call``. Popups are not added directly, use ``ImguiFigure.set_imgui_right_click()``, ``Subplot.set_imgui_right_click()`` or ``Graphic.set_imgui_right_click()`` which provide the parent and window flags via ``_fpl_add_hook()``. Parameters ---------- update_call: callable a callable that draws imgui elements, used instead of ``update()``, see ``set_imgui_right_click`` """ super().__init__() if update_call is None: self._update_calls = [self.update] else: self._update_calls = [update_call] # parent, set by the parent in set_imgui_right_click() via _fpl_add_hook() self._figure = None self._parent = None self._window_flags = imgui.WindowFlags_.none # popups are identified by a str id, the counter keeps it unique between popups self._popup_id = f"popup##{self._id_counter}" # what this popup was opened on, set by the right-click dispatch in Subplot self._subplot = None self._graphic = None self._open_requested = False self._pos = None self._is_open = False def _fpl_add_hook( self, figure, parent, window_flags: imgui.WindowFlags_ = None, ): """ Set the parent of this popup, called by ``set_imgui_right_click()``. Parameters ---------- figure: ImguiFigure the figure this popup is drawn in parent: ImguiFigure | Subplot | Graphic the object this popup is set on window_flags: ``imgui.WindowFlags_`` window flag enum, can be combined with the ``|`` operator, see ``ImguiWindow._fpl_add_hook`` for the valid flags """ self._figure = figure # weak, the parent owns this popup and a strong reference here would put them in a cycle self._parent = weakref.ref(parent) if window_flags is not None: self._window_flags = window_flags @property def parent(self): """the object this popup is set on, an ``ImguiFigure``, ``Subplot`` or ``Graphic``""" if self._parent is None: return None return self._parent() @property def subplot(self): """the subplot this popup was opened in""" return self._subplot @property def graphic(self): """the graphic this popup was opened on, ``None`` if it was not opened on a graphic""" return self._graphic @property def is_open(self) -> bool: """whether the popup is currently open""" return self._is_open @property def window_flags(self) -> imgui.WindowFlags_: """imgui window flags""" return self._window_flags @window_flags.setter def window_flags(self, flags: imgui.WindowFlags_): self._window_flags = flags
[docs] def open(self, pos: tuple[int, int] = None): """ Request that this popup is opened on the next render. Parameters ---------- pos: (int, int), optional canvas position of the popup, imgui uses the current mouse position if not provided """ self._pos = pos self._open_requested = True
def _fpl_open(self, subplot, graphic): """set what the popup is opened on and open it, called by the right-click dispatch in ``Subplot``""" self._subplot = subplot self._graphic = graphic self.open() def _fpl_close(self): """called when another popup replaces this one as the open popup""" self._is_open = False
[docs] def draw(self): """helps simplify using imgui by managing the popup open state, and pushing/popping the ID""" if self._open_requested: self._open_requested = False if self._pos is not None: imgui.set_next_window_pos(self._pos) imgui.open_popup(self._popup_id) if imgui.begin_popup(self._popup_id, self._window_flags): self._is_open = True # push ID to prevent conflict between multiple figs with same UI imgui.push_id(self._id_counter) for update_call in self._update_calls: update_call() imgui.pop_id() imgui.end_popup() else: self._is_open = False
[docs] def update(self): """Implement your GUI here and it will be drawn within the popup. See the GUI examples""" raise NotImplementedError