From 59e74ea8613b90a4dd8644c18c24e7ead1f55742 Mon Sep 17 00:00:00 2001 From: Jonas Kulhanek Date: Sun, 21 Jan 2024 11:17:48 +0100 Subject: [PATCH 1/4] Components api change --- src/viser/_gui_components.py | 435 ++++++++++++++++ src/viser/_gui_handles.py | 4 - src/viser/_messages.py | 157 +----- .../client/src/ControlPanel/Generated.tsx | 359 ------------- .../client/src/ControlPanel/GuiState.tsx | 71 +-- src/viser/client/src/GeneratedComponent.tsx | 73 +++ src/viser/client/src/WebsocketInterface.tsx | 30 +- src/viser/client/src/WebsocketMessages.tsx | 478 ++++++++---------- src/viser/client/src/components/Button.tsx | 66 +++ src/viser/client/src/components/Checkbox.tsx | 56 ++ src/viser/client/src/components/Dropdown.tsx | 33 ++ src/viser/client/src/components/Folder.tsx | 72 +++ .../client/src/{ => components}/Markdown.tsx | 21 +- src/viser/client/src/components/Modal.tsx | 0 .../client/src/components/NumberInput.tsx | 33 ++ src/viser/client/src/components/RgbInput.tsx | 25 + src/viser/client/src/components/RgbaInput.tsx | 22 + src/viser/client/src/components/Slider.tsx | 74 +++ src/viser/client/src/components/TabGroup.tsx | 50 ++ src/viser/client/src/components/TextInput.tsx | 23 + .../client/src/components/Vector2Input.tsx | 20 + .../client/src/components/Vector3Input.tsx | 20 + src/viser/client/src/components/utils.tsx | 178 +++++++ src/viser/infra/__init__.py | 1 + src/viser/infra/_typescript_interface_gen.py | 137 +++-- sync_message_defs.py | 11 + 26 files changed, 1602 insertions(+), 847 deletions(-) create mode 100644 src/viser/_gui_components.py create mode 100644 src/viser/client/src/GeneratedComponent.tsx create mode 100644 src/viser/client/src/components/Button.tsx create mode 100644 src/viser/client/src/components/Checkbox.tsx create mode 100644 src/viser/client/src/components/Dropdown.tsx create mode 100644 src/viser/client/src/components/Folder.tsx rename src/viser/client/src/{ => components}/Markdown.tsx (91%) create mode 100644 src/viser/client/src/components/Modal.tsx create mode 100644 src/viser/client/src/components/NumberInput.tsx create mode 100644 src/viser/client/src/components/RgbInput.tsx create mode 100644 src/viser/client/src/components/RgbaInput.tsx create mode 100644 src/viser/client/src/components/Slider.tsx create mode 100644 src/viser/client/src/components/TabGroup.tsx create mode 100644 src/viser/client/src/components/TextInput.tsx create mode 100644 src/viser/client/src/components/Vector2Input.tsx create mode 100644 src/viser/client/src/components/Vector3Input.tsx create mode 100644 src/viser/client/src/components/utils.tsx diff --git a/src/viser/_gui_components.py b/src/viser/_gui_components.py new file mode 100644 index 000000000..a19cbe996 --- /dev/null +++ b/src/viser/_gui_components.py @@ -0,0 +1,435 @@ +from dataclasses import field +from functools import wraps +import time +from typing import Optional, Literal, Union, TypeVar, Generic, Tuple +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 + + +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]: + return wraps(fn_signature)(fn) + 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) + + +class ComponentHandle(Generic[TProps]): + def __init__(self, update, id: str, props: TProps): + self.id = id + self._props = props + self._api_update = update + self._update_timestamp = time.time() + + 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._api_update(self._impl.id, kwargs) + + def property(self, name: str) -> Property[T]: + if not hasattr(self._props, name): + raise AttributeError(f"Component has no property {name}") + return Property( + lambda: getattr(self._props, name), + lambda value: self._update(**{name: value}), + ) + + def __getattribute__(self, name: str) -> T: + if not hasattr(ComponentHandle, name): + return self.property(name).get() + else: + return super().__getattribute__(name) + + +@dataclass +class Button(Protocol): + label: str + color: Optional[ + Literal[ + "dark", + "gray", + "red", + "pink", + "grape", + "violet", + "indigo", + "blue", + "cyan", + "green", + "lime", + "yellow", + "orange", + "teal", + ] + ] = None + icon_base64: Optional[str] = None + disabled: bool = False + hint: Optional[str] = None + + +@dataclass +class Input(Protocol): + value: str + label: str + hint: Optional[str] + disabled: bool = False + + +@dataclass +class TextInput(Input): + pass + +@dataclass +class Folder(Protocol): + label: str + expand_by_default: bool = True + +@dataclass +class Markdown(Protocol): + markdown: str + +@dataclass +class TabGroup(Protocol): + tab_labels: Tuple[str, ...] + tab_icons_base64: Tuple[Union[str, None], ...] + tab_container_ids: Tuple[str, ...] + +@dataclass +class Modal(Protocol): + order: float + id: str + title: str + + +@dataclass(kw_only=True) +class Slider(Input): + value: float + min: Optional[float] = None + max: Optional[float] = None + step: Optional[float] = None + precision: Optional[int] = None + + +@dataclass(kw_only=True) +class NumberInput(Input): + value: float + step: float + min: Optional[float] = None + max: Optional[float] = None + precision: Optional[int] = None + + +@dataclass(kw_only=True) +class RgbInput(Input): + value: Tuple[int, int, int] + + +@dataclass(kw_only=True) +class RgbaInput(Input): + value: Tuple[int, int, int, int] + + +@dataclass(kw_only=True) +class Checkbox(Input): + value: bool + + +@dataclass(kw_only=True) +class Vector2Input(Input): + value: Tuple[float, float] + step: float + min: Optional[Tuple[float, float]] = None + max: Optional[Tuple[float, float]] = None + precision: Optional[int] = None + + +@dataclass(kw_only=True) +class Vector3Input(Input): + value: Tuple[float, float, float] + min: Optional[Tuple[float, float, float]] + max: Optional[Tuple[float, float, float]] + step: float + precision: int + + +@dataclass(kw_only=True) +class Dropdown(Input): + options: Tuple[str, ...] + value: Optional[str] = None + + +# Create handles for each component. +# This is a workaround for Python's lack of support for +# type intersection. See: +# https://github.com/python/typing/issues/18 + +class ButtonHandle(ComponentHandle[Button], Button): + pass + + +class TextInputHandle(ComponentHandle[TextInput], TextInput): + pass + + +class NumberInputHandle(ComponentHandle[NumberInput], NumberInput): + pass + + +class SliderHandle(ComponentHandle[Slider], Slider): + pass + + +class CheckboxHandle(ComponentHandle[Checkbox], Checkbox): + pass + + +class RgbInputHandle(ComponentHandle[RgbInput], RgbInput): + pass + + +class RgbaInputHandle(ComponentHandle[RgbaInput], RgbaInput): + pass + + +class FolderHandle(ComponentHandle[Folder], Folder): + pass + + +class MarkdownHandle(ComponentHandle[Markdown], Markdown): + pass + + +class TabGroupHandle(ComponentHandle[TabGroup], TabGroup): + pass + + +class ModalHandle(ComponentHandle[Modal], Modal): + pass + + +class Vector2InputHandle(ComponentHandle[Vector2Input], Vector2Input): + pass + + +class Vector3InputHandle(ComponentHandle[Vector3Input], Vector3Input): + pass + + +class DropdownHandle(ComponentHandle[Dropdown], Dropdown): + pass + + +# This could also be done with typing, but not at the moment: +# https://peps.python.org/pep-0612/#concatenating-keyword-parameters +# For now we have to copy the signature manually. +@dataclass +class _AddComponentProtocol(Protocol): + order: Optional[float] = field(default=None, kw_only=True) + + @staticmethod + def split_kwargs(kwargs): + kwargs = kwargs.copy() + main_kwargs = {} + for k in kwargs: + if k in vars(_AddComponentProtocol): + main_kwargs[k] = kwargs.pop(k) + return main_kwargs, kwargs + +@dataclass +class _AddButtonProtocol(_AddComponentProtocol, Button): + pass + + +@dataclass +class _AddTextInputProtocol(_AddComponentProtocol, TextInput): + pass + +@dataclass +class _AddNumberInputProtocol(_AddComponentProtocol, NumberInput): + pass + +@dataclass +class _AddSliderProtocol(_AddComponentProtocol, Slider): + pass + + +@dataclass +class _AddCheckboxProtocol(_AddComponentProtocol, Checkbox): + pass + + +@dataclass +class _AddRgbInputProtocol(_AddComponentProtocol, RgbInput): + pass + + +@dataclass +class _AddRgbaInputProtocol(_AddComponentProtocol, RgbaInput): + pass + + +@dataclass +class _AddFolderProtocol(_AddComponentProtocol, Folder): + pass + + +@dataclass +class _AddMarkdownProtocol(_AddComponentProtocol, Markdown): + pass + + +@dataclass +class _AddTabGroupProtocol(_AddComponentProtocol, TabGroup): + pass + + +@dataclass +class _AddModalProtocol(_AddComponentProtocol, Modal): + pass + + +@dataclass +class _AddVector2InputProtocol(_AddComponentProtocol, Vector2Input): + pass + + +@dataclass +class _AddVector3InputProtocol(_AddComponentProtocol, Vector3Input): + pass + + +@dataclass +class _AddDropdownProtocol(_AddComponentProtocol, Dropdown): + pass + + +class GuiApiMixin: + @copy_signature(_AddButtonProtocol) + def add_gui_button(self, *args, **kwargs) -> ButtonHandle: + main_kwargs, kwargs = _AddComponentProtocol.split_kwargs(kwargs) + self.gui_add_component(Button(*args, **kwargs), order=main_kwargs) + + @copy_signature(_AddTextInputProtocol) + def gui_add_text_input(self, *args, **kwargs) -> TextInputHandle: + main_kwargs, kwargs = _AddComponentProtocol.split_kwargs(kwargs) + self.gui_add_component(TextInput(*args, **kwargs), order=main_kwargs) + + @copy_signature(_AddNumberInputProtocol) + def add_gui_number(self, *args, **kwargs) -> NumberInputHandle: + main_kwargs, kwargs = _AddComponentProtocol.split_kwargs(kwargs) + self.gui_add_component(NumberInput(*args, **kwargs), order=main_kwargs) + + @copy_signature(_AddModalProtocol) + def add_gui_modal(self, *args, **kwargs) -> ModalHandle: + main_kwargs, kwargs = _AddComponentProtocol.split_kwargs(kwargs) + self.gui_add_component(Modal(*args, **kwargs), order=main_kwargs) + + @copy_signature(_AddSliderProtocol) + def add_gui_slider(self, *args, **kwargs) -> SliderHandle: + main_kwargs, kwargs = _AddComponentProtocol.split_kwargs(kwargs) + self.gui_add_component(Slider(*args, **kwargs), order=main_kwargs) + + + @copy_signature(_AddCheckboxProtocol) + def add_gui_checkbox(self, *args, **kwargs) -> CheckboxHandle: + main_kwargs, kwargs = _AddComponentProtocol.split_kwargs(kwargs) + self.gui_add_component(Checkbox(*args, **kwargs), order=main_kwargs) + + @copy_signature(_AddRgbInputProtocol) + def add_gui_rgb(self, *args, **kwargs) -> RgbInputHandle: + main_kwargs, kwargs = _AddComponentProtocol.split_kwargs(kwargs) + self.gui_add_component(RgbInput(*args, **kwargs), order=main_kwargs) + + @copy_signature(_AddRgbaInputProtocol) + def add_gui_rgba(self, *args, **kwargs) -> RgbaInputHandle: + main_kwargs, kwargs = _AddComponentProtocol.split_kwargs(kwargs) + self.gui_add_component(RgbaInput(*args, **kwargs), order=main_kwargs) + + @copy_signature(_AddFolderProtocol) + def add_gui_folder(self, *args, **kwargs) -> FolderHandle: + main_kwargs, kwargs = _AddComponentProtocol.split_kwargs(kwargs) + self.gui_add_component(Folder(*args, **kwargs), order=main_kwargs) + + @copy_signature(_AddMarkdownProtocol) + def add_gui_markdown(self, *args, **kwargs) -> MarkdownHandle: + main_kwargs, kwargs = _AddComponentProtocol.split_kwargs(kwargs) + self.gui_add_component(Markdown(*args, **kwargs), order=main_kwargs) + + + @copy_signature(_AddTabGroupProtocol) + def add_gui_tab_group(self, *args, **kwargs) -> TabGroupHandle: + main_kwargs, kwargs = _AddComponentProtocol.split_kwargs(kwargs) + self.gui_add_component(TabGroup(*args, **kwargs), order=main_kwargs) + + + @copy_signature(_AddVector2InputProtocol) + def add_gui_vector2(self, *args, **kwargs) -> Vector2InputHandle: + main_kwargs, kwargs = _AddComponentProtocol.split_kwargs(kwargs) + self.gui_add_component(Vector2Input(*args, **kwargs), order=main_kwargs) + + + @copy_signature(_AddVector3InputProtocol) + def add_gui_vector3(self, *args, **kwargs) -> Vector3InputHandle: + main_kwargs, kwargs = _AddComponentProtocol.split_kwargs(kwargs) + self.gui_add_component(Vector3Input(*args, **kwargs), order=main_kwargs) + + + @copy_signature(_AddDropdownProtocol) + def add_gui_dropdown(self, *args, **kwargs) -> DropdownHandle: + main_kwargs, kwargs = _AddComponentProtocol.split_kwargs(kwargs) + self.gui_add_component(Dropdown(*args, **kwargs), order=main_kwargs) + + + def gui_add_component(self, component: Any, order: Optional[float] = None): + raise NotImplementedError() + + +Component = Union[ + Button, + TextInput, + NumberInput, + Slider, + Checkbox, + RgbInput, + RgbaInput, + Folder, + Markdown, + TabGroup, + Modal, + Vector2Input, + Vector3Input, + Dropdown, +] \ No newline at end of file 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..dce666aeb 100644 --- a/src/viser/client/src/ControlPanel/Generated.tsx +++ b/src/viser/client/src/ControlPanel/Generated.tsx @@ -95,31 +95,9 @@ 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} - - - ); - } const messageSender = makeThrottledMessageSender(viewer.websocketRef, 50); function updateValue(value: any) { @@ -149,188 +127,6 @@ function GeneratedInput({ 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 "GuiAddRgbMessage": - input = ( - updateValue(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)" }, - }} - /> - ); - break; - case "GuiAddRgbaMessage": - input = ( - updateValue(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" } }} - /> - ); - break; case "GuiAddButtonGroupMessage": input = ( @@ -447,50 +208,6 @@ function GeneratedInput({ ); } - - if (conf.hint !== null) - input = // We need to add for inputs that we can't assign refs to. - ( - - - {input} - - - ); - - if (labeled) - input = ( - - ); - - return ( - - {input} - - ); } function GeneratedFolder({ @@ -560,49 +277,6 @@ function GeneratedFolder({ ); } -function GeneratedTabGroup({ conf }: { conf: GuiAddTabGroupMessage }) { - const [tabState, setTabState] = React.useState("0"); - 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) => ( - - - - ))} - - ); -} function VectorInput( props: @@ -703,36 +377,3 @@ function LabeledInput(props: { ); } - -// Color conversion helpers. - -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}`; -} - -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]; -} -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}`; -} - -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]; -} diff --git a/src/viser/client/src/ControlPanel/GuiState.tsx b/src/viser/client/src/ControlPanel/GuiState.tsx index 2efe5c73b..2a167338d 100644 --- a/src/viser/client/src/ControlPanel/GuiState.tsx +++ b/src/viser/client/src/ControlPanel/GuiState.tsx @@ -7,25 +7,16 @@ import { immer } from "zustand/middleware/immer"; import { ViewerContext } from "../App"; import { MantineThemeOverride } from "@mantine/core"; -export type GuiConfig = - | Messages.GuiAddButtonMessage - | Messages.GuiAddCheckboxMessage - | Messages.GuiAddDropdownMessage - | Messages.GuiAddFolderMessage - | Messages.GuiAddTabGroupMessage - | Messages.GuiAddNumberMessage - | Messages.GuiAddRgbMessage - | Messages.GuiAddRgbaMessage - | Messages.GuiAddSliderMessage - | Messages.GuiAddButtonGroupMessage - | Messages.GuiAddTextMessage - | Messages.GuiAddVector2Message - | Messages.GuiAddVector3Message - | Messages.GuiAddMarkdownMessage; - -export function isGuiConfig(message: Messages.Message): message is GuiConfig { - return message.type.startsWith("GuiAdd"); -} + +export type GuiGenerateContextProps> = { + update: (changes: Partial) => void; + renderContainer: (containerId: string, incrementFolderDepth?: boolean) => React.ReactNode; + folderDepth: number; +}; +export const GuiGenerateContext = React.createContext | null>(null); + + +export type GuiProps = Omit & GuiGenerateContextProps> & { id: string }; interface GuiState { theme: Messages.ThemeConfigurationMessage; @@ -38,7 +29,8 @@ interface GuiState { }; modals: Messages.GuiModalMessage[]; guiOrderFromId: { [id: string]: number }; - guiConfigFromId: { [id: string]: GuiConfig }; + guiConfigFromId: { [id: string]: Messages.AllComponentProps & { container_id: string } }; + guiCallbackFromId: { [id: string]: { [callback: string]: (value: any) => void } }; guiValueFromId: { [id: string]: any }; guiAttributeFromId: { [id: string]: { visible?: boolean; disabled?: boolean } | undefined; @@ -47,14 +39,14 @@ interface GuiState { interface GuiActions { setTheme: (theme: Messages.ThemeConfigurationMessage) => void; - addGui: (config: GuiConfig) => void; - addModal: (config: Messages.GuiModalMessage) => void; - removeModal: (id: string) => void; + addGui: (config: Messages.AllComponentProps & { id: string, order: number, container_id: string }) => void; setGuiValue: (id: string, value: any) => void; setGuiVisible: (id: string, visible: boolean) => void; setGuiDisabled: (id: string, visible: boolean) => void; removeGui: (id: string) => void; resetGui: () => void; + + dispatchCallback: (id: string, callback: string, value: any) => void; } const cleanGuiState: GuiState = { @@ -77,6 +69,7 @@ const cleanGuiState: GuiState = { guiConfigFromId: {}, guiValueFromId: {}, guiAttributeFromId: {}, + guiCallbackFromId: {}, }; export function computeRelativeLuminance(color: string) { @@ -101,20 +94,22 @@ export function useGuiState(initialServer: string) { set((state) => { state.theme = theme; }), - addGui: (guiConfig) => + addGui: ({id, order, ...props }: Messages.AllComponentProps & { + id: string, + order: number, + container_id: string }) => set((state) => { - state.guiOrderFromId[guiConfig.id] = guiConfig.order; - state.guiConfigFromId[guiConfig.id] = guiConfig; - if (!(guiConfig.container_id in state.guiIdSetFromContainerId)) { - state.guiIdSetFromContainerId[guiConfig.container_id] = {}; + if (props.type == "Modal") { + state.modals.push(modalConfig); + } else { + state.guiOrderFromId[id] = order; + state.guiConfigFromId[id] = props; + state.guiCallbackFromId[id] = {}; + if (!(props.container_id in state.guiIdSetFromContainerId)) { + state.guiIdSetFromContainerId[props.container_id] = {}; + } + state.guiIdSetFromContainerId[props.container_id]![id] = true; } - state.guiIdSetFromContainerId[guiConfig.container_id]![ - guiConfig.id - ] = true; - }), - addModal: (modalConfig) => - set((state) => { - state.modals.push(modalConfig); }), removeModal: (id) => set((state) => { @@ -156,6 +151,12 @@ export function useGuiState(initialServer: string) { state.guiValueFromId = {}; state.guiAttributeFromId = {}; }), + dispatchCallback: (id, callback, value) => + set((state) => { + if (id in state.guiCallbackFromId && callback in state.guiCallbackFromId[id]) { + state.guiCallbackFromId[id][callback](value) + } + }), })), ), )[0]; diff --git a/src/viser/client/src/GeneratedComponent.tsx b/src/viser/client/src/GeneratedComponent.tsx new file mode 100644 index 000000000..ed0cffc35 --- /dev/null +++ b/src/viser/client/src/GeneratedComponent.tsx @@ -0,0 +1,73 @@ +// AUTOMATICALLY GENERATED message interfaces, from Python dataclass definitions. +// This file should not be manually modified. +import { + AllComponentProps, + ButtonProps, + TextInputProps, + NumberInputProps, + SliderProps, + CheckboxProps, + RgbInputProps, + RgbaInputProps, + FolderProps, + MarkdownProps, + TabGroupProps, + ModalProps, + Vector2InputProps, + Vector3InputProps, + DropdownProps, +} from "./WebsocketMessages"; +import Button from "./components/Button"; +import TextInput from "./components/TextInput"; +import NumberInput from "./components/NumberInput"; +import Slider from "./components/Slider"; +import Checkbox from "./components/Checkbox"; +import RgbInput from "./components/RgbInput"; +import RgbaInput from "./components/RgbaInput"; +import Folder from "./components/Folder"; +import Markdown from "./components/Markdown"; +import TabGroup from "./components/TabGroup"; +import Modal from "./components/Modal"; +import Vector2Input from "./components/Vector2Input"; +import Vector3Input from "./components/Vector3Input"; +import Dropdown from "./components/Dropdown"; +import { GuiProps, GuiGenerateContextProps } from "./ControlPanel/GuiState"; + + +export type GeneratedComponentProps = T & GuiGenerateContextProps> & { id: string }; + + +export default function GeneratedComponent({ type, ...props }: AllComponentProps) { + switch (type) { + case "Button": + return + +} \ No newline at end of file diff --git a/src/viser/client/src/components/Checkbox.tsx b/src/viser/client/src/components/Checkbox.tsx new file mode 100644 index 000000000..fb9acf836 --- /dev/null +++ b/src/viser/client/src/components/Checkbox.tsx @@ -0,0 +1,56 @@ +import { Box, Tooltip } from '@mantine/core'; +import { WrapInputDefault } from './utils'; +import { Checkbox } from '@mantine/core'; +import { CheckboxProps } from '../WebsocketMessages'; +import { GuiProps } from '../ControlPanel/GuiState'; +import { computeRelativeLuminance } from "../ControlPanel/GuiState"; +import { useMantineTheme } from '@mantine/core'; + + +export default function CheckboxComponent({ id, value, disabled, update, hint, ...props}: GuiProps) { + const theme = useMantineTheme(); + let inputColor = + computeRelativeLuminance(theme.fn.primaryColor()) > 50.0 + ? theme.colors.gray[9] + : theme.white; + let input = (<> + update({ value: value.target.checked })} + disabled={disabled} + styles={{ + icon: { + color: inputColor + " !important", + }, + }} + /> + ); + if (hint !== null) + input = // We need to add for inputs that we can't assign refs to. + ( + + + {input} + + + ); + return {input}; +} \ No newline at end of file diff --git a/src/viser/client/src/components/Dropdown.tsx b/src/viser/client/src/components/Dropdown.tsx new file mode 100644 index 000000000..fe2b05ab9 --- /dev/null +++ b/src/viser/client/src/components/Dropdown.tsx @@ -0,0 +1,33 @@ +import { Select } from "@mantine/core"; +import { WrapInputDefault } from "./utils"; +import { GuiProps } from "../ControlPanel/GuiState"; +import { DropdownProps } from "../WebsocketMessages"; + + +export default function DropdownComponent({ value, update, ...conf }: GuiProps) { + return ( + modal zIndex. - // On edge cases: it seems like existing dropdowns are always closed when a new modal is opened. - zIndex={1000} - withinPortal={true} - /> - ); - break; - case "GuiAddButtonGroupMessage": - input = ( - - {conf.options.map((option, index) => ( - - ))} - - ); - } -} - -function GeneratedFolder({ - conf, - folderDepth, - viewer, -}: { - conf: GuiAddFolderMessage; - folderDepth: number; - viewer: ViewerContextContents; -}) { - 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} - - - - - - - - - - ); -} - - -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. */ -function LabeledInput(props: { - id: string; - label: string; - input: React.ReactNode; - folderDepth: number; -}) { - return ( - - - - - - - {props.input} - - ); } diff --git a/src/viser/client/src/ControlPanel/GuiState.tsx b/src/viser/client/src/ControlPanel/GuiState.tsx index 2a167338d..4d1635376 100644 --- a/src/viser/client/src/ControlPanel/GuiState.tsx +++ b/src/viser/client/src/ControlPanel/GuiState.tsx @@ -8,15 +8,25 @@ import { ViewerContext } from "../App"; import { MantineThemeOverride } from "@mantine/core"; -export type GuiGenerateContextProps> = { - update: (changes: Partial) => void; +export type GuiGenerateContextProps = T extends Omit ? { + id: string, + update: (changes: Partial) => T; renderContainer: (containerId: string, incrementFolderDepth?: boolean) => React.ReactNode; folderDepth: number; -}; +} : never; export const GuiGenerateContext = React.createContext | null>(null); +export type GuiProps = T extends Messages.AllComponentProps ? Omit & { id: string }: never; +export function useGuiComponentContext() { + return React.useContext(GuiGenerateContext)! as GuiGenerateContextProps>; +} +export type SetProps = T extends Messages.AllComponentProps ? (id: string, callback: (props: T) => Partial) => void : never; - -export type GuiProps = Omit & GuiGenerateContextProps> & { id: string }; +export interface GuiAttributes { + containerId: string; + order: number; + visible?: boolean; + disabled?: boolean; +} interface GuiState { theme: Messages.ThemeConfigurationMessage; @@ -28,12 +38,12 @@ interface GuiState { [containerId: string]: { [id: string]: true } | undefined; }; modals: Messages.GuiModalMessage[]; - guiOrderFromId: { [id: string]: number }; - guiConfigFromId: { [id: string]: Messages.AllComponentProps & { container_id: string } }; guiCallbackFromId: { [id: string]: { [callback: string]: (value: any) => void } }; - guiValueFromId: { [id: string]: any }; + guiPropsFromId: { [id: string]: Messages.AllComponentProps }; + setProps: SetProps; + setAttributes: (id: string, callback: (props: GuiAttributes) => GuiAttributes) => void; guiAttributeFromId: { - [id: string]: { visible?: boolean; disabled?: boolean } | undefined; + [id: string]: GuiAttributes | undefined; }; } @@ -65,9 +75,9 @@ const cleanGuiState: GuiState = { backgroundAvailable: false, guiIdSetFromContainerId: {}, modals: [], - guiOrderFromId: {}, - guiConfigFromId: {}, - guiValueFromId: {}, + setAttributes: () => {}, + setProps: () => {}, + guiPropsFromId: {}, guiAttributeFromId: {}, guiCallbackFromId: {}, }; @@ -115,24 +125,14 @@ export function useGuiState(initialServer: string) { set((state) => { state.modals = state.modals.filter((m) => m.id !== id); }), - setGuiValue: (id, value) => - set((state) => { - state.guiValueFromId[id] = value; - }), - setGuiVisible: (id, visible) => - set((state) => { - state.guiAttributeFromId[id] = { - ...state.guiAttributeFromId[id], - visible: visible, - }; - }), - setGuiDisabled: (id, disabled) => - set((state) => { - state.guiAttributeFromId[id] = { - ...state.guiAttributeFromId[id], - disabled: disabled, - }; - }), + setProps: (id, callback) => + set((state) => { + state.guiPropsFromId[id] = {...state.guiPropsFromId[id], ...callback(state.guiPropsFromId[id])}; + }), + setAttributes: (id, callback) => + set((state) => { + state.guiAttributeFromId[id] = callback(state.guiAttributeFromId[id]); + }), removeGui: (id) => set((state) => { const guiConfig = state.guiConfigFromId[id]; @@ -140,7 +140,7 @@ export function useGuiState(initialServer: string) { delete state.guiIdSetFromContainerId[guiConfig.container_id]![id]; delete state.guiOrderFromId[id]; delete state.guiConfigFromId[id]; - delete state.guiValueFromId[id]; + delete state.guiPropsFromId[id]; delete state.guiAttributeFromId[id]; }), resetGui: () => @@ -148,7 +148,7 @@ export function useGuiState(initialServer: string) { state.guiIdSetFromContainerId = {}; state.guiOrderFromId = {}; state.guiConfigFromId = {}; - state.guiValueFromId = {}; + state.guiPropsFromId = {}; state.guiAttributeFromId = {}; }), dispatchCallback: (id, callback, value) => diff --git a/src/viser/client/src/GeneratedComponent.tsx b/src/viser/client/src/GeneratedComponent.tsx index ed0cffc35..732e48104 100644 --- a/src/viser/client/src/GeneratedComponent.tsx +++ b/src/viser/client/src/GeneratedComponent.tsx @@ -31,43 +31,70 @@ import Modal from "./components/Modal"; import Vector2Input from "./components/Vector2Input"; import Vector3Input from "./components/Vector3Input"; import Dropdown from "./components/Dropdown"; -import { GuiProps, GuiGenerateContextProps } from "./ControlPanel/GuiState"; +import React, { useContext } from "react"; +import { GuiProps, GuiGenerateContextProps, GuiGenerateContext } from "./ControlPanel/GuiState"; -export type GeneratedComponentProps = T & GuiGenerateContextProps> & { id: string }; +type GenericGeneratedComponentProps = T extends AllComponentProps ? T & GuiGenerateContextProps> & { id: string }: never; +export type GeneratedComponentProps = GenericGeneratedComponentProps; -export default function GeneratedComponent({ type, ...props }: AllComponentProps) { +function assertUnknownComponent(x: string): never { + throw new Error(`Component type ${x} is not known.`); +} + + +export default function GeneratedComponent(props: AllComponentProps) { + const { type } = props; + const id = "test"; + const contextProps = useContext(GuiGenerateContext)! as GuiGenerateContextProps>; + let component = null; switch (type) { case "Button": - return