Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
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
10 changes: 1 addition & 9 deletions fastplotlib/widgets/nd_widget/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -643,20 +643,12 @@ def graphic(self) -> Graphic:
def indices(self) -> dict[str, Any]:
raise NotImplementedError

async def _set_indices_(
self, indices: dict[str, Any], should_write: Callable[[], bool] | None = None
):
async def _set_indices_(self, indices: dict[str, Any]):
"""
Get the data slice for ``indices`` from the processor and write it to the graphic.

Semi-private: only ``ReferenceIndex`` should call this. Construction-time call
sites use :func:`run_sync` to drive it synchronously.

``should_write`` is checked right before the write. If supplied and it returns
False, the data slice is dropped. ``ReferenceIndex`` uses this to drop stale
results when a newer tick has superseded this one between the last ``await``
and the graphic write (asyncio cancellation does not fire after the final
``await`` of a task, so a tick check is required here).
"""
pass

Expand Down
98 changes: 43 additions & 55 deletions fastplotlib/widgets/nd_widget/_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,6 @@ def __init__(self, start: int | float, stop: int | float, step: int | float):
self._stop = stop
self._step = step

self._throttle = 0.2

@property
def start(self) -> int | float:
"""get or set the start boundary of the reference range"""
Expand All @@ -84,17 +82,6 @@ def step(self) -> int | float:
"""get or set the step size of the range, only used for UI elements"""
return self._step

@property
def throttle(self) -> float:
"""get or set throttle value in seconds. Used for throttling UI sliders"""
return self._throttle

@throttle.setter
def throttle(self, val: float):
if val < 0:
raise ValueError("throttle value must be >= 0.0")
self._throttle = val

@property
def size(self) -> int | float:
"""the size of the reference range"""
Expand Down Expand Up @@ -177,8 +164,8 @@ def __init__(
Single shared time axis:

ri = ReferenceIndex(ref_ranges={"time": (0, 1000, 1), "depth": (15, 35, 0.5)})
ri["time"] = 500 # update one dim and re-render
ri.set({"time": 500, "depth": 10}) # update several dims atomically
ri.set_dim_index("time", 500) # update one dim and re-render
ri.set({"time": 500, "depth": 10}) # update several dims atomically

Two independent time axes for data from two different recording sessions:

Expand Down Expand Up @@ -206,10 +193,8 @@ def __init__(

self._ndwidgets: list[NDWidget] = list()

# render-request bookkeeping. Each new indices update increments _tick. Any in-flight
# asyncio.Task per graphic is cancelled and replaced when a newer tick arrives, and
# _set_indices_ checks the tick right before writing to drop stale results.
self._tick: int = 0
# tracks in-flight throttled render tasks so they can be cancelled when a newer
# slider position arrives before the previous one has finished loading
self._awaiting: dict[NDGraphic, asyncio.Task] = dict()

@property
Expand All @@ -228,11 +213,32 @@ def _add_ndwidget_(self, ndw: NDWidget):

self._ndwidgets.append(ndw)

def set(self, indices: dict[str, Any]):
def set(self, indices: dict[str, Any], throttle: bool = False):
for dim, value in indices.items():
self._indices[dim] = self._clamp(dim, value)

self._render_indices()
self._render_indices(throttle=throttle)
self._indices_changed()

def set_dim_index(self, dim: str, index, throttle: bool = False):
"""
Set the index for a single dimension and trigger a render.

Parameters
----------
dim : str
Dimension name.
index : int or float
New reference-space value for this dimension.
throttle : bool, default False
If True, cancel any in-flight render tasks before scheduling a new one.
Use this only for rapid-fire inputs such as an imgui slider drag where
intermediate positions are disposable. All other callers (play advance,
step buttons, LinearSelector, programmatic updates) should leave this False.
"""
self._check_has_dim(dim)
self._indices[dim] = self._clamp(dim, index)
self._render_indices(throttle=throttle)
self._indices_changed()

def _clamp(self, dim, value):
Expand All @@ -244,70 +250,52 @@ def _clamp(self, dim, value):

return value

def _render_indices(self):
def _render_indices(self, throttle: bool = False):
"""
Schedule a render for every affected NDGraphic via the rendercanvas event loop.

Each call increments ``_tick`` and cancels any in-flight task per graphic, so a
rapid slider drag drops queued window_func/spatial_func work and never writes a
stale frame after a fresh one. Falls back to synchronous drain when no loop is
available yet (figure not shown).
When ``throttle=True``, any in-flight tasks from a previous throttled call are
cancelled before new ones are scheduled, so rapid slider drags never queue up
stale window_func/spatial_func work. Falls back to a synchronous drain when no
event loop is running yet (figure not shown).
"""
self._tick += 1
tick = self._tick

# cancel any prior in-flight tasks. Future submissions on the per-processor
# ThreadPoolExecutor that haven't started yet will be cancelled via asyncio's
# propagation through asyncio.wrap_future. Already-running submissions complete
# but their results are dropped by the should_write tick check.
for task in self._awaiting.values():
task.cancel()
self._awaiting.clear()
if throttle:
for task in self._awaiting.values():
task.cancel()
self._awaiting.clear()

for ndw in self._ndwidgets:
for g in ndw.ndgraphics:
if g.data is None or g.pause or g._block_indices:
continue
# only provide slider indices to the graphic
indices = {d: self._indices[d] for d in g.processor.slider_dims}

try:
asyncio.get_running_loop()
except RuntimeError:
# no running loop (figure not shown yet): drain synchronously so
# construction-time programmatic ref_index updates still take effect.
run_sync(g._set_indices_(indices))
continue

_loop.add_task(self._render_request, g, indices, tick, name="ndw-render")
_loop.add_task(self._render_request, g, indices, throttle, name="ndw-render")

async def _render_request(
self, graphic: "NDGraphic", indices: dict[str, Any], tick: int
self, graphic: "NDGraphic", indices: dict[str, Any], throttle: bool
):
"""Run the data pipeline for one graphic and write the result if still current."""
self._awaiting[graphic] = asyncio.current_task()
"""Run the data pipeline for one graphic and write the result."""
if throttle:
self._awaiting[graphic] = asyncio.current_task()
try:
await graphic._set_indices_(
indices, should_write=lambda: self._tick == tick
)
await graphic._set_indices_(indices)
except asyncio.CancelledError:
pass
finally:
# drop self from _awaiting if still there (may have been overwritten by a newer tick)
if self._awaiting.get(graphic) is asyncio.current_task():
if throttle and self._awaiting.get(graphic) is asyncio.current_task():
del self._awaiting[graphic]

def __getitem__(self, dim):
self._check_has_dim(dim)
return self._indices[dim]

def __setitem__(self, dim, value):
self._check_has_dim(dim)
# set index for given dim and render
self._indices[dim] = self._clamp(dim, value)
self._render_indices()
self._indices_changed()

def _check_has_dim(self, dim):
if dim not in self.dims:
raise KeyError(
Expand Down
9 changes: 2 additions & 7 deletions fastplotlib/widgets/nd_widget/_nd_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,13 +558,8 @@ def indices(self) -> dict[str, Any]:
"""get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually"""
return {d: self._ref_index[d] for d in self.processor.slider_dims}

async def _set_indices_(
self, indices: dict[str, Any], should_write: Callable[[], bool] | None = None
):
data_slice = await self.processor.get(indices)
if should_write is not None and not should_write():
return
self.graphic.data = data_slice
async def _set_indices_(self, indices: dict[str, Any]):
self.graphic.data = await self.processor.get(indices)

@property
def compute_histogram(self) -> bool:
Expand Down
13 changes: 3 additions & 10 deletions fastplotlib/widgets/nd_widget/_nd_positions/_nd_positions.py
Original file line number Diff line number Diff line change
Expand Up @@ -762,16 +762,11 @@ def spatial_dims(self, dims: tuple[str, str, str]):
def indices(self) -> dict[Hashable, Any]:
return {d: self._ref_index[d] for d in self.processor.slider_dims}

async def _set_indices_(
self, indices: dict[Hashable, Any], should_write: Callable[[], bool] | None = None
):
async def _set_indices_(self, indices: dict[Hashable, Any]):
if self.data is None:
return

new_features = await self.processor.get(indices)
if should_write is not None and not should_write():
return

data_slice = new_features["data"]

# TODO: set other graphic features, colors, sizes, markers, etc.
Expand Down Expand Up @@ -840,8 +835,7 @@ async def _set_indices_(

def _linear_selector_handler(self, ev):
with block_indices_ctx(self):
# linear selector always acts on the `p` dim
self._ref_index[self.processor.spatial_dims[1]] = ev.info["value"]
self._ref_index.set_dim_index(self.processor.spatial_dims[1], ev.info["value"])

def _tooltip_handler(self, graphic, pick_info):
if isinstance(self.graphic, (LineCollection, ScatterCollection)):
Expand Down Expand Up @@ -1030,8 +1024,7 @@ def _update_from_view_range(self):
return

self.processor.display_window = new_width
# set the `p` dim on the global index vector
self._ref_index[self.processor.spatial_dims[1]] = new_index
self._ref_index.set_dim_index(self.processor.spatial_dims[1], new_index)

@property
def cmap(self) -> str | None:
Expand Down
7 changes: 1 addition & 6 deletions fastplotlib/widgets/nd_widget/_nd_vectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,13 +337,8 @@ def indices(self) -> dict[str, Any]:
"""get or set the indices, managed by the ReferenceIndex, users usually don't want to set this manually"""
return {d: self._ref_index[d] for d in self.processor.slider_dims}

async def _set_indices_(
self, indices: dict[str, Any], should_write: Callable[[], bool] | None = None
):
async def _set_indices_(self, indices: dict[str, Any]):
data_slice = await self.processor.get(indices)
if should_write is not None and not should_write():
return

self.graphic.positions = data_slice[:, 0]
self.graphic.directions = data_slice[:, 1]

Expand Down
16 changes: 3 additions & 13 deletions fastplotlib/widgets/nd_widget/_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,6 @@ def __init__(self, figure, size, ndwidget):
# loop playback
self._loop = {dim: False for dim in ref_ranges.keys()}

# last time the slider was moved, used for throttling
self._last_slider_movement: dict[str, float] = dict()

# auto-plays the ImageWidget's left-most dimension in docs galleries
if "DOCS_BUILD" in os.environ.keys():
if os.environ["DOCS_BUILD"] == "1":
Expand All @@ -72,7 +69,7 @@ def _set_index(self, dim, index):
index = self._ndwidget.ranges[dim].stop
self._playing[dim] = False

self._ndwidget.indices[dim] = index
self._ndwidget.indices.set_dim_index(dim, index)

def update(self):
now = perf_counter()
Expand Down Expand Up @@ -117,7 +114,7 @@ def update(self):
if imgui.button(label=fa.ICON_FA_STOP):
self._playing[dim] = False
self._last_frame_time[dim] = 0
self._ndwidget.indices[dim] = rr.start
self._ndwidget.indices.set_dim_index(dim, rr.start)

imgui.same_line()
# loop checkbox
Expand Down Expand Up @@ -160,15 +157,8 @@ def update(self):
label=f"##{dim}",
)

# TODO: refactor all this stuff, make fully fledged UI
if changed:
# apply throttling
if not dim in self._last_slider_movement:
self._last_slider_movement[dim] = 0.0

if now - self._last_slider_movement[dim] > rr.throttle:
self._ndwidget.indices[dim] = new_index
self._last_slider_movement[dim] = now
self._ndwidget.indices.set_dim_index(dim, new_index, throttle=True)

elif imgui.is_item_hovered():
if imgui.is_key_pressed(imgui.Key.right_arrow):
Expand Down