diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 014db571..2486e900 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,14 +10,10 @@ repos: - id: end-of-file-fixer - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.5.1 + rev: v0.6.2 hooks: # Run the linter. - id: ruff args: [--fix] # Run the formatter. - id: ruff-format - - repo: https://github.com/psf/black - rev: "23.3.0" - hooks: - - id: black diff --git a/docs/source/client_handles.md b/docs/source/client_handles.md index 5da650d7..be26421b 100644 --- a/docs/source/client_handles.md +++ b/docs/source/client_handles.md @@ -7,4 +7,9 @@ :undoc-members: :inherited-members: +.. autoclass:: viser.NotificationHandle + :members: + :undoc-members: + :inherited-members: + diff --git a/examples/01_image.py b/examples/01_image.py index 05568b67..86a2a686 100644 --- a/examples/01_image.py +++ b/examples/01_image.py @@ -11,6 +11,7 @@ import imageio.v3 as iio import numpy as onp + import viser diff --git a/examples/02_gui.py b/examples/02_gui.py index e1322835..e48e63fd 100644 --- a/examples/02_gui.py +++ b/examples/02_gui.py @@ -5,6 +5,7 @@ import time import numpy as onp + import viser diff --git a/examples/03_gui_callbacks.py b/examples/03_gui_callbacks.py index a7d2133a..a1df139e 100644 --- a/examples/03_gui_callbacks.py +++ b/examples/03_gui_callbacks.py @@ -6,9 +6,10 @@ import time import numpy as onp -import viser from typing_extensions import assert_never +import viser + def main() -> None: server = viser.ViserServer() diff --git a/examples/05_camera_commands.py b/examples/05_camera_commands.py index 7130b7aa..4a51aeaa 100644 --- a/examples/05_camera_commands.py +++ b/examples/05_camera_commands.py @@ -7,6 +7,7 @@ import time import numpy as onp + import viser import viser.transforms as tf diff --git a/examples/06_mesh.py b/examples/06_mesh.py index eeeaf783..3d30f09e 100644 --- a/examples/06_mesh.py +++ b/examples/06_mesh.py @@ -8,6 +8,7 @@ import numpy as onp import trimesh + import viser import viser.transforms as tf diff --git a/examples/07_record3d_visualizer.py b/examples/07_record3d_visualizer.py index 2d869257..e8b926bb 100644 --- a/examples/07_record3d_visualizer.py +++ b/examples/07_record3d_visualizer.py @@ -8,10 +8,11 @@ import numpy as onp import tyro +from tqdm.auto import tqdm + import viser import viser.extras import viser.transforms as tf -from tqdm.auto import tqdm def main( diff --git a/examples/08_smpl_visualizer.py b/examples/08_smpl_visualizer.py index 3ea097c0..7d89f6b7 100644 --- a/examples/08_smpl_visualizer.py +++ b/examples/08_smpl_visualizer.py @@ -15,6 +15,7 @@ import numpy as np import numpy as onp import tyro + import viser import viser.transforms as tf diff --git a/examples/09_urdf_visualizer.py b/examples/09_urdf_visualizer.py index fe448539..3aebee5a 100644 --- a/examples/09_urdf_visualizer.py +++ b/examples/09_urdf_visualizer.py @@ -14,8 +14,9 @@ import numpy as onp import tyro -import viser from robot_descriptions.loaders.yourdfpy import load_robot_description + +import viser from viser.extras import ViserUrdf # A subset of robots available in the robot_descriptions package. diff --git a/examples/10_realsense.py b/examples/10_realsense.py index 8cc08f34..a16e6c2d 100644 --- a/examples/10_realsense.py +++ b/examples/10_realsense.py @@ -11,9 +11,10 @@ import numpy as np import numpy.typing as npt import pyrealsense2 as rs # type: ignore -import viser from tqdm.auto import tqdm +import viser + @contextlib.contextmanager def realsense_pipeline(fps: int = 30): diff --git a/examples/11_colmap_visualizer.py b/examples/11_colmap_visualizer.py index 493131b6..c9db0130 100644 --- a/examples/11_colmap_visualizer.py +++ b/examples/11_colmap_visualizer.py @@ -10,9 +10,10 @@ import imageio.v3 as iio import numpy as onp import tyro +from tqdm.auto import tqdm + import viser import viser.transforms as tf -from tqdm.auto import tqdm from viser.extras.colmap import ( read_cameras_binary, read_images_binary, diff --git a/examples/12_click_meshes.py b/examples/12_click_meshes.py index ddd70584..8fdec08f 100644 --- a/examples/12_click_meshes.py +++ b/examples/12_click_meshes.py @@ -6,6 +6,7 @@ import time import matplotlib + import viser diff --git a/examples/15_gui_in_scene.py b/examples/15_gui_in_scene.py index 9947d839..8a5638c5 100644 --- a/examples/15_gui_in_scene.py +++ b/examples/15_gui_in_scene.py @@ -9,6 +9,7 @@ from typing import Optional import numpy as onp + import viser import viser.transforms as tf diff --git a/examples/17_background_composite.py b/examples/17_background_composite.py index 3904d442..6098f02f 100644 --- a/examples/17_background_composite.py +++ b/examples/17_background_composite.py @@ -9,6 +9,7 @@ import numpy as onp import trimesh import trimesh.creation + import viser server = viser.ViserServer() diff --git a/examples/18_splines.py b/examples/18_splines.py index 3c939c9d..a6d42ebc 100644 --- a/examples/18_splines.py +++ b/examples/18_splines.py @@ -6,6 +6,7 @@ import time import numpy as onp + import viser diff --git a/examples/19_get_renders.py b/examples/19_get_renders.py index f235730b..2de8a065 100644 --- a/examples/19_get_renders.py +++ b/examples/19_get_renders.py @@ -6,6 +6,7 @@ import imageio.v3 as iio import numpy as onp + import viser diff --git a/examples/20_scene_pointer.py b/examples/20_scene_pointer.py index 6d5a2e90..e18fac85 100644 --- a/examples/20_scene_pointer.py +++ b/examples/20_scene_pointer.py @@ -16,6 +16,7 @@ import trimesh import trimesh.creation import trimesh.ray + import viser import viser.transforms as tf from viser.theme import TitlebarConfig diff --git a/examples/22_games.py b/examples/22_games.py index 6a45f5a6..1ff2f33a 100644 --- a/examples/22_games.py +++ b/examples/22_games.py @@ -7,9 +7,10 @@ import numpy as onp import trimesh.creation +from typing_extensions import assert_never + import viser import viser.transforms as tf -from typing_extensions import assert_never def main() -> None: diff --git a/examples/23_plotly.py b/examples/23_plotly.py index 48cfd36b..7f7231a7 100644 --- a/examples/23_plotly.py +++ b/examples/23_plotly.py @@ -7,9 +7,10 @@ import numpy as onp import plotly.express as px import plotly.graph_objects as go -import viser from PIL import Image +import viser + def create_sinusoidal_wave(t: float) -> go.Figure: """Create a sinusoidal wave plot, starting at time t.""" diff --git a/examples/23_smpl_visualizer_skinned.py b/examples/23_smpl_visualizer_skinned.py index 2fb364c4..02df8506 100644 --- a/examples/23_smpl_visualizer_skinned.py +++ b/examples/23_smpl_visualizer_skinned.py @@ -19,6 +19,7 @@ import numpy as np import numpy as onp import tyro + import viser import viser.transforms as tf diff --git a/examples/24_notification.py b/examples/24_notification.py new file mode 100644 index 00000000..9013a304 --- /dev/null +++ b/examples/24_notification.py @@ -0,0 +1,97 @@ +"""Notifications + +Examples of adding notifications per client in Viser.""" + +import time + +import viser + + +def main() -> None: + server = viser.ViserServer() + + persistent_notif_button = server.gui.add_button( + "Show persistent notification (default)" + ) + timed_notif_button = server.gui.add_button("Show timed notification") + controlled_notif_button = server.gui.add_button("Show controlled notification") + loading_notif_button = server.gui.add_button("Show loading notification") + + remove_controlled_notif = server.gui.add_button("Remove controlled notification") + + @persistent_notif_button.on_click + def _(event: viser.GuiEvent) -> None: + """Show persistent notification when the button is clicked.""" + client = event.client + assert client is not None + + client.add_notification( + title="Persistent notification", + body="This can be closed manually and does not disappear on its own!", + loading=False, + with_close_button=True, + auto_close=False, + ) + + @timed_notif_button.on_click + def _(event: viser.GuiEvent) -> None: + """Show timed notification when the button is clicked.""" + client = event.client + assert client is not None + + client.add_notification( + title="Timed notification", + body="This disappears automatically after 5 seconds!", + loading=False, + with_close_button=True, + auto_close=5000, + ) + + @controlled_notif_button.on_click + def _(event: viser.GuiEvent) -> None: + """Show controlled notification when the button is clicked.""" + client = event.client + assert client is not None + + controlled_notif = client.add_notification( + title="Controlled notification", + body="This cannot be closed by the user and is controlled in code only!", + loading=False, + with_close_button=False, + auto_close=False, + ) + + @remove_controlled_notif.on_click + def _(_) -> None: + """Remove controlled notification.""" + controlled_notif.remove() + + @loading_notif_button.on_click + def _(event: viser.GuiEvent) -> None: + """Show loading notification when the button is clicked.""" + client = event.client + assert client is not None + + loading_notif = client.add_notification( + title="Loading notification", + body="This indicates that some action is in progress! It will be updated in 3 seconds.", + loading=True, + with_close_button=False, + auto_close=False, + ) + + time.sleep(3.0) + + loading_notif.title = "Updated notification" + loading_notif.body = "This notification has been updated!" + loading_notif.loading = False + loading_notif.with_close_button = True + loading_notif.auto_close = 5000 + loading_notif.color = "green" + + while True: + time.sleep(1.0) + + +if __name__ == "__main__": + main() diff --git a/examples/experimental/gaussian_splats.py b/examples/experimental/gaussian_splats.py index 24d7b10f..461e2fab 100644 --- a/examples/experimental/gaussian_splats.py +++ b/examples/experimental/gaussian_splats.py @@ -9,8 +9,9 @@ import numpy as onp import numpy.typing as onpt import tyro -import viser from plyfile import PlyData + +import viser from viser import transforms as tf diff --git a/pyproject.toml b/pyproject.toml index b9fee7b6..48da3390 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "pyright>=1.1.308", - "ruff==0.4.6", + "ruff==0.6.2", "pre-commit==3.3.2", ] examples = [ @@ -110,5 +110,6 @@ lint.ignore = [ "PLR0912", # Too many branches. "PLW0603", # Globa statement updates are discouraged. "PLW2901", # For loop variable overwritten. + "PLW0642", # Reassigned self in instance method. ] exclude = [ ".nodeenv" ] diff --git a/src/viser/__init__.py b/src/viser/__init__.py index 33ee2621..5be41272 100644 --- a/src/viser/__init__.py +++ b/src/viser/__init__.py @@ -11,6 +11,7 @@ from ._gui_handles import GuiTabHandle as GuiTabHandle from ._icons_enum import Icon as Icon from ._icons_enum import IconName as IconName +from ._notification_handle import NotificationHandle as NotificationHandle from ._scene_api import SceneApi as SceneApi from ._scene_handles import BatchedAxesHandle as BatchedAxesHandle from ._scene_handles import CameraFrustumHandle as CameraFrustumHandle diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index 16dcfebe..dbe994c7 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -1242,8 +1242,7 @@ def add_slider( """ value: IntOrFloat = initial_value assert max >= min - if step > max - min: - step = max - min + step = __builtins__.min(step, max - min) assert max >= value >= min # GUI callbacks cast incoming values to match the type of the initial value. If @@ -1326,8 +1325,7 @@ def add_multi_slider( A handle that can be used to interact with the GUI element. """ assert max >= min - if step > max - min: - step = max - min + step = __builtins__.min(step, max - min) assert all(max >= x >= min for x in initial_value) # GUI callbacks cast incoming values to match the type of the initial value. If diff --git a/src/viser/_messages.py b/src/viser/_messages.py index e16cd082..ed3a75fb 100644 --- a/src/viser/_messages.py +++ b/src/viser/_messages.py @@ -60,7 +60,7 @@ def redundancy_key(self) -> str: if node_name is not None: parts.append(node_name) - # GUI messages all have an "id" field. + # GUI and notification messages all have an "id" field. node_name = getattr(self, "id", None) if node_name is not None: parts.append(node_name) @@ -95,6 +95,27 @@ def redundancy_key(self) -> str: return str(uuid.uuid4()) +@dataclasses.dataclass +class NotificationMessage(Message): + """Notification message.""" + + mode: Literal["show", "update"] + id: str + title: str + body: str + loading: bool + with_close_button: bool + auto_close: Union[int, Literal[False]] + color: Optional[Color] + + +@dataclasses.dataclass +class RemoveNotificationMessage(Message): + """Remove a specific notification.""" + + id: str + + @dataclasses.dataclass class ViewerCameraMessage(Message): """Message for a posed viewer camera. @@ -157,7 +178,7 @@ class CameraFrustumMessage(Message): @dataclasses.dataclass class GlbMessage(Message): - """GlTF Message""" + """GlTF message.""" name: str glb_data: bytes diff --git a/src/viser/_notification_handle.py b/src/viser/_notification_handle.py new file mode 100644 index 00000000..0292af82 --- /dev/null +++ b/src/viser/_notification_handle.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import dataclasses +from typing import Literal + +from ._gui_api import Color +from ._messages import NotificationMessage, RemoveNotificationMessage +from .infra._infra import WebsockClientConnection + + +@dataclasses.dataclass +class _NotificationHandleState: + websock_interface: WebsockClientConnection + id: str + title: str + body: str + loading: bool + with_close_button: bool + auto_close: int | Literal[False] + color: Color | None + + +@dataclasses.dataclass +class NotificationHandle: + """Handle for a notification in our visualizer.""" + + _impl: _NotificationHandleState + + def _sync_with_client(self, first: bool = False) -> None: + m = NotificationMessage( + "show" if first else "update", + self._impl.id, + self._impl.title, + self._impl.body, + self._impl.loading, + self._impl.with_close_button, + self._impl.auto_close, + self._impl.color, + ) + self._impl.websock_interface.queue_message(m) + + @property + def title(self) -> str: + """Title to display on the notification.""" + return self._impl.title + + @title.setter + def title(self, title: str) -> None: + if title == self._impl.title: + return + + self._impl.title = title + self._sync_with_client() + + @property + def body(self) -> str: + """Message to display on the notification body.""" + return self._impl.body + + @body.setter + def body(self, body: str) -> None: + if body == self._impl.body: + return + + self._impl.body = body + self._sync_with_client() + + @property + def loading(self) -> bool: + """Whether the notification shows loading icon.""" + return self._impl.loading + + @loading.setter + def loading(self, loading: bool) -> None: + if loading == self._impl.loading: + return + + self._impl.loading = loading + self._sync_with_client() + + @property + def with_close_button(self) -> bool: + """Whether the notification can be manually closed.""" + return self._impl.with_close_button + + @with_close_button.setter + def with_close_button(self, with_close_button: bool) -> None: + if with_close_button == self._impl.with_close_button: + return + + self._impl.with_close_button = with_close_button + self._sync_with_client() + + @property + def auto_close(self) -> int | Literal[False]: + """Time in ms before the notification automatically closes; + otherwise False such that the notification never closes on its own.""" + return self._impl.auto_close + + @auto_close.setter + def auto_close(self, auto_close: int | Literal[False]) -> None: + if auto_close == self._impl.auto_close: + return + + self._impl.auto_close = auto_close + self._sync_with_client() + + @property + def color(self) -> Color | None: + """Color of the notification.""" + return self._impl.color + + @color.setter + def color(self, color: Color | None) -> None: + if color == self._impl.color: + return + + self._impl.color = color + self._sync_with_client() + + def remove(self) -> None: + self._impl.websock_interface.queue_message( + RemoveNotificationMessage(self._impl.id) + ) diff --git a/src/viser/_viser.py b/src/viser/_viser.py index 43485669..5b2d9b94 100644 --- a/src/viser/_viser.py +++ b/src/viser/_viser.py @@ -20,7 +20,8 @@ from . import _client_autobuild, _messages, infra from . import transforms as tf -from ._gui_api import GuiApi +from ._gui_api import Color, GuiApi, _make_unique_id +from ._notification_handle import NotificationHandle, _NotificationHandleState from ._scene_api import SceneApi, cast_vector from ._tunnel import ViserTunnel from .infra._infra import RecordHandle @@ -360,8 +361,6 @@ def send_file_download( if mime_type is None: mime_type = "application/octet-stream" - from ._gui_api import _make_unique_id - parts = [ content[i * chunk_size : (i + 1) * chunk_size] for i in range(int(onp.ceil(len(content) / chunk_size))) @@ -389,6 +388,47 @@ def send_file_download( ) self.flush() + def add_notification( + self, + title: str, + body: str, + loading: bool = False, + with_close_button: bool = True, + auto_close: int | Literal[False] = False, + color: Color | None = None, + ) -> NotificationHandle: + """Add a notification to the client's interface. + + This method creates a new notification that will be displayed at the + top left corner of the client's viewer. Notifications are useful for + providing alerts or status updates to users. + + Args: + title: Title to display on the notification. + body: Message to display on the notification body. + loading: Whether the notification shows loading icon. + with_close_button: Whether the notification can be manually closed. + auto_close: Time in ms before the notification automatically closes; + otherwise False such that the notification never closes on its own. + + Returns: + A handle that can be used to interact with the GUI element. + """ + handle = NotificationHandle( + _NotificationHandleState( + websock_interface=self._websock_connection, + id=_make_unique_id(), + title=title, + body=body, + loading=loading, + with_close_button=with_close_button, + auto_close=auto_close, + color=color, + ) + ) + handle._sync_with_client(first=True) + return handle + class ViserServer(_BackwardsCompatibilityShim if not TYPE_CHECKING else object): """:class:`ViserServer` is the main class for working with viser. On diff --git a/src/viser/client/src/App.tsx b/src/viser/client/src/App.tsx index 17e7c533..010f06b8 100644 --- a/src/viser/client/src/App.tsx +++ b/src/viser/client/src/App.tsx @@ -224,6 +224,7 @@ function ViewerContents({ children }: { children: React.ReactNode }) { {children}