Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions examples/ndwidget/timeseries.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
},
cmap="jet",
x_range_mode="auto",
display_window=np.pi * 10,
name="nd-sine"
)

Expand Down
4 changes: 2 additions & 2 deletions fastplotlib/graphics/selectors/_highlight_selector.py
Original file line number Diff line number Diff line change
Expand Up @@ -715,12 +715,12 @@ def append(self, dict_or_index: dict | int) -> None:
if self._selection_options is not None:
# options mode
index = dict_or_index
if not isinstance(index, Integral):
if not isinstance(index, Integral) and index is not None:
raise TypeError(
f"must provide integer index to append to selection "
f"in 'options' mode, you passed: {dict_or_index!r}"
)
if index not in self._selected_indices:
if index not in self._selected_indices or index is None:
self._selected_indices.append(index)
self._update_all_graphics()
self._emit({"value": self.selection})
Expand Down
31 changes: 19 additions & 12 deletions fastplotlib/layouts/_plot_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -895,37 +895,44 @@ def _auto_scale_scene(
def x_range(self) -> tuple[float, float]:
"""
Get or set the x-range currently in view.
Only valid for orthographic projections of the xy plane.
Really only valid for orthographic projections of the xy plane.
Use camera.set_state() to set the camera position for arbitrary projections.
"""
hw = self.camera.width / 2
hw = self.camera.projection_matrix_inverse[0, 0]
x = self.camera.local.x
return x - hw, x + hw

@x_range.setter
def x_range(self, xr: tuple[float, float]):
width = xr[1] - xr[0]
x_mid = (xr[0] + xr[1]) / 2
self.camera.width = width
self.camera.local.x = x_mid
hw = (xr[1] - xr[0]) / 2
if self.camera.fov > 0:
# really shouldn't use this for fov > 0 but ¯\_(ツ)_/¯
self.camera.zoom *= self.camera.projection_matrix_inverse[0, 0] / hw
else:
# sets correct x_range for orthographic projection of xy plane
self.camera.width = (xr[1] - xr[0]) * self.camera.zoom
self.camera.local.x = (xr[0] + xr[1]) / 2

@property
def y_range(self) -> tuple[float, float]:
"""
Get or set the y-range currently in view.
Only valid for orthographic projections of the xy plane.
Really only valid for orthographic projections of the xy plane.
Use camera.set_state() to set the camera position for arbitrary projections.
"""
hh = self.camera.height / 2
hh = self.camera.projection_matrix_inverse[1, 1]
y = self.camera.local.y
return y - hh, y + hh

@y_range.setter
def y_range(self, yr: tuple[float, float]):
height = yr[1] - yr[0]
y_mid = yr[0] + (height / 2)
self.camera.height = height
self.camera.local.y = y_mid
hh = (yr[1] - yr[0]) / 2
if self.camera.fov > 0:
# shouldn't really do this but ¯\_(ツ)_/¯
self.camera.zoom *= self.camera.projection_matrix_inverse[1, 1] / hh
else:
self.camera.height = (yr[1] - yr[0]) * self.camera.zoom
self.camera.local.y = (yr[0] + yr[1]) / 2

def remove_graphic(self, graphic: Graphic):
"""
Expand Down
125 changes: 35 additions & 90 deletions fastplotlib/widgets/nd_widget/_async.py
Original file line number Diff line number Diff line change
@@ -1,100 +1,45 @@
from collections.abc import Generator
from concurrent.futures import Future
import asyncio
from concurrent.futures import Executor, Future, ThreadPoolExecutor
from typing import Any, Callable, Coroutine

from ...utils import ArrayProtocol, FutureProtocol, CudaArrayProtocol, cuda_to_numpy
from rendercanvas.utils.asyncs import Event, detect_current_call_soon_threadsafe


class FutureArray(Future):
def __init__(self, shape, dtype, timeout: float = 1.0):
self._shape = shape
self._dtype = dtype
self._timeout = timeout

super().__init__()

@property
def shape(self) -> tuple[int, ...]:
return self._shape

@property
def ndim(self) -> int:
return len(self.shape)

@property
def dtype(self) -> str:
return self._dtype

def __getitem__(self, item) -> ArrayProtocol:
return self.result(self._timeout)[item]

def __array__(self) -> ArrayProtocol:
return self.result(self._timeout)

def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
raise NotImplementedError

def __array_function__(self, func, types, *args, **kwargs):
raise NotImplementedError


# inspired by https://www.dabeaz.com/coroutines/
def start_coroutine(func):
async def wait_for_future(future: Future) -> Any:
"""
Starts coroutines for async arrays wrapped by NDProcessor.
Used by all NDGraphic.set_indices and NDGraphic._create_graphic.

It also immediately starts coroutines unless block=False is provided. It handles all the triage of possible
sync vs. async (Future-like) objects.

The only time when block=False is when ReferenceIndex._render_indices uses it to loop through setting all
indices, and then collect and send the results back down to NDProcessor.get().
Await a ``concurrent.futures.Future`` from any rendercanvas-supported async
backend (asyncio for glfw/jupyter, the rendercanvas asyncadapter for qt/wx).

``asyncio.wrap_future`` cannot be used because the asyncadapter only
understands its own awaitables (see rendercanvas docs: "restrict your use
of async features to ``sleep`` and ``Event``"). We instead build the same
primitive on top of rendercanvas's cross-framework :class:`Event`,
signaled via the active loop's ``call_soon_threadsafe`` so the future's
done-callback (which runs on the executor thread) hands control back to
the event loop safely.
"""
event = Event()
call_soon_threadsafe = detect_current_call_soon_threadsafe()
future.add_done_callback(lambda f: call_soon_threadsafe(event.set))
await event.wait()
return future.result()

def start(
self, *args, **kwargs
) -> tuple[Generator, ArrayProtocol | CudaArrayProtocol | FutureProtocol] | None:
cr = func(self, *args, **kwargs)
try:
# begin coroutine
to_resolve: FutureProtocol | ArrayProtocol | CudaArrayProtocol = cr.send(
None
)
except StopIteration:
# NDProcessor.get() has no `yield` expression, not async, nothing to return
return None

block = kwargs.get("block", True)
timeout = kwargs.get("timeout", 1.0)
async def run_in_thread_pool(
executor: Executor, fn: Callable, *args, **kwargs
) -> Any:
"""Submit ``fn(*args, **kwargs)`` to ``executor`` and await the result."""
return await wait_for_future(executor.submit(fn, *args, **kwargs))

if block: # resolve Future immediately
try:
if isinstance(to_resolve, FutureProtocol):
# array is async, resolve future and send
cr.send(to_resolve.result(timeout=timeout))
elif isinstance(to_resolve, CudaArrayProtocol):
# array is on GPU, it is technically and on GPU, convert to numpy array on CPU
cr.send(cuda_to_numpy(to_resolve))
else:
# not async, just send the array
cr.send(to_resolve)
except StopIteration:
pass

else: # no block, probably resolving multiple futures simultaneously
if isinstance(to_resolve, FutureProtocol):
# data is async, return coroutine generator and future
# ReferenceIndex._render_indices() will manage them and wait to gather all futures
return cr, to_resolve
elif isinstance(to_resolve, CudaArrayProtocol):
# it is async technically, but it's a GPU array, ReferenceIndex._render_indices will manage it
return cr, to_resolve
else:
# not async, just send the array
try:
cr.send(to_resolve)
except (
StopIteration
): # has to be here because of the yield expression, i.e. it's a generator
pass
def run_sync(coro: Coroutine) -> Any:
"""
Drive an ``async def`` coroutine to completion synchronously, in a helper thread.

return start
Used by construction-time call sites (NDGraphic.__init__, data setter, property
setters) that cannot be made async without coloring the public API.
``asyncio.run`` is dispatched to a helper thread so this never collides with a
loop already running on the calling thread (the rendercanvas loop, Jupyter, IDEs).
"""
with ThreadPoolExecutor(max_workers=1) as ex:
return ex.submit(asyncio.run, coro).result()
Loading