diff --git a/examples/02_gui.py b/examples/02_gui.py index 7825fcae5..efc51ef3f 100644 --- a/examples/02_gui.py +++ b/examples/02_gui.py @@ -16,7 +16,7 @@ def main(): with server.add_gui_folder("Read-only"): gui_counter = server.add_gui_number( "Counter", - initial_value=0, + value=0, disabled=True, ) @@ -25,38 +25,38 @@ def main(): min=0, max=100, step=1, - initial_value=0, + value=0, disabled=True, ) with server.add_gui_folder("Editable"): gui_vector2 = server.add_gui_vector2( "Position", - initial_value=(0.0, 0.0), + value=(0.0, 0.0), step=0.1, ) gui_vector3 = server.add_gui_vector3( "Size", - initial_value=(1.0, 1.0, 1.0), + value=(1.0, 1.0, 1.0), step=0.25, ) with server.add_gui_folder("Text toggle"): gui_checkbox_hide = server.add_gui_checkbox( "Hide", - initial_value=False, + value=False, ) gui_text = server.add_gui_text( "Text", - initial_value="Hello world", + value="Hello world", ) gui_button = server.add_gui_button("Button") gui_checkbox_disable = server.add_gui_checkbox( "Disable", - initial_value=False, + value=False, ) gui_rgb = server.add_gui_rgb( "Color", - initial_value=(255, 255, 0), + value=(255, 255, 0), ) # Pre-generate a point cloud to send. diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index 16b188fa3..b21d94a2a 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -4,6 +4,28 @@ # - https://github.com/python/mypy/issues/12554 from __future__ import annotations + +from dataclasses import field, InitVar +from functools import wraps +import time +from typing import Optional, Literal, Union, TypeVar, Generic, Tuple, Type +from typing import Callable, Any +from dataclasses import dataclass +try: + from typing import Concatenate +except ImportError: + from typing_extensions import Concatenate +try: + from typing import Protocol +except ImportError: + from typing_extensions import Protocol +try: + from typing import ParamSpec +except ImportError: + from typing_extensions import ParamSpec +from ._gui_components import GuiApiMixin + + import abc import dataclasses import threading @@ -45,6 +67,7 @@ from ._icons import base64_from_icon from ._icons_enum import Icon from ._message_api import MessageApi, cast_vector +from ._gui_components import Property if TYPE_CHECKING: from .infra import ClientId @@ -52,6 +75,9 @@ IntOrFloat = TypeVar("IntOrFloat", int, float) TString = TypeVar("TString", bound=str) TLiteralString = TypeVar("TLiteralString", bound=LiteralString) +TProps = TypeVar("TProps") +TReturn = TypeVar('TReturn') +TArgs = ParamSpec('TArgs') T = TypeVar("T") @@ -106,7 +132,86 @@ def _apply_default_order(order: Optional[float]) -> float: return _global_order_counter -class GuiApi(abc.ABC): + +class ComponentHandle(Generic[TProps]): + _id: str + _props: TProps + _gui_api: 'GuiApi' + _update_timestamp: float + _container_id: str + _backup_container_id: Optional[str] = None + + def __init__(self, gui_api: 'GuiApi', id: str, props: TProps): + self._id = id + self._props = props + self._gui_api = gui_api + self._update_timestamp = time.time() + self._container_id = gui_api._get_container_id() + props._order = _apply_default_order(props._order) + self._register() + + def _register(self): + self._gui_api._get_api()._queue(_messages.GuiAddComponentMessage( + order=self.order, + id=self.id, + props=self._props, + container_id=self._container_id, + )) + + @property + def id(self): + return self._id + + def __enter__(self): + self._backup_container_id = self._gui_api._get_container_id() + self._gui_api._set_container_id(self.id) + return self + + def __exit__(self, exc_type, exc_value, traceback): + self._gui_api._set_container_id(self._backup_container_id) + return None + + def _update(self, **kwargs): + for k, v in kwargs.items(): + if not hasattr(self._props, k): + raise AttributeError(f"Component has no property {k}") + setattr(self._props, k, v) + self._update_timestamp = time.time() + + # Raise message to update component. + self._gui_api._get_api()._queue(_messages.GuiUpdateComponentMessage(id=id, **kwargs)) + + def property(self, name: str) -> Property[T]: + props = object.__getattribute__(self, "_props") + update = object.__getattribute__(self, "_update") + if not hasattr(props, name): + raise AttributeError(f"Component has no property {name}") + return Property( + lambda: getattr(props, name), + lambda value: update(**{name: value}), + ) + + def __getattr__(self, name: str) -> T: + if not hasattr(ComponentHandle, name): + props = object.__getattribute__(self, "_props") + if hasattr(props, name): + return self.property(name).get() + else: + raise AttributeError(f"Component has no property {name}") + return super().__getattribute__(name) + + def __setattr__(self, __name: str, __value: Any) -> None: + if hasattr(self, "_props"): + props = object.__getattribute__(self, "_props") + if hasattr(props, __name): + return self.property(__name).set(__value) + return super().__setattr__(__name, __value) + + + + + +class GuiApi(abc.ABC, GuiApiMixin): _target_container_from_thread_id: Dict[int, str] = {} """ID of container to put GUI elements into.""" @@ -129,44 +234,44 @@ def _handle_gui_updates( if handle is None: return - handle_state = handle._impl - - # Do some type casting. This is necessary when we expect floats but the - # Javascript side gives us integers. - if handle_state.typ is tuple: - assert len(message.value) == len(handle_state.value) - value = tuple( - type(handle_state.value[i])(message.value[i]) - for i in range(len(message.value)) - ) - else: - value = handle_state.typ(message.value) - - # Only call update when value has actually changed. - if not handle_state.is_button and value == handle_state.value: - return - - # Update state. - with self._get_api()._atomic_lock: - handle_state.value = value - handle_state.update_timestamp = time.time() - - # Trigger callbacks. - for cb in handle_state.update_cb: - from ._viser import ClientHandle, ViserServer - - # Get the handle of the client that triggered this event. - api = self._get_api() - if isinstance(api, ClientHandle): - client = api - elif isinstance(api, ViserServer): - client = api.get_clients()[client_id] - else: - assert False - - cb(GuiEvent(client, client_id, handle)) - if handle_state.sync_cb is not None: - handle_state.sync_cb(client_id, value) + # handle_state = handle._impl + + # # Do some type casting. This is necessary when we expect floats but the + # # Javascript side gives us integers. + # if handle_state.typ is tuple: + # assert len(message.value) == len(handle_state.value) + # value = tuple( + # type(handle_state.value[i])(message.value[i]) + # for i in range(len(message.value)) + # ) + # else: + # value = handle_state.typ(message.value) + + # # Only call update when value has actually changed. + # if not handle_state.is_button and value == handle_state.value: + # return + + # # Update state. + # with self._get_api()._atomic_lock: + # handle_state.value = value + # handle_state.update_timestamp = time.time() + + # # Trigger callbacks. + # for cb in handle_state.update_cb: + # from ._viser import ClientHandle, ViserServer + + # # Get the handle of the client that triggered this event. + # api = self._get_api() + # if isinstance(api, ClientHandle): + # client = api + # elif isinstance(api, ViserServer): + # client = api.get_clients()[client_id] + # else: + # assert False + + # cb(GuiEvent(client, client_id, handle)) + # if handle_state.sync_cb is not None: + # handle_state.sync_cb(client_id, value) def _get_container_id(self) -> str: """Get container ID associated with the current thread.""" @@ -181,803 +286,11 @@ def _get_api(self) -> MessageApi: """Message API to use.""" ... - if not TYPE_CHECKING: - - def gui_folder(self, label: str) -> GuiFolderHandle: - """Deprecated.""" - warnings.warn( - "gui_folder() is deprecated. Use add_gui_folder() instead!", - stacklevel=2, - ) - return self.add_gui_folder(label) - - def add_gui_folder( - self, - label: str, - order: Optional[float] = None, - expand_by_default: bool = True, - ) -> GuiFolderHandle: - """Add a folder, and return a handle that can be used to populate it. - - Args: - label: Label to display on the folder. - order: Optional ordering, smallest values will be displayed first. - expand_by_default: Open the folder by default. Set to False to collapse it by - default. - - Returns: - A handle that can be used as a context to populate the folder. - """ - folder_container_id = _make_unique_id() - order = _apply_default_order(order) - self._get_api()._queue( - _messages.GuiAddFolderMessage( - order=order, - id=folder_container_id, - label=label, - container_id=self._get_container_id(), - expand_by_default=expand_by_default, - ) - ) - return GuiFolderHandle( - _gui_api=self, - _id=folder_container_id, - _parent_container_id=self._get_container_id(), - _order=order, - ) - - def add_gui_modal( - self, - title: str, - order: Optional[float] = None, - ) -> GuiModalHandle: - """Show a modal window, which can be useful for popups and messages, then return - a handle that can be used to populate it. - - Args: - title: Title to display on the modal. - order: Optional ordering, smallest values will be displayed first. - - Returns: - A handle that can be used as a context to populate the modal. - """ - modal_container_id = _make_unique_id() - order = _apply_default_order(order) - self._get_api()._queue( - _messages.GuiModalMessage( - order=order, - id=modal_container_id, - title=title, - ) - ) - return GuiModalHandle( - _gui_api=self, - _id=modal_container_id, - ) - - def add_gui_tab_group( - self, - order: Optional[float] = None, - ) -> GuiTabGroupHandle: - """Add a tab group. - - Args: - order: Optional ordering, smallest values will be displayed first. - - Returns: - A handle that can be used as a context to populate the tab group. - """ - tab_group_id = _make_unique_id() - order = _apply_default_order(order) - return GuiTabGroupHandle( - _tab_group_id=tab_group_id, - _labels=[], - _icons_base64=[], - _tabs=[], - _gui_api=self, - _container_id=self._get_container_id(), - _order=order, - ) - - def add_gui_markdown( - self, - content: str, - image_root: Optional[Path] = None, - order: Optional[float] = None, - ) -> GuiMarkdownHandle: - """Add markdown to the GUI. - - Args: - content: Markdown content to display. - image_root: Optional root directory to resolve relative image paths. - order: Optional ordering, smallest values will be displayed first. - - Returns: - A handle that can be used to interact with the GUI element. - """ - handle = GuiMarkdownHandle( - _gui_api=self, - _id=_make_unique_id(), - _visible=True, - _container_id=self._get_container_id(), - _order=_apply_default_order(order), - _image_root=image_root, - _content=None, - ) - - # Assigning content will send a GuiAddMarkdownMessage. - handle.content = content - return handle - - def add_gui_button( - self, - label: str, - disabled: bool = False, - visible: bool = True, - hint: Optional[str] = None, - color: Optional[ - Literal[ - "dark", - "gray", - "red", - "pink", - "grape", - "violet", - "indigo", - "blue", - "cyan", - "green", - "lime", - "yellow", - "orange", - "teal", - ] - ] = None, - icon: Optional[Icon] = None, - order: Optional[float] = None, - ) -> GuiButtonHandle: - """Add a button to the GUI. The value of this input is set to `True` every time - it is clicked; to detect clicks, we can manually set it back to `False`. - - Args: - label: Label to display on the button. - visible: Whether the button is visible. - disabled: Whether the button is disabled. - hint: Optional hint to display on hover. - color: Optional color to use for the button. - icon: Optional icon to display on the button. - order: Optional ordering, smallest values will be displayed first. - - Returns: - A handle that can be used to interact with the GUI element. - """ - - # Re-wrap the GUI handle with a button interface. - id = _make_unique_id() - order = _apply_default_order(order) - return GuiButtonHandle( - self._create_gui_input( - initial_value=False, - message=_messages.GuiAddButtonMessage( - order=order, - id=id, - label=label, - container_id=self._get_container_id(), - hint=hint, - initial_value=False, - color=color, - icon_base64=None if icon is None else base64_from_icon(icon), - ), - disabled=disabled, - visible=visible, - is_button=True, - )._impl - ) - - # The TLiteralString overload tells pyright to resolve the value type to a Literal - # whenever possible. - # - # TString is helpful when the input types are generic (could be str, could be - # Literal). - @overload - def add_gui_button_group( - self, - label: str, - options: Sequence[TLiteralString], - visible: bool = True, - disabled: bool = False, - hint: Optional[str] = None, - order: Optional[float] = None, - ) -> GuiButtonGroupHandle[TLiteralString]: - ... - - @overload - def add_gui_button_group( - self, - label: str, - options: Sequence[TString], - visible: bool = True, - disabled: bool = False, - hint: Optional[str] = None, - order: Optional[float] = None, - ) -> GuiButtonGroupHandle[TString]: - ... - - def add_gui_button_group( - self, - label: str, - options: Sequence[TLiteralString] | Sequence[TString], - visible: bool = True, - disabled: bool = False, - hint: Optional[str] = None, - order: Optional[float] = None, - ) -> GuiButtonGroupHandle[Any]: # Return types are specified in overloads. - """Add a button group to the GUI. - - Args: - label: Label to display on the button group. - options: Sequence of options to display as buttons. - visible: Whether the button group is visible. - disabled: Whether the button group is disabled. - hint: Optional hint to display on hover. - order: Optional ordering, smallest values will be displayed first. - - Returns: - A handle that can be used to interact with the GUI element. - """ - initial_value = options[0] - id = _make_unique_id() - order = _apply_default_order(order) - return GuiButtonGroupHandle( - self._create_gui_input( - initial_value, - message=_messages.GuiAddButtonGroupMessage( - order=order, - id=id, - label=label, - container_id=self._get_container_id(), - hint=hint, - initial_value=initial_value, - options=tuple(options), - ), - disabled=disabled, - visible=visible, - )._impl, - ) - - def add_gui_checkbox( - self, - label: str, - initial_value: bool, - disabled: bool = False, - visible: bool = True, - hint: Optional[str] = None, - order: Optional[float] = None, - ) -> GuiInputHandle[bool]: - """Add a checkbox to the GUI. - - Args: - label: Label to display on the checkbox. - initial_value: Initial value of the checkbox. - disabled: Whether the checkbox is disabled. - visible: Whether the checkbox is visible. - hint: Optional hint to display on hover. - order: Optional ordering, smallest values will be displayed first. - - Returns: - A handle that can be used to interact with the GUI element. - """ - assert isinstance(initial_value, bool) - id = _make_unique_id() - order = _apply_default_order(order) - return self._create_gui_input( - initial_value, - message=_messages.GuiAddCheckboxMessage( - order=order, - id=id, - label=label, - container_id=self._get_container_id(), - hint=hint, - initial_value=initial_value, - ), - disabled=disabled, - visible=visible, - ) - - def add_gui_text( - self, - label: str, - initial_value: str, - disabled: bool = False, - visible: bool = True, - hint: Optional[str] = None, - order: Optional[float] = None, - ) -> GuiInputHandle[str]: - """Add a text input to the GUI. - - Args: - label: Label to display on the text input. - initial_value: Initial value of the text input. - disabled: Whether the text input is disabled. - visible: Whether the text input is visible. - hint: Optional hint to display on hover. - order: Optional ordering, smallest values will be displayed first. - - Returns: - A handle that can be used to interact with the GUI element. - """ - assert isinstance(initial_value, str) - id = _make_unique_id() - order = _apply_default_order(order) - return self._create_gui_input( - initial_value, - message=_messages.GuiAddTextMessage( - order=order, - id=id, - label=label, - container_id=self._get_container_id(), - hint=hint, - initial_value=initial_value, - ), - disabled=disabled, - visible=visible, - ) - - def add_gui_number( - self, - label: str, - initial_value: IntOrFloat, - min: Optional[IntOrFloat] = None, - max: Optional[IntOrFloat] = None, - step: Optional[IntOrFloat] = None, - disabled: bool = False, - visible: bool = True, - hint: Optional[str] = None, - order: Optional[float] = None, - ) -> GuiInputHandle[IntOrFloat]: - """Add a number input to the GUI, with user-specifiable bound and precision parameters. - - Args: - label: Label to display on the number input. - initial_value: Initial value of the number input. - min: Optional minimum value of the number input. - max: Optional maximum value of the number input. - step: Optional step size of the number input. Computed automatically if not - specified. - disabled: Whether the number input is disabled. - visible: Whether the number input is visible. - hint: Optional hint to display on hover. - order: Optional ordering, smallest values will be displayed first. - - Returns: - A handle that can be used to interact with the GUI element. - """ - - assert isinstance(initial_value, (int, float)) - - if step is None: - # It's ok that `step` is always a float, even if the value is an integer, - # because things all become `number` types after serialization. - step = float( # type: ignore - onp.min( - [ - _compute_step(initial_value), - _compute_step(min), - _compute_step(max), - ] - ) - ) - - assert step is not None - - id = _make_unique_id() - order = _apply_default_order(order) - return self._create_gui_input( - initial_value=initial_value, - message=_messages.GuiAddNumberMessage( - order=order, - id=id, - label=label, - container_id=self._get_container_id(), - hint=hint, - initial_value=initial_value, - min=min, - max=max, - precision=_compute_precision_digits(step), - step=step, - ), - disabled=disabled, - visible=visible, - is_button=False, - ) - - def add_gui_vector2( - self, - label: str, - initial_value: Tuple[float, float] | onp.ndarray, - min: Tuple[float, float] | onp.ndarray | None = None, - max: Tuple[float, float] | onp.ndarray | None = None, - step: Optional[float] = None, - disabled: bool = False, - visible: bool = True, - hint: Optional[str] = None, - order: Optional[float] = None, - ) -> GuiInputHandle[Tuple[float, float]]: - """Add a length-2 vector input to the GUI. - - Args: - label: Label to display on the vector input. - initial_value: Initial value of the vector input. - min: Optional minimum value of the vector input. - max: Optional maximum value of the vector input. - step: Optional step size of the vector input. Computed automatically if not - disabled: Whether the vector input is disabled. - visible: Whether the vector input is visible. - hint: Optional hint to display on hover. - order: Optional ordering, smallest values will be displayed first. - - Returns: - A handle that can be used to interact with the GUI element. - """ - initial_value = cast_vector(initial_value, 2) - min = cast_vector(min, 2) if min is not None else None - max = cast_vector(max, 2) if max is not None else None - id = _make_unique_id() - order = _apply_default_order(order) - - if step is None: - possible_steps: List[float] = [] - possible_steps.extend([_compute_step(x) for x in initial_value]) - if min is not None: - possible_steps.extend([_compute_step(x) for x in min]) - if max is not None: - possible_steps.extend([_compute_step(x) for x in max]) - step = float(onp.min(possible_steps)) - - return self._create_gui_input( - initial_value, - message=_messages.GuiAddVector2Message( - order=order, - id=id, - label=label, - container_id=self._get_container_id(), - hint=hint, - initial_value=initial_value, - min=min, - max=max, - step=step, - precision=_compute_precision_digits(step), - ), - disabled=disabled, - visible=visible, - ) - - def add_gui_vector3( - self, - label: str, - initial_value: Tuple[float, float, float] | onp.ndarray, - min: Tuple[float, float, float] | onp.ndarray | None = None, - max: Tuple[float, float, float] | onp.ndarray | None = None, - step: Optional[float] = None, - disabled: bool = False, - visible: bool = True, - hint: Optional[str] = None, - order: Optional[float] = None, - ) -> GuiInputHandle[Tuple[float, float, float]]: - """Add a length-3 vector input to the GUI. - - Args: - label: Label to display on the vector input. - initial_value: Initial value of the vector input. - min: Optional minimum value of the vector input. - max: Optional maximum value of the vector input. - step: Optional step size of the vector input. Computed automatically if not - disabled: Whether the vector input is disabled. - visible: Whether the vector input is visible. - hint: Optional hint to display on hover. - order: Optional ordering, smallest values will be displayed first. - - Returns: - A handle that can be used to interact with the GUI element. - """ - initial_value = cast_vector(initial_value, 2) - min = cast_vector(min, 3) if min is not None else None - max = cast_vector(max, 3) if max is not None else None - id = _make_unique_id() - order = _apply_default_order(order) - - if step is None: - possible_steps: List[float] = [] - possible_steps.extend([_compute_step(x) for x in initial_value]) - if min is not None: - possible_steps.extend([_compute_step(x) for x in min]) - if max is not None: - possible_steps.extend([_compute_step(x) for x in max]) - step = float(onp.min(possible_steps)) - - return self._create_gui_input( - initial_value, - message=_messages.GuiAddVector3Message( - order=order, - id=id, - label=label, - container_id=self._get_container_id(), - hint=hint, - initial_value=initial_value, - min=min, - max=max, - step=step, - precision=_compute_precision_digits(step), - ), - disabled=disabled, - visible=visible, - ) - - # See add_gui_dropdown for notes on overloads. - @overload - def add_gui_dropdown( - self, - label: str, - options: Sequence[TLiteralString], - initial_value: Optional[TLiteralString] = None, - disabled: bool = False, - visible: bool = True, - hint: Optional[str] = None, - order: Optional[float] = None, - ) -> GuiDropdownHandle[TLiteralString]: - ... - - @overload - def add_gui_dropdown( - self, - label: str, - options: Sequence[TString], - initial_value: Optional[TString] = None, - disabled: bool = False, - visible: bool = True, - hint: Optional[str] = None, - order: Optional[float] = None, - ) -> GuiDropdownHandle[TString]: - ... - - def add_gui_dropdown( - self, - label: str, - options: Sequence[TLiteralString] | Sequence[TString], - initial_value: Optional[TLiteralString | TString] = None, - disabled: bool = False, - visible: bool = True, - hint: Optional[str] = None, - order: Optional[float] = None, - ) -> GuiDropdownHandle[Any]: # Output type is specified in overloads. - """Add a dropdown to the GUI. - - Args: - label: Label to display on the dropdown. - options: Sequence of options to display in the dropdown. - initial_value: Initial value of the dropdown. - disabled: Whether the dropdown is disabled. - visible: Whether the dropdown is visible. - hint: Optional hint to display on hover. - order: Optional ordering, smallest values will be displayed first. - - Returns: - A handle that can be used to interact with the GUI element. - """ - if initial_value is None: - initial_value = options[0] - id = _make_unique_id() - order = _apply_default_order(order) - return GuiDropdownHandle( - self._create_gui_input( - initial_value, - message=_messages.GuiAddDropdownMessage( - order=order, - id=id, - label=label, - container_id=self._get_container_id(), - hint=hint, - initial_value=initial_value, - options=tuple(options), - ), - disabled=disabled, - visible=visible, - )._impl, - _impl_options=tuple(options), - ) - - def add_gui_slider( - self, - label: str, - min: IntOrFloat, - max: IntOrFloat, - step: IntOrFloat, - initial_value: IntOrFloat, - disabled: bool = False, - visible: bool = True, - hint: Optional[str] = None, - order: Optional[float] = None, - ) -> GuiInputHandle[IntOrFloat]: - """Add a slider to the GUI. Types of the min, max, step, and initial value should match. - - Args: - label: Label to display on the slider. - min: Minimum value of the slider. - max: Maximum value of the slider. - step: Step size of the slider. - initial_value: Initial value of the slider. - disabled: Whether the slider is disabled. - visible: Whether the slider is visible. - hint: Optional hint to display on hover. - order: Optional ordering, smallest values will be displayed first. - - Returns: - A handle that can be used to interact with the GUI element. - """ - assert max >= min - if step > max - min: - step = max - min - assert max >= initial_value >= min - - # GUI callbacks cast incoming values to match the type of the initial value. If - # the min, max, or step is a float, we should cast to a float. - if type(initial_value) is int and ( - type(min) is float or type(max) is float or type(step) is float - ): - initial_value = float(initial_value) # type: ignore - - # TODO: as of 6/5/2023, this assert will break something in nerfstudio. (at - # least LERF) - # - # assert type(min) == type(max) == type(step) == type(initial_value) - - id = _make_unique_id() - order = _apply_default_order(order) - return self._create_gui_input( - initial_value=initial_value, - message=_messages.GuiAddSliderMessage( - order=order, - id=id, - label=label, - container_id=self._get_container_id(), - hint=hint, - min=min, - max=max, - step=step, - initial_value=initial_value, - precision=_compute_precision_digits(step), - ), - disabled=disabled, - visible=visible, - is_button=False, - ) - - def add_gui_rgb( - self, - label: str, - initial_value: Tuple[int, int, int], - disabled: bool = False, - visible: bool = True, - hint: Optional[str] = None, - order: Optional[float] = None, - ) -> GuiInputHandle[Tuple[int, int, int]]: - """Add an RGB picker to the GUI. - - Args: - label: Label to display on the RGB picker. - initial_value: Initial value of the RGB picker. - disabled: Whether the RGB picker is disabled. - visible: Whether the RGB picker is visible. - hint: Optional hint to display on hover. - order: Optional ordering, smallest values will be displayed first. - - Returns: - A handle that can be used to interact with the GUI element. - """ - - id = _make_unique_id() - order = _apply_default_order(order) - return self._create_gui_input( - initial_value, - message=_messages.GuiAddRgbMessage( - order=order, - id=id, - label=label, - container_id=self._get_container_id(), - hint=hint, - initial_value=initial_value, - ), - disabled=disabled, - visible=visible, - ) - - def add_gui_rgba( - self, - label: str, - initial_value: Tuple[int, int, int, int], - disabled: bool = False, - visible: bool = True, - hint: Optional[str] = None, - order: Optional[float] = None, - ) -> GuiInputHandle[Tuple[int, int, int, int]]: - """Add an RGBA picker to the GUI. - - Args: - label: Label to display on the RGBA picker. - initial_value: Initial value of the RGBA picker. - disabled: Whether the RGBA picker is disabled. - visible: Whether the RGBA picker is visible. - hint: Optional hint to display on hover. - order: Optional ordering, smallest values will be displayed first. - - Returns: - A handle that can be used to interact with the GUI element. - """ - id = _make_unique_id() - order = _apply_default_order(order) - return self._create_gui_input( - initial_value, - message=_messages.GuiAddRgbaMessage( - order=order, - id=id, - label=label, - container_id=self._get_container_id(), - hint=hint, - initial_value=initial_value, - ), - disabled=disabled, - visible=visible, - ) - - def _create_gui_input( - self, - initial_value: T, - message: _messages._GuiAddInputBase, - disabled: bool, - visible: bool, - is_button: bool = False, - ) -> GuiInputHandle[T]: - """Private helper for adding a simple GUI element.""" - - # Send add GUI input message. - self._get_api()._queue(message) - - # Construct handle. - handle_state = _GuiHandleState( - label=message.label, - typ=type(initial_value), - gui_api=self, - value=initial_value, - update_timestamp=time.time(), - container_id=self._get_container_id(), - update_cb=[], - is_button=is_button, - sync_cb=None, - disabled=False, - visible=True, - id=message.id, - order=message.order, - initial_value=initial_value, - hint=message.hint, - ) - - # For broadcasted GUI handles, we should synchronize all clients. - # This will be a no-op for client handles. - if not is_button: - - def sync_other_clients(client_id: ClientId, value: Any) -> None: - message = _messages.GuiSetValueMessage(id=handle_state.id, value=value) - message.excluded_self_client = client_id - self._get_api()._queue(message) - - handle_state.sync_cb = sync_other_clients - - handle = GuiInputHandle(handle_state) - - # Set the disabled/visible fields. These will queue messages under-the-hood. - if disabled: - handle.disabled = disabled - if not visible: - handle.visible = visible + def _update_component_props(self, id: str, kwargs: Dict[str, Any]) -> None: + """Update properties of a GUI element.""" + self._get_api()._queue(_messages.GuiUpdateMessage(id=id, **kwargs)) + def gui_add_component(self, props: TProps) -> TProps: + handle = ComponentHandle(self, id=_make_unique_id(), props=props) + self._gui_handle_from_id[handle.id] = handle return handle diff --git a/src/viser/_gui_components.py b/src/viser/_gui_components.py new file mode 100644 index 000000000..dd7e9c003 --- /dev/null +++ b/src/viser/_gui_components.py @@ -0,0 +1,312 @@ +import typing +from dataclasses import field, InitVar, KW_ONLY +from functools import wraps +import time +from typing import Optional, Literal, Union, TypeVar, Generic, Tuple, Type +from typing import Callable, Any +from dataclasses import dataclass + +try: + from typing import Concatenate +except ImportError: + from typing_extensions import Concatenate +try: + from typing import Self +except ImportError: + from typing_extensions import Self +try: + from typing import Protocol +except ImportError: + from typing_extensions import Protocol +try: + from typing import ParamSpec +except ImportError: + from typing_extensions import ParamSpec + + +TProps = TypeVar("TProps") +TReturn = TypeVar("TReturn") +TArgs = ParamSpec("TArgs") +T = TypeVar("T") + + +def copy_signature(fn_signature: Callable[TArgs, Any]): + def wrapper( + fn: Callable[..., TReturn] + ) -> Callable[Concatenate[Any, TArgs], TReturn]: + out = wraps(fn_signature)(fn) + # TODO: perhaps copy signature from fn_signature and get help for arguments + out.__doc__ = f"""Creates a new GUI {fn_signature.__name__} component and returns a handle to it. + +Returns: + The component handle. +""" + return out + + return wrapper + + +class Property(Generic[T]): + def __init__(self, getter, setter): + self.getter = getter + self.setter = setter + + def get(self) -> T: + return self.getter() + + def set(self, value: T): + self.setter(value) + + +@dataclass(kw_only=True) +class GuiComponent(Protocol): + order: InitVar[Optional[float]] = None + + @property + def order(self) -> Optional[float]: + return object.__getattribute__(self, "_order") + + @property + def id(self): + raise NotImplementedError() + + def __post_init__(self, order: Optional[float]): + object.__setattr__(self, "_order", order) + + def property(self, name: str) -> Property[T]: + raise NotImplementedError() + + +class GuiContainer(Protocol): + def __enter__(self) -> Self: + raise NotImplementedError() + + def __exit__(self, exc_type, exc_value, traceback): + return None + + +@dataclass +class Button(GuiComponent, Protocol): + """Button component""" + + label: str + """Button label""" + color: Optional[ + Literal[ + "dark", + "gray", + "red", + "pink", + "grape", + "violet", + "indigo", + "blue", + "cyan", + "green", + "lime", + "yellow", + "orange", + "teal", + ] + ] = None + """Button color""" + icon_base64: Optional[str] = None + """Icon to display on the button, as a base64-encoded SVG image.""" + disabled: bool = False + """Whether the button is disabled.""" + hint: Optional[str] = None + """Button tooltip.""" + + +@dataclass +class Input(GuiComponent, Protocol): + label: str + _: KW_ONLY + hint: Optional[str] = None + disabled: bool = False + + +@dataclass(kw_only=True) +class TextInput(Input, Protocol): + value: str + + +@dataclass +class Folder(GuiComponent, GuiContainer, Protocol): + label: str + _: KW_ONLY + expand_by_default: bool = True + + +@dataclass(kw_only=True) +class Markdown(GuiComponent, Protocol): + markdown: str + + +@dataclass(kw_only=True) +class TabGroup(GuiComponent, Protocol): + tab_labels: Tuple[str, ...] + tab_icons_base64: Tuple[Union[str, None], ...] + tab_container_ids: Tuple[str, ...] + + +@dataclass(kw_only=True) +class Modal(GuiComponent, Protocol): + order: float + id: str + title: str + + +@dataclass(kw_only=True) +class Slider(Input, Protocol): + value: float + min: Optional[float] = None + max: Optional[float] = None + step: Optional[float] = None + precision: Optional[int] = None + + +@dataclass +class NumberInput(Input, Protocol): + value: float + step: Optional[float] = None + min: Optional[float] = None + max: Optional[float] = None + precision: Optional[int] = None + + +@dataclass(kw_only=True) +class RgbInput(Input, Protocol): + value: Tuple[int, int, int] + + +@dataclass +class RgbaInput(Input, Protocol): + value: Tuple[int, int, int, int] + + +@dataclass +class Checkbox(Input, Protocol): + value: bool + + +@dataclass +class Vector2Input(Input, Protocol): + value: Tuple[float, float] + step: float + min: Optional[Tuple[float, float]] = None + max: Optional[Tuple[float, float]] = None + precision: Optional[int] = None + + +@dataclass +class Vector3Input(Input, Protocol): + value: Tuple[float, float, float] + step: float + min: Optional[Tuple[float, float, float]] = None + max: Optional[Tuple[float, float, float]] = None + precision: Optional[int] = None + + +@dataclass +class Dropdown(Input, Protocol): + options: Tuple[str, ...] + value: Optional[str] = None + + def __post_init__(self, *args, **kwargs): + if self.value is None and len(self.options) > 0: + self.value = self.options[0] + return super().__post_init__(*args, **kwargs) + + +class GuiApiMixin: + @copy_signature(Button) + def add_gui_button(self, *args, **kwargs) -> Button: + props = Button(*args, **kwargs) + return self.gui_add_component(props) + + @copy_signature(TextInput) + def add_gui_text(self, *args, **kwargs) -> TextInput: + props = TextInput(*args, **kwargs) + return self.gui_add_component(props) + + @copy_signature(NumberInput) + def add_gui_number(self, *args, **kwargs) -> NumberInput: + props = NumberInput(*args, **kwargs) + return self.gui_add_component(props) + + @copy_signature(Modal) + def add_gui_modal(self, *args, **kwargs) -> Modal: + props = Modal(*args, **kwargs) + return self.gui_add_component(props) + + @copy_signature(Slider) + def add_gui_slider(self, *args, **kwargs) -> Slider: + props = Slider(*args, **kwargs) + return self.gui_add_component(props) + + @copy_signature(Checkbox) + def add_gui_checkbox(self, *args, **kwargs) -> Checkbox: + props = Checkbox(*args, **kwargs) + return self.gui_add_component(props) + + @copy_signature(RgbInput) + def add_gui_rgb(self, *args, **kwargs) -> RgbInput: + props = RgbInput(*args, **kwargs) + return self.gui_add_component(props) + + @copy_signature(RgbaInput) + def add_gui_rgba(self, *args, **kwargs) -> RgbaInput: + props = RgbaInput(*args, **kwargs) + return self.gui_add_component(props) + + @copy_signature(Folder) + def add_gui_folder(self, *args, **kwargs) -> Folder: + props = Folder(*args, **kwargs) + return self.gui_add_component(props) + + @copy_signature(Markdown) + def add_gui_markdown(self, *args, **kwargs) -> Markdown: + props = Markdown(*args, **kwargs) + return self.gui_add_component(props) + + @copy_signature(TabGroup) + def add_gui_tab_group(self, *args, **kwargs) -> TabGroup: + props = TabGroup(*args, **kwargs) + return self.gui_add_component(props) + + @copy_signature(Vector2Input) + def add_gui_vector2(self, *args, **kwargs) -> Vector2Input: + props = Vector2Input(*args, **kwargs) + return self.gui_add_component(props) + + @copy_signature(Vector3Input) + def add_gui_vector3(self, *args, **kwargs) -> Vector3Input: + props = Vector3Input(*args, **kwargs) + return self.gui_add_component(props) + + @copy_signature(Dropdown) + def add_gui_dropdown(self, *args, **kwargs) -> Dropdown: + props = Dropdown(*args, **kwargs) + return self.gui_add_component(props) + + def gui_add_component(self, props: TProps) -> TProps: + raise NotImplementedError() + + +Component = Union[ + Button, + TextInput, + NumberInput, + Slider, + Checkbox, + RgbInput, + RgbaInput, + Folder, + Markdown, + TabGroup, + Modal, + Vector2Input, + Vector3Input, + Dropdown, +] diff --git a/src/viser/_gui_handles.py b/src/viser/_gui_handles.py index 614a2f839..e7810c156 100644 --- a/src/viser/_gui_handles.py +++ b/src/viser/_gui_handles.py @@ -30,10 +30,6 @@ from ._icons_enum import Icon from ._message_api import _encode_image_base64 from ._messages import ( - GuiAddDropdownMessage, - GuiAddMarkdownMessage, - GuiAddTabGroupMessage, - GuiCloseModalMessage, GuiRemoveMessage, GuiSetDisabledMessage, GuiSetValueMessage, diff --git a/src/viser/_messages.py b/src/viser/_messages.py index 24819e3cb..06ac17314 100644 --- a/src/viser/_messages.py +++ b/src/viser/_messages.py @@ -11,6 +11,7 @@ from typing_extensions import Literal, override from . import infra, theme +from ._gui_components import Component class Message(infra.Message): @@ -37,6 +38,17 @@ def redundancy_key(self) -> str: return "_".join(parts) +@dataclasses.dataclass +class GuiAddComponentMessage(Message): + """Add a GUI component.""" + + order: float + id: str + container_id: str + + props: Component + + @dataclasses.dataclass class ViewerCameraMessage(Message): """Message for a posed viewer camera. @@ -334,151 +346,6 @@ class ResetSceneMessage(Message): """Reset scene.""" -@dataclasses.dataclass -class GuiAddFolderMessage(Message): - order: float - id: str - label: str - container_id: str - expand_by_default: bool - - -@dataclasses.dataclass -class GuiAddMarkdownMessage(Message): - order: float - id: str - markdown: str - container_id: str - - -@dataclasses.dataclass -class GuiAddTabGroupMessage(Message): - order: float - id: str - container_id: str - tab_labels: Tuple[str, ...] - tab_icons_base64: Tuple[Union[str, None], ...] - tab_container_ids: Tuple[str, ...] - - -@dataclasses.dataclass -class _GuiAddInputBase(Message): - """Base message type containing fields commonly used by GUI inputs.""" - - order: float - id: str - label: str - container_id: str - hint: Optional[str] - initial_value: Any - - -@dataclasses.dataclass -class GuiModalMessage(Message): - order: float - id: str - title: str - - -@dataclasses.dataclass -class GuiCloseModalMessage(Message): - id: str - - -@dataclasses.dataclass -class GuiAddButtonMessage(_GuiAddInputBase): - # All GUI elements currently need an `initial_value` field. - # This makes our job on the frontend easier. - initial_value: bool - color: Optional[ - Literal[ - "dark", - "gray", - "red", - "pink", - "grape", - "violet", - "indigo", - "blue", - "cyan", - "green", - "lime", - "yellow", - "orange", - "teal", - ] - ] - icon_base64: Optional[str] - - -@dataclasses.dataclass -class GuiAddSliderMessage(_GuiAddInputBase): - min: float - max: float - step: Optional[float] - initial_value: float - precision: int - - -@dataclasses.dataclass -class GuiAddNumberMessage(_GuiAddInputBase): - initial_value: float - precision: int - step: float - min: Optional[float] - max: Optional[float] - - -@dataclasses.dataclass -class GuiAddRgbMessage(_GuiAddInputBase): - initial_value: Tuple[int, int, int] - - -@dataclasses.dataclass -class GuiAddRgbaMessage(_GuiAddInputBase): - initial_value: Tuple[int, int, int, int] - - -@dataclasses.dataclass -class GuiAddCheckboxMessage(_GuiAddInputBase): - initial_value: bool - - -@dataclasses.dataclass -class GuiAddVector2Message(_GuiAddInputBase): - initial_value: Tuple[float, float] - min: Optional[Tuple[float, float]] - max: Optional[Tuple[float, float]] - step: float - precision: int - - -@dataclasses.dataclass -class GuiAddVector3Message(_GuiAddInputBase): - initial_value: Tuple[float, float, float] - min: Optional[Tuple[float, float, float]] - max: Optional[Tuple[float, float, float]] - step: float - precision: int - - -@dataclasses.dataclass -class GuiAddTextMessage(_GuiAddInputBase): - initial_value: str - - -@dataclasses.dataclass -class GuiAddDropdownMessage(_GuiAddInputBase): - initial_value: str - options: Tuple[str, ...] - - -@dataclasses.dataclass -class GuiAddButtonGroupMessage(_GuiAddInputBase): - initial_value: str - options: Tuple[str, ...] - - @dataclasses.dataclass class GuiRemoveMessage(Message): """Sent server->client to remove a GUI element.""" diff --git a/src/viser/client/src/ControlPanel/Generated.tsx b/src/viser/client/src/ControlPanel/Generated.tsx index 093aa46bb..1eb87f00b 100644 --- a/src/viser/client/src/ControlPanel/Generated.tsx +++ b/src/viser/client/src/ControlPanel/Generated.tsx @@ -1,4 +1,5 @@ import { + AllComponentProps, GuiAddFolderMessage, GuiAddTabGroupMessage, } from "../WebsocketMessages"; @@ -32,19 +33,31 @@ import Markdown from "../Markdown"; import { ErrorBoundary } from "react-error-boundary"; import { useDisclosure } from "@mantine/hooks"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; +import { GuiGenerateContext, SetProps } from "./GuiState"; +import GeneratedComponent from "../GeneratedComponent"; + + +function GeneratedComponentFromId({ id }: { id: string }) { + const viewer = React.useContext(ViewerContext)!; + const props = viewer.useGui((state) => state.guiPropsFromId[id]) ?? {}; + const attributes = viewer.useGui((state) => state.guiAttributeFromId[id]) ?? {}; + const contextValue = React.useContext(GuiGenerateContext)!; + const update = (callback: SetProps) => viewer.useGui((state) => state.setProps(id, callback)); + return + + ; +} /** Root of generated inputs. */ export default function GeneratedGuiContainer({ // We need to take viewer as input in drei's elements, where contexts break. containerId, - viewer, folderDepth, }: { containerId: string; - viewer?: ViewerContextContents; folderDepth?: number; }) { - if (viewer === undefined) viewer = React.useContext(ViewerContext)!; + const viewer = React.useContext(ViewerContext)!; const guiIdSet = viewer.useGui((state) => state.guiIdSetFromContainerId[containerId]) ?? {}; @@ -62,15 +75,13 @@ export default function GeneratedGuiContainer({ {guiIdOrderPairArray .sort((a, b) => a.order - b.order) - .map((pair, index) => ( - - ))} + .map((pair, index) => + )} ); return out; @@ -95,31 +106,10 @@ function GeneratedInput({ // Handle nested containers. if (conf.type == "GuiAddFolderMessage") return ( - - - + <> ); if (conf.type == "GuiAddTabGroupMessage") - return ; - if (conf.type == "GuiAddMarkdownMessage") { - let { visible } = - viewer.useGui((state) => state.guiAttributeFromId[conf.id]) || {}; - visible = visible ?? true; - if (!visible) return <>; - return ( - - Markdown Failed to Render} - > - {conf.markdown} - - - ); - } + return <>; const messageSender = makeThrottledMessageSender(viewer.websocketRef, 50); function updateValue(value: any) { @@ -139,600 +129,4 @@ function GeneratedInput({ visible = visible ?? true; disabled = disabled ?? false; - if (!visible) return <>; - - let inputColor = - computeRelativeLuminance(theme.fn.primaryColor()) > 50.0 - ? theme.colors.gray[9] - : theme.white; - - let labeled = true; - let input = null; - switch (conf.type) { - case "GuiAddButtonMessage": - labeled = false; - if (conf.color !== null) { - inputColor = - computeRelativeLuminance( - theme.colors[conf.color][theme.fn.primaryShade()], - ) > 50.0 - ? theme.colors.gray[9] - : theme.white; - } - - input = ( - - ); - break; - case "GuiAddSliderMessage": - input = ( - - - ({ - thumb: { - background: theme.fn.primaryColor(), - borderRadius: "0.1em", - height: "0.75em", - width: "0.625em", - }, - })} - pt="0.2em" - showLabelOnHover={false} - min={conf.min} - max={conf.max} - step={conf.step ?? undefined} - precision={conf.precision} - value={value} - onChange={updateValue} - marks={[{ value: conf.min }, { value: conf.max }]} - disabled={disabled} - /> - - {parseInt(conf.min.toFixed(6))} - {parseInt(conf.max.toFixed(6))} - - - { - // Ignore empty values. - newValue !== "" && updateValue(newValue); - }} - size="xs" - min={conf.min} - max={conf.max} - hideControls - step={conf.step ?? undefined} - precision={conf.precision} - sx={{ width: "3rem" }} - styles={{ - input: { - padding: "0.375em", - letterSpacing: "-0.5px", - minHeight: "1.875em", - height: "1.875em", - }, - }} - ml="xs" - /> - - ); - break; - case "GuiAddNumberMessage": - input = ( - { - // Ignore empty values. - newValue !== "" && updateValue(newValue); - }} - styles={{ - input: { - minHeight: "1.625rem", - height: "1.625rem", - }, - }} - disabled={disabled} - stepHoldDelay={500} - stepHoldInterval={(t) => Math.max(1000 / t ** 2, 25)} - /> - ); - break; - case "GuiAddTextMessage": - input = ( - { - updateValue(value.target.value); - }} - styles={{ - input: { - minHeight: "1.625rem", - height: "1.625rem", - padding: "0 0.5em", - }, - }} - disabled={disabled} - /> - ); - break; - case "GuiAddCheckboxMessage": - input = ( - { - updateValue(value.target.checked); - }} - disabled={disabled} - styles={{ - icon: { - color: inputColor + " !important", - }, - }} - /> - ); - break; - case "GuiAddVector2Message": - input = ( - - ); - break; - case "GuiAddVector3Message": - input = ( - - ); - break; - case "GuiAddDropdownMessage": - input = ( - update({ value })} + searchable + maxDropdownHeight={400} + size="xs" + styles={{ + input: { + padding: "0.5em", + letterSpacing: "-0.5px", + minHeight: "1.625rem", + height: "1.625rem", + }, + }} + // zIndex of dropdown should be >modal zIndex. + // On edge cases: it seems like existing dropdowns are always closed when a new modal is opened. + zIndex={1000} + withinPortal={true} + /> + ); +} + diff --git a/src/viser/client/src/components/Folder.tsx b/src/viser/client/src/components/Folder.tsx new file mode 100644 index 000000000..7bbb0df67 --- /dev/null +++ b/src/viser/client/src/components/Folder.tsx @@ -0,0 +1,72 @@ +import React from "react"; + +import { FolderProps } from '../WebsocketMessages'; +import { ViewerContext, ViewerContextContents } from "../App"; +import { + Collapse, + Paper, + Box, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; +import { GuiGenerateContext } from "../ControlPanel/GuiState"; + + +export default function FolderComponent(conf: FolderProps) { + const viewer = React.useContext(ViewerContext)!; + const { renderContainer } = React.useContext(GuiGenerateContext)!; + const [opened, { toggle }] = useDisclosure(conf.expand_by_default); + const guiIdSet = viewer.useGui( + (state) => state.guiIdSetFromContainerId[conf.id], + ); + const isEmpty = guiIdSet === undefined || Object.keys(guiIdSet).length === 0; + + const ToggleIcon = opened ? IconChevronUp : IconChevronDown; + return ( + + + {conf.label} + + + + {renderContainer(conf.id, true)} + + + + + + + ); +} \ No newline at end of file diff --git a/src/viser/client/src/Markdown.tsx b/src/viser/client/src/components/Markdown.tsx similarity index 91% rename from src/viser/client/src/Markdown.tsx rename to src/viser/client/src/components/Markdown.tsx index fa7b7db29..dd94105f5 100644 --- a/src/viser/client/src/Markdown.tsx +++ b/src/viser/client/src/components/Markdown.tsx @@ -8,6 +8,7 @@ import remarkGfm from "remark-gfm"; import rehypeColorChips from "rehype-color-chips"; import { Anchor, + Box, Blockquote, Code, Image, @@ -22,6 +23,11 @@ import { visit } from "unist-util-visit"; import { Transformer } from "unified"; import { Element, Root } from "hast"; +import { ErrorBoundary } from "react-error-boundary"; +import { MarkdownProps } from "../WebsocketMessages"; + + + // Custom Rehype to clean up code blocks (Mantine makes these annoying to style) // Adds "block" to any code non-inline code block, which gets directly passed into // the Mantine Code component. @@ -166,7 +172,7 @@ async function parseMarkdown(markdown: string) { * NOTE: Only run on markdown you trust. * It might be worth looking into sandboxing all markdown so that it can't run JS. */ -export default function Markdown(props: { children?: string }) { +export function Markdown(props: { children?: string }) { const [child, setChild] = useState(null); useEffect(() => { @@ -181,3 +187,16 @@ export default function Markdown(props: { children?: string }) { return child; } + + + + +export default function MarkdownComponent(conf: MarkdownProps) { + return ( + Markdown Failed to Render} + > + {conf.markdown} + + ); +} \ No newline at end of file diff --git a/src/viser/client/src/components/Modal.tsx b/src/viser/client/src/components/Modal.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/viser/client/src/components/NumberInput.tsx b/src/viser/client/src/components/NumberInput.tsx new file mode 100644 index 000000000..f1b924ff4 --- /dev/null +++ b/src/viser/client/src/components/NumberInput.tsx @@ -0,0 +1,33 @@ +import { NumberInput } from "@mantine/core"; +import { WrapInputDefault } from "./utils"; +import { GuiProps } from "../ControlPanel/GuiState"; +import { NumberInputProps } from "../WebsocketMessages"; + + +export default function NumberInputComponent({ id, precision, min, max, step, disabled, value, update }: GuiProps) { + return ( + { + // Ignore empty values. + value !== "" && update({ value }); + }} + styles={{ + input: { + minHeight: "1.625rem", + height: "1.625rem", + }, + }} + disabled={disabled} + stepHoldDelay={500} + stepHoldInterval={(t) => Math.max(1000 / t ** 2, 25)} + /> + ); +} + diff --git a/src/viser/client/src/components/RgbInput.tsx b/src/viser/client/src/components/RgbInput.tsx new file mode 100644 index 000000000..07579b0f1 --- /dev/null +++ b/src/viser/client/src/components/RgbInput.tsx @@ -0,0 +1,25 @@ +import { ColorInput } from "@mantine/core"; +import { hexToRgb, rgbToHex, WrapInputDefault } from "./utils"; +import { GuiProps } from "../ControlPanel/GuiState"; +import { RgbInputProps } from "../WebsocketMessages"; + + +export default function RgbInputComponent({ disabled, value, update }: GuiProps) { + return ( + update({ value: hexToRgb(v) })} + format="hex" + // zIndex of dropdown should be >modal zIndex. + // On edge cases: it seems like existing dropdowns are always closed when a new modal is opened. + dropdownZIndex={1000} + withinPortal={true} + styles={{ + input: { height: "1.625rem", minHeight: "1.625rem" }, + icon: { transform: "scale(0.8)" }, + }} + /> + ); +} \ No newline at end of file diff --git a/src/viser/client/src/components/RgbaInput.tsx b/src/viser/client/src/components/RgbaInput.tsx new file mode 100644 index 000000000..7fc233331 --- /dev/null +++ b/src/viser/client/src/components/RgbaInput.tsx @@ -0,0 +1,22 @@ +import { ColorInput } from "@mantine/core"; +import { hexToRgba, rgbaToHex, WrapInputDefault } from "./utils"; +import { GuiProps } from "../ControlPanel/GuiState"; +import { RgbaInputProps } from "../WebsocketMessages"; + + +export default function RgbaInputComponent({ disabled, value, update }: GuiProps) { + return ( + update({ value: hexToRgba(v)})} + format="hexa" + // zIndex of dropdown should be >modal zIndex. + // On edge cases: it seems like existing dropdowns are always closed when a new modal is opened. + dropdownZIndex={1000} + withinPortal={true} + styles={{ input: { height: "1.625rem", minHeight: "1.625rem" } }} + /> + ); +} \ No newline at end of file diff --git a/src/viser/client/src/components/Slider.tsx b/src/viser/client/src/components/Slider.tsx new file mode 100644 index 000000000..e302a99bf --- /dev/null +++ b/src/viser/client/src/components/Slider.tsx @@ -0,0 +1,74 @@ +import { Flex, Box, Slider, NumberInput, Text } from '@mantine/core'; +import { WrapInputDefault } from "./utils"; +import { GuiProps } from "../ControlPanel/GuiState"; +import { SliderProps } from "../WebsocketMessages"; + + +export default function RgbInputComponent({ disabled, value, update, ...conf }: GuiProps) { + const min = conf.min ?? 0; + const max = conf.max ?? 100; + return ( + + + ({ + thumb: { + background: theme.fn.primaryColor(), + borderRadius: "0.1em", + height: "0.75em", + width: "0.625em", + }, + })} + pt="0.2em" + showLabelOnHover={false} + min={min} + max={max} + step={conf.step ?? undefined} + precision={conf.precision ?? undefined} + value={value} + onChange={(value) => update({ value })} + marks={[{ value: min }, { value: max }]} + disabled={disabled} + /> + + {parseInt(min.toFixed(6))} + {parseInt(max.toFixed(6))} + + + { + // Ignore empty values. + value !== "" && update({ value }); + }} + size="xs" + min={min} + max={max} + hideControls + step={conf.step ?? undefined} + precision={conf.precision ?? undefined} + sx={{ width: "3rem" }} + styles={{ + input: { + padding: "0.375em", + letterSpacing: "-0.5px", + minHeight: "1.875em", + height: "1.875em", + }, + }} + ml="xs" + /> + + ); +} \ No newline at end of file diff --git a/src/viser/client/src/components/TabGroup.tsx b/src/viser/client/src/components/TabGroup.tsx new file mode 100644 index 000000000..a153d2ea0 --- /dev/null +++ b/src/viser/client/src/components/TabGroup.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Tabs, Image } from '@mantine/core'; +import { TabGroupProps } from '../WebsocketMessages'; +import { GuiGenerateContext } from '../ControlPanel/GuiState'; + + +export default function GeneratedTabGroup({ conf }: { conf: TabGroupProps }) { + const [tabState, setTabState] = React.useState("0"); + const { renderContainer } = React.useContext(GuiGenerateContext)!; + const icons = conf.tab_icons_base64; + + return ( + + + {conf.tab_labels.map((label, index) => ( + ({ + filter: + theme.colorScheme == "dark" ? "invert(1)" : undefined, + })} + src={"data:image/svg+xml;base64," + icons[index]} + /> + ) + } + > + {label} + + ))} + + {conf.tab_container_ids.map((containerId, index) => ( + + {renderContainer(containerId)} + + ))} + + ); + } \ No newline at end of file diff --git a/src/viser/client/src/components/TextInput.tsx b/src/viser/client/src/components/TextInput.tsx new file mode 100644 index 000000000..3f44ec5e9 --- /dev/null +++ b/src/viser/client/src/components/TextInput.tsx @@ -0,0 +1,23 @@ +import { TextInput } from "@mantine/core"; +import { TextInputProps } from "../WebsocketMessages"; +import { GuiProps } from "../ControlPanel/GuiState"; +import { WrapInputDefault } from "./utils"; + +export default function TextInputComponent({ id, value, disabled, update }: GuiProps) { + return + update({ value: e.target.value})} + styles={{ + input: { + minHeight: "1.625rem", + height: "1.625rem", + padding: "0 0.5em", + }, + }} + disabled={disabled} + /> + +} \ No newline at end of file diff --git a/src/viser/client/src/components/Vector2Input.tsx b/src/viser/client/src/components/Vector2Input.tsx new file mode 100644 index 000000000..ed4d92700 --- /dev/null +++ b/src/viser/client/src/components/Vector2Input.tsx @@ -0,0 +1,20 @@ +import { WrapInputDefault, VectorInput } from "./utils"; +import { GuiProps } from "../ControlPanel/GuiState"; +import { Vector2InputProps } from "../WebsocketMessages"; + + +export default function Vector2InputComponent({ disabled, value, update, ...conf }: GuiProps) { + return ( + update({ value: value as [number, number] })} + min={conf.min} + max={conf.max} + step={conf.step} + precision={conf.precision ?? undefined} + disabled={disabled} + /> + ); +} \ No newline at end of file diff --git a/src/viser/client/src/components/Vector3Input.tsx b/src/viser/client/src/components/Vector3Input.tsx new file mode 100644 index 000000000..b782f9758 --- /dev/null +++ b/src/viser/client/src/components/Vector3Input.tsx @@ -0,0 +1,20 @@ +import { WrapInputDefault, VectorInput } from "./utils"; +import { GuiProps } from "../ControlPanel/GuiState"; +import { Vector3InputProps } from "../WebsocketMessages"; + + +export default function Vector3InputComponent({ disabled, value, update, ...conf }: GuiProps) { + return ( + update({ value: value as [number, number, number] })} + min={conf.min} + max={conf.max} + step={conf.step} + precision={conf.precision ?? undefined} + disabled={disabled} + /> + ); +} \ No newline at end of file diff --git a/src/viser/client/src/components/utils.tsx b/src/viser/client/src/components/utils.tsx new file mode 100644 index 000000000..7481b84b9 --- /dev/null +++ b/src/viser/client/src/components/utils.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { Box, Tooltip, Flex, NumberInput, Text } from '@mantine/core'; +import { GuiGenerateContext } from '../ControlPanel/GuiState'; + + +export function WrapInputDefault({ children, label, hint }: { children: React.ReactNode, label?: string | null, hint?: string | null }) { + const { id, folderDepth, _hint, _label } = React.useContext(GuiGenerateContext)!; + label = label === undefined ? _label : label; + hint = hint === undefined ? _hint : hint; + if (hint !== null && hint !== undefined) + children = // We need to add for inputs that we can't assign refs to. + ( + + + {children} + + + ); + + if (label !== null && label !== undefined) { + children = ( + + ); + } + + return ( + + {children} + + ); +} + + +// Color conversion helpers. +export function rgbToHex([r, g, b]: [number, number, number]): string { + const hexR = r.toString(16).padStart(2, "0"); + const hexG = g.toString(16).padStart(2, "0"); + const hexB = b.toString(16).padStart(2, "0"); + return `#${hexR}${hexG}${hexB}`; +} + +export function hexToRgb(hexColor: string): [number, number, number] { + const hex = hexColor.slice(1); // Remove the # in #ffffff. + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + return [r, g, b]; +} +export function rgbaToHex([r, g, b, a]: [number, number, number, number]): string { + const hexR = r.toString(16).padStart(2, "0"); + const hexG = g.toString(16).padStart(2, "0"); + const hexB = b.toString(16).padStart(2, "0"); + const hexA = a.toString(16).padStart(2, "0"); + return `#${hexR}${hexG}${hexB}${hexA}`; +} + +export function hexToRgba(hexColor: string): [number, number, number, number] { + const hex = hexColor.slice(1); // Remove the # in #ffffff. + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + const a = parseInt(hex.substring(6, 8), 16); + return [r, g, b, a]; +} + + +export function VectorInput( + props: + | { + id: string; + n: 2; + value: [number, number]; + min: [number, number] | null; + max: [number, number] | null; + step: number; + precision?: number; + onChange: (value: number[]) => void; + disabled: boolean; + } + | { + id: string; + n: 3; + value: [number, number, number]; + min: [number, number, number] | null; + max: [number, number, number] | null; + step: number; + precision?: number; + onChange: (value: number[]) => void; + disabled: boolean; + }, +) { + return ( + + {[...Array(props.n).keys()].map((i) => ( + { + const updated = [...props.value]; + updated[i] = v === "" ? 0.0 : v; + props.onChange(updated); + }} + size="xs" + styles={{ + root: { flexGrow: 1, width: 0 }, + input: { + paddingLeft: "0.5em", + paddingRight: "1.75em", + textAlign: "right", + minHeight: "1.875em", + height: "1.875em", + }, + rightSection: { width: "1.2em" }, + control: { + width: "1.1em", + }, + }} + precision={props.precision} + step={props.step} + min={props.min === null ? undefined : props.min[i]} + max={props.max === null ? undefined : props.max[i]} + stepHoldDelay={500} + stepHoldInterval={(t) => Math.max(1000 / t ** 2, 25)} + disabled={props.disabled} + /> + ))} + + ); +} + +/** GUI input with a label horizontally placed to the left of it. */ +export function LabeledInput(props: { + id: string; + label: string; + input: React.ReactNode; + folderDepth: number; +}) { + return ( + + + + + + + {props.input} + + ); +} \ No newline at end of file diff --git a/src/viser/infra/__init__.py b/src/viser/infra/__init__.py index fa67e909d..8c81627bf 100644 --- a/src/viser/infra/__init__.py +++ b/src/viser/infra/__init__.py @@ -18,4 +18,5 @@ from ._messages import Message as Message from ._typescript_interface_gen import ( generate_typescript_interfaces as generate_typescript_interfaces, + generate_typescript_components as generate_typescript_components, ) diff --git a/src/viser/infra/_typescript_interface_gen.py b/src/viser/infra/_typescript_interface_gen.py index 84ba23c23..82b834036 100644 --- a/src/viser/infra/_typescript_interface_gen.py +++ b/src/viser/infra/_typescript_interface_gen.py @@ -1,5 +1,6 @@ +from functools import partial import dataclasses -from typing import Any, ClassVar, Type, Union, cast, get_type_hints +from typing import Any, ClassVar, Type, Union, cast, get_type_hints, Dict, Tuple import numpy as onp from typing_extensions import Literal, get_args, get_origin, is_typeddict @@ -10,6 +11,7 @@ LiteralAlt = Literal # type: ignore from ._messages import Message +from .._gui_components import Component, Property _raw_type_mapping = { bool: "boolean", @@ -25,15 +27,28 @@ } -def _get_ts_type(typ: Type[Any]) -> str: +# def _is_property(typ: Type[Any]) -> bool: +# if get_origin(typ) is not Union: +# return False +# if len(get_args(typ)) != 2: +# return False +# base_type = get_args(typ)[0] +# link_type = get_args(type)[1] +# if get_origin(link_type) is not Link: +# get_args(typ) == (Link,) + + +def _get_ts_type(typ: Type[Any], known_types: Dict[Type, str]) -> str: origin_typ = get_origin(typ) + if typ in known_types: + return known_types[typ] if origin_typ is tuple: args = get_args(typ) if len(args) == 2 and args[1] == ...: - return _get_ts_type(args[0]) + "[]" + return _get_ts_type(args[0], known_types) + "[]" else: - return "[" + ", ".join(map(_get_ts_type, args)) + "]" + return "[" + ", ".join(map(partial(_get_ts_type, known_types=known_types), args)) + "]" elif origin_typ in (Literal, LiteralAlt): return " | ".join( map( @@ -41,12 +56,15 @@ def _get_ts_type(typ: Type[Any]) -> str: get_args(typ), ) ) + elif origin_typ is Property: + subtype = _get_ts_type(get_args(typ)[0], known_types) + return "Property<" + subtype + ">" elif origin_typ is Union: return ( "(" + " | ".join( map( - _get_ts_type, + partial(_get_ts_type, known_types=known_types), get_args(typ), ) ) @@ -57,7 +75,7 @@ def _get_ts_type(typ: Type[Any]) -> str: def fmt(key): val = hints[key] - ret = f"'{key}'" + ": " + _get_ts_type(val) + ret = f"'{key}'" + ": " + _get_ts_type(val, known_types) return ret ret = "{" + ", ".join(map(fmt, hints)) + "}" @@ -66,40 +84,99 @@ def fmt(key): # Like get_origin(), but also supports numpy.typing.NDArray[dtype]. typ = cast(Any, getattr(typ, "__origin__", typ)) + if typ not in _raw_type_mapping: + breakpoint() + assert typ in _raw_type_mapping, f"Unsupported type {typ}" return _raw_type_mapping[typ] -def generate_typescript_interfaces(message_cls: Type[Message]) -> str: +def generate_typescript_components() -> str: + component_types = get_args(Component) + known_types = {t: t.__name__ + "Props" for t in component_types} + known_types[Component] = "AllComponentProps" + + lines = [] + lines.append("import {") + lines.append(" AllComponentProps,") + for cls in component_types: + lines.append(f" {cls.__name__ + 'Props'},") + lines.append("} from './WebsocketMessages';") + for cls in component_types: + cname = cls.__name__ + lines.append(f"import {cname} from './components/{cname}';") + lines.append("") + lines.append("") + lines.append("export default function GeneratedComponent({type, props}: AllComponentProps) {") + lines.append(" switch (type) {") + for cls in component_types: + lines.append(f' case "{cls.__name__}":') + lines.append(f" return <{cls.__name__} {{...props}} />;") + + lines.append(" default:") + lines.append(" throw new Error(`Unknown component type ${type}`);") + lines.append(" }") + lines.append("}") + + return ( + "\n".join( + [ + ( + "// AUTOMATICALLY GENERATED message interfaces, from Python" + " dataclass definitions." + ), + "// This file should not be manually modified.", + "", + ] + ) + + "\n".join(lines) + "\n" + ) + + +def generate_typescript_interfaces(message_cls: Type[Message]) -> Tuple[str, str]: """Generate TypeScript definitions for all subclasses of a base message class.""" out_lines = [] message_types = message_cls.get_subclasses() + component_types = get_args(Component) + known_types = {t: t.__name__ for t in component_types} + known_types[Component] = "AllComponentProps" - # Generate interfaces for each specific message. - for cls in message_types: - if cls.__doc__ is not None: - docstring = "\n * ".join( - map(lambda line: line.strip(), cls.__doc__.split("\n")) - ) - out_lines.append(f"/** {docstring}") - out_lines.append(" *") - out_lines.append(" * (automatically generated)") - out_lines.append(" */") - - out_lines.append(f"export interface {cls.__name__} " + "{") - out_lines.append(f' type: "{cls.__name__}";') - field_names = set([f.name for f in dataclasses.fields(cls)]) # type: ignore - for name, typ in get_type_hints(cls).items(): - if typ == ClassVar[str]: - typ = f'"{getattr(cls, name)}"' - elif name in field_names: - typ = _get_ts_type(typ) - else: - continue - out_lines.append(f" {name}: {typ};") - out_lines.append("}") + # Add common property interface. + out_lines.append("export type Property = { path: string; } | { value: T };") out_lines.append("") + # Generate interfaces for each specific message. + for types, postfix in [(component_types, "Props"), (message_types, "")]: + for cls in types: + if cls.__doc__ is not None: + docstring = "\n * ".join( + map(lambda line: line.strip(), cls.__doc__.split("\n")) + ) + out_lines.append(f"/** {docstring}") + out_lines.append(" *") + out_lines.append(" * (automatically generated)") + out_lines.append(" */") + + out_lines.append(f"export interface {cls.__name__ + postfix} " + "{") + out_lines.append(f' type: "{cls.__name__}";') + field_names = set([f.name for f in dataclasses.fields(cls)]) # type: ignore + for name, typ in get_type_hints(cls).items(): + if typ == ClassVar[str]: + typ = f'"{getattr(cls, name)}"' + elif name in field_names: + typ = _get_ts_type(typ, known_types) + else: + continue + out_lines.append(f" {name}: {typ};") + out_lines.append("}") + out_lines.append("") + + # Generate union type over all component props. + out_lines.append("export type AllComponentProps = ") + for cls in component_types: + out_lines.append(f" | {cls.__name__ + 'Props'}") + out_lines[-1] = out_lines[-1] + ";" + # Generate union type over all messages. out_lines.append("export type Message = ") for cls in message_types: diff --git a/sync_message_defs.py b/sync_message_defs.py index 6a22eba43..57fe3c598 100644 --- a/sync_message_defs.py +++ b/sync_message_defs.py @@ -20,3 +20,14 @@ # Run prettier. subprocess.run(args=["npx", "prettier", "-w", str(target_path)], check=False) + + # Write typescript components + target_path = pathlib.Path(__file__).parent / pathlib.Path( + "src/viser/client/src/GeneratedComponent.tsx" + ) + assert target_path.exists() + target_path.write_text(viser.infra.generate_typescript_components()) + print(f"Wrote to {target_path}") + + # Run prettier. + subprocess.run(args=["npx", "prettier", "-w", str(target_path)], check=False)