Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
hidable controls, temp "corner" config to prevent -y/-x axis
  • Loading branch information
FlynnOConnell committed Sep 25, 2026
commit c770ad041d40b41ff4a6d17cbdd2fbea6ea612f4
4 changes: 4 additions & 0 deletions docs/source/user_guide/guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -899,10 +899,12 @@ the argument, where ``init`` is the constructor::
fpl.LineGraphic.config.init.thickness = 5.0
fpl.ImageGraphic.config.init.cmap = "gray"
fpl.Axes.config.init.grids = False
fpl.Axes.config.init.corner = True # rulers stop at their intersection
fpl.Figure.config.init.size = (900, 700)
fpl.Figure.config.show.axes_visible = False
fpl.layouts.Subplot.config.init.toolbar = False
fpl.layouts.Subplot.config.auto_scale.zoom = 0.9
fpl.NDWidget.config.init.controls = False # sliders without the playback controls

Configurable components:

Expand All @@ -915,6 +917,8 @@ Configurable components:
+----------------------------------------------------+--------------------------+
| ``fastplotlib.Axes`` | ``init`` |
+----------------------------------------------------+--------------------------+
| ``fastplotlib.NDWidget`` | ``init`` |
+----------------------------------------------------+--------------------------+
| every ``Graphic``, ex. ``fastplotlib.LineGraphic`` | ``init`` |
+----------------------------------------------------+--------------------------+

Expand Down
9 changes: 9 additions & 0 deletions docs/source/user_guide/nd_widget.rst
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ increment used by the step buttons and by playback::
names=["video", "ephys"],
)

The playback controls above each slider, i.e. play, step, stop, loop and framerate, can be left out so that the
bottom of the widget is one labelled slider per dim, and the title bar of that window with them::

ndw = fpl.NDWidget(ranges={"time": (0.0, 600.0, 1 / 30)}, controls=False, title=None)

ndw.ui_sliders.controls = True # bring them back

This is also a global default, ``fpl.NDWidget.config.init.controls = False``, see :ref:`global_configuration`.

Each array maps a slider position onto its own indices with ``slider_maps``. An array of reference values, such as
the timestamp of every frame or sample, is used through its ``searchsorted``, and a callable can be given for any
other mapping::
Expand Down
30 changes: 30 additions & 0 deletions fastplotlib/axes/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ class Axes:

@global_config.declare(
"intersection",
"corner",
"tick_size",
"line_width",
"tick_marker",
Expand All @@ -282,6 +283,7 @@ def __init__(
self,
plot_area,
intersection: tuple[int, int, int] | None = None,
corner: bool = False,
tick_size: float = 8.0,
line_width: float = 2.0,
tick_marker: str = "tick",
Expand Down Expand Up @@ -429,6 +431,7 @@ def __init__(
self._grids = False

self._intersection = intersection
self._corner = corner
self._auto_grid = auto_grid

self._basis = None
Expand Down Expand Up @@ -548,6 +551,23 @@ def intersection(self, intersection: tuple[float, float, float] | None):

self._intersection = tuple(float(v) for v in intersection)

@property
def corner(self) -> bool:
"""
Stop the rulers at their intersection so they form a corner, an L shape.

``False`` (default) extends every ruler through the intersection to the edges of the view. ``True`` cuts
each ruler at the intersection and keeps its longer part, so with the default ``intersection=None`` the
x ruler runs from the bottom left corner of the view to the right and the y ruler from there upwards.
"""
return self._corner

@corner.setter
def corner(self, value: bool):
self._corner = bool(value)
# redo the placement on the next render
self._last_state = None

def _get_view_state(self) -> tuple:
viewport = self._plot_area.viewport
cam_matrix = self._plot_area.camera.camera_matrix.tobytes()
Expand Down Expand Up @@ -706,6 +726,16 @@ def update(self, bbox, intersection):

"""

bbox = np.array(bbox, dtype=np.float64)

if self._corner:
# each ruler stops at the intersection and keeps its longer part
for dim, value in enumerate(intersection):
if abs(bbox[1, dim] - value) >= abs(value - bbox[0, dim]):
bbox[0, dim] = value
else:
bbox[1, dim] = value

world_xmin, world_ymin, world_zmin = bbox[0]
world_xmax, world_ymax, world_zmax = bbox[1]
world_x_10, world_y_10, world_z_10 = intersection
Expand Down
22 changes: 19 additions & 3 deletions fastplotlib/widgets/nd_widget/_ndwidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@
from ._ndw_subplot import NDWSubplot
from ._ui import NDWidgetUI, RightClickMenu
from ...layouts import ImguiFigure, Subplot
from ...utils import global_config


@global_config.register
class NDWidget:
config = global_config.descriptor

@global_config.declare("controls", "title")
def __init__(
self,
ranges: dict[str, tuple] = None,
indices: Optional[ReferenceIndices] = None,
controls: bool = True,
title: str | None = "NDWidget controls",
**kwargs,
):
"""
Expand Down Expand Up @@ -55,6 +62,13 @@ def __init__(
Multiple ``NDWidget`` instances that share a ``ReferenceIndex`` are synchronized, so one set of sliders
can drive data displayed across several windows.

controls: bool, default True
show the playback controls, i.e. play, step, stop, loop and framerate, above each slider. ``False``
leaves only a labelled slider per slider dim, see ``ui_sliders.controls`` to change it later.

title: str or None, default "NDWidget controls"
title bar of the slider window at the bottom of the figure, ``None`` draws no title bar

kwargs
passed to :class:`.ImguiFigure`

Expand Down Expand Up @@ -95,11 +109,13 @@ def __init__(
self._subplots_nd[subplot] = NDWSubplot(self, subplot)

# hard code the expected height so that the first render looks right in tests, docs etc.
ui_size = 57 + (50 * len(self.indices))
# the window sets its own height from its contents after the first frame
row = 50 if controls else 24
ui_size = (57 if title is not None else 31) + (row * len(self.indices))

self._sliders_ui = NDWidgetUI(self)
self._sliders_ui = NDWidgetUI(self, controls=controls)
self.figure.add_imgui_window(
self._sliders_ui, location="bottom", size=ui_size, title="NDWidget controls"
self._sliders_ui, location="bottom", size=ui_size, title=title
)

@property
Expand Down
133 changes: 75 additions & 58 deletions fastplotlib/widgets/nd_widget/_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@
class NDWidgetUI(ImguiWindow):
"""Playback controls and a slider for each slider dim, shown at the bottom of an ``NDWidget``"""

def __init__(self, ndwidget):
def __init__(self, ndwidget, controls: bool = True):
super().__init__()
self._ndwidget = ndwidget
self._controls = controls

# whether or not a dimension is in play mode
self._playing = dict()
Expand Down Expand Up @@ -73,6 +74,15 @@ def push_dim(self, dim):
# for however many frames the machine can render within 1 / fps seconds
self._frame_time[dim] = 0

@property
def controls(self) -> bool:
"""show the playback controls, i.e. play, step, stop, loop and framerate, above each slider"""
return self._controls

@controls.setter
def controls(self, value: bool):
self._controls = bool(value)

def pop_dim(self, dim):
"""remove the playback & slider UI state for a removed dim"""
self._playing.pop(dim)
Expand Down Expand Up @@ -107,69 +117,76 @@ def update(self):

rr = self._ndwidget.ranges[dim]

if self._playing[dim]:
# show pause button if playing
if imgui.button(label=fa.ICON_FA_PAUSE):
# if pause button clicked, then set playing to false
self._playing[dim] = False
if self._controls:
if self._playing[dim]:
# show pause button if playing
if imgui.button(label=fa.ICON_FA_PAUSE):
# if pause button clicked, then set playing to false
self._playing[dim] = False

# if in play mode and enough time has elapsed w.r.t. the desired framerate, increment the index
if now - self._last_frame_time[dim] >= self._frame_time[dim]:
# if in play mode and enough time has elapsed w.r.t. the desired framerate, increment the index
if now - self._last_frame_time[dim] >= self._frame_time[dim]:
self._set_index(dim, current_index + rr.step)
self._last_frame_time[dim] = now

else:
# we are not playing, so display play button
if imgui.button(label=fa.ICON_FA_PLAY):
# if play button is clicked, set last frame time to 0 so that index increments on next render
self._last_frame_time[dim] = 0
# set playing to True since play button was clicked
self._playing[dim] = True

imgui.same_line()
# step back one frame button
if (
imgui.button(label=fa.ICON_FA_BACKWARD_STEP)
and not self._playing[dim]
):
self._set_index(dim, current_index - rr.step)

imgui.same_line()
# step forward one frame button
if (
imgui.button(label=fa.ICON_FA_FORWARD_STEP)
and not self._playing[dim]
):
self._set_index(dim, current_index + rr.step)
self._last_frame_time[dim] = now

else:
# we are not playing, so display play button
if imgui.button(label=fa.ICON_FA_PLAY):
# if play button is clicked, set last frame time to 0 so that index increments on next render
imgui.same_line()
# stop button
if imgui.button(label=fa.ICON_FA_STOP):
self._playing[dim] = False
self._last_frame_time[dim] = 0
# set playing to True since play button was clicked
self._playing[dim] = True

imgui.same_line()
# step back one frame button
if imgui.button(label=fa.ICON_FA_BACKWARD_STEP) and not self._playing[dim]:
self._set_index(dim, current_index - rr.step)

imgui.same_line()
# step forward one frame button
if imgui.button(label=fa.ICON_FA_FORWARD_STEP) and not self._playing[dim]:
self._set_index(dim, current_index + rr.step)
self._ndwidget.indices.set_dim_index(dim, rr.start)

imgui.same_line()
# stop button
if imgui.button(label=fa.ICON_FA_STOP):
self._playing[dim] = False
self._last_frame_time[dim] = 0
self._ndwidget.indices.set_dim_index(dim, rr.start)

imgui.same_line()
# loop checkbox
_, self._loop[dim] = imgui.checkbox(
label=fa.ICON_FA_ROTATE, v=self._loop[dim]
)
if imgui.is_item_hovered(0):
imgui.set_tooltip("loop playback")

imgui.same_line()
imgui.text("framerate :")
imgui.same_line()
imgui.set_next_item_width(100)
# framerate int entry
fps_changed, value = imgui.input_int(
label="fps", v=self._fps[dim], step_fast=5
)
if imgui.is_item_hovered(0):
imgui.set_tooltip(
"framerate is approximate and less reliable as it approaches your monitor refresh rate"
imgui.same_line()
# loop checkbox
_, self._loop[dim] = imgui.checkbox(
label=fa.ICON_FA_ROTATE, v=self._loop[dim]
)
if imgui.is_item_hovered(0):
imgui.set_tooltip("loop playback")

imgui.same_line()
imgui.text("framerate :")
imgui.same_line()
imgui.set_next_item_width(100)
# framerate int entry
fps_changed, value = imgui.input_int(
label="fps", v=self._fps[dim], step_fast=5
)
if fps_changed:
if value < 1:
value = 1
if value > 100:
value = 100
self._fps[dim] = value
self._frame_time[dim] = 1 / value
if imgui.is_item_hovered(0):
imgui.set_tooltip(
"framerate is approximate and less reliable as it approaches your monitor refresh rate"
)
if fps_changed:
if value < 1:
value = 1
if value > 100:
value = 100
self._fps[dim] = value
self._frame_time[dim] = 1 / value

imgui.text(str(dim))
imgui.same_line()
Expand Down