From 9f4165697eba3ed4c04c75bf61b007850f2b3eb2 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Wed, 7 Feb 2024 16:00:58 -0800 Subject: [PATCH 1/6] Multi-property GUI updates --- src/viser/_gui_api.py | 54 +++++++--------- src/viser/_gui_handles.py | 62 +++++++++---------- src/viser/_messages.py | 14 +++-- .../client/src/ControlPanel/Generated.tsx | 5 +- .../client/src/ControlPanel/GuiState.tsx | 15 ++++- src/viser/client/src/WebsocketInterface.tsx | 2 +- src/viser/client/src/WebsocketMessages.tsx | 3 +- src/viser/client/src/components/Button.tsx | 3 +- .../client/src/components/ButtonGroup.tsx | 3 +- src/viser/infra/_typescript_interface_gen.py | 10 +++ 10 files changed, 92 insertions(+), 79 deletions(-) diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index f3c68a4ae..9dabe99e9 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -128,36 +128,33 @@ def _handle_gui_updates( handle = self._gui_handle_from_id.get(message.id, None) if handle is None: return - - prop_name = message.prop_name - prop_value = message.prop_value - del message - handle_state = handle._impl - assert hasattr(handle_state, prop_name) - current_value = getattr(handle_state, prop_name) - - has_changed = current_value != prop_value - - if prop_name == "value": - # 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(prop_value) == len(handle_state.value) - prop_value = tuple( - type(handle_state.value[i])(prop_value[i]) - for i in range(len(prop_value)) - ) - else: - prop_value = handle_state.typ(prop_value) + + has_changed = False + for prop_name, prop_value in message.updates.items(): + assert hasattr(handle_state, prop_name) + current_value = getattr(handle_state, prop_name) + + if current_value != prop_value: + has_changed = True + setattr(handle_state, prop_name, prop_value) + + # Do some type casting. This is brittle, but necessary when we + # expect floats but the Javascript side gives us integers. + if prop_name == "value": + if handle_state.typ is tuple: + assert len(prop_value) == len(handle_state.value) + prop_value = tuple( + type(handle_state.value[i])(prop_value[i]) + for i in range(len(prop_value)) + ) + else: + prop_value = handle_state.typ(prop_value) # Only call update when value has actually changed. if not handle_state.is_button and not has_changed: return - # Update state. - setattr(handle_state, prop_name, prop_value) - # Trigger callbacks. for cb in handle_state.update_cb: from ._viser import ClientHandle, ViserServer @@ -174,7 +171,7 @@ def _handle_gui_updates( cb(GuiEvent(client, client_id, handle)) if handle_state.sync_cb is not None: - handle_state.sync_cb(client_id, prop_name, prop_value) + handle_state.sync_cb(client_id, message.updates) def _get_container_id(self) -> str: """Get container ID associated with the current thread.""" @@ -1080,7 +1077,6 @@ def _create_gui_input( typ=type(value), gui_api=self, value=value, - initial_value=value, update_timestamp=time.time(), container_id=self._get_container_id(), update_cb=[], @@ -1098,11 +1094,9 @@ def _create_gui_input( if not is_button: def sync_other_clients( - client_id: ClientId, prop_name: str, prop_value: Any + client_id: ClientId, updates: Dict[str, Any] ) -> None: - message = _messages.GuiUpdateMessage( - handle_state.id, prop_name, prop_value - ) + message = _messages.GuiUpdateMessage(handle_state.id, updates) message.excluded_self_client = client_id self._get_api()._queue(message) diff --git a/src/viser/_gui_handles.py b/src/viser/_gui_handles.py index 1e22ea94c..7be24fd05 100644 --- a/src/viser/_gui_handles.py +++ b/src/viser/_gui_handles.py @@ -29,12 +29,7 @@ from ._icons import base64_from_icon from ._icons_enum import IconName from ._message_api import _encode_image_base64 -from ._messages import ( - GuiCloseModalMessage, - GuiRemoveMessage, - GuiUpdateMessage, - Message, -) +from ._messages import GuiCloseModalMessage, GuiRemoveMessage, GuiUpdateMessage, Message from .infra import ClientId if TYPE_CHECKING: @@ -81,7 +76,7 @@ class _GuiHandleState(Generic[T]): is_button: bool """Indicates a button element, which requires special handling.""" - sync_cb: Optional[Callable[[ClientId, str, Any], None]] + sync_cb: Optional[Callable[[ClientId, Dict[str, Any]], None]] """Callback for synchronizing inputs across clients.""" disabled: bool @@ -89,7 +84,6 @@ class _GuiHandleState(Generic[T]): order: float id: str - initial_value: T hint: Optional[str] message_type: Type[Message] @@ -136,7 +130,7 @@ def value(self, value: T | onp.ndarray) -> None: # Send to client, except for buttons. if not self._impl.is_button: self._impl.gui_api._get_api()._queue( - GuiUpdateMessage(self._impl.id, "value", value) + GuiUpdateMessage(self._impl.id, {"value": value}) ) # Set internal state. We automatically convert numpy arrays to the expected @@ -175,7 +169,7 @@ def disabled(self, disabled: bool) -> None: return self._impl.gui_api._get_api()._queue( - GuiUpdateMessage(self._impl.id, "disabled", disabled) + GuiUpdateMessage(self._impl.id, {"disabled": disabled}) ) self._impl.disabled = disabled @@ -191,7 +185,7 @@ def visible(self, visible: bool) -> None: return self._impl.gui_api._get_api()._queue( - GuiUpdateMessage(self._impl.id, "visible", visible) + GuiUpdateMessage(self._impl.id, {"visible": visible}) ) self._impl.visible = visible @@ -307,15 +301,23 @@ def options(self) -> Tuple[StringType, ...]: @options.setter def options(self, options: Iterable[StringType]) -> None: self._impl_options = tuple(options) - if self._impl.initial_value not in self._impl_options: - self._impl.initial_value = self._impl_options[0] - self._impl.gui_api._get_api()._queue( - GuiUpdateMessage(self._impl.id, "options", self._impl_options) - ) - - if self.value not in self._impl_options: - self.value = self._impl_options[0] + need_to_overwrite_value = self.value not in self._impl_options + if need_to_overwrite_value: + self._impl.gui_api._get_api()._queue( + GuiUpdateMessage( + self._impl.id, + {"options": self._impl_options, "value": self._impl_options[0]}, + ) + ) + self._impl.value = self._impl_options[0] + else: + self._impl.gui_api._get_api()._queue( + GuiUpdateMessage( + self._impl.id, + {"options": self._impl_options}, + ) + ) @dataclasses.dataclass(frozen=True) @@ -355,19 +357,14 @@ def remove(self) -> None: def _sync_with_client(self) -> None: """Send messages for syncing tab state with the client.""" - self._gui_api._get_api()._queue( - GuiUpdateMessage(self._tab_group_id, "tab_labels", tuple(self._labels)) - ) - self._gui_api._get_api()._queue( - GuiUpdateMessage( - self._tab_group_id, "tab_icons_base64", tuple(self._icons_base64) - ) - ) self._gui_api._get_api()._queue( GuiUpdateMessage( self._tab_group_id, - "tab_container_ids", - tuple(tab._id for tab in self._tabs), + { + "tab_labels": tuple(self._labels), + "tab_icons_base64": tuple(self._icons_base64), + "tab_container_ids": tuple(tab._id for tab in self._tabs), + }, ) ) @@ -558,8 +555,7 @@ def content(self, content: str) -> None: self._gui_api._get_api()._queue( GuiUpdateMessage( self._id, - "markdown", - _parse_markdown(content, self._image_root), + {"markdown": _parse_markdown(content, self._image_root)}, ) ) @@ -579,7 +575,9 @@ def visible(self, visible: bool) -> None: if visible == self.visible: return - self._gui_api._get_api()._queue(GuiUpdateMessage(self._id, "visible", visible)) + self._gui_api._get_api()._queue( + GuiUpdateMessage(self._id, {"visible": visible}) + ) self._visible = visible def __post_init__(self) -> None: diff --git a/src/viser/_messages.py b/src/viser/_messages.py index 5b9566a87..7115cd259 100644 --- a/src/viser/_messages.py +++ b/src/viser/_messages.py @@ -4,7 +4,7 @@ from __future__ import annotations import dataclasses -from typing import Any, Callable, ClassVar, Optional, Tuple, Type, TypeVar, Union +from typing import Any, Callable, ClassVar, Dict, Optional, Tuple, Type, TypeVar, Union import numpy as onp import numpy.typing as onpt @@ -552,12 +552,18 @@ class GuiUpdateMessage(Message): """Sent client<->server when any property of a GUI component is changed.""" id: str - prop_name: str - prop_value: Any + updates: Dict[str, Any] + """Mapping from property name to new value.""" @override def redundancy_key(self) -> str: - return type(self).__name__ + "-" + self.id + "-" + self.prop_name + return ( + type(self).__name__ + + "-" + + self.id + + "-" + + ",".join(list(self.updates.keys())) + ) @dataclasses.dataclass diff --git a/src/viser/client/src/ControlPanel/Generated.tsx b/src/viser/client/src/ControlPanel/Generated.tsx index 5bb2bb02c..3051bd6d1 100644 --- a/src/viser/client/src/ControlPanel/Generated.tsx +++ b/src/viser/client/src/ControlPanel/Generated.tsx @@ -31,12 +31,11 @@ export default function GeneratedGuiContainer({ const messageSender = makeThrottledMessageSender(viewer.websocketRef, 50); function setValue(id: string, value: any) { - updateGuiProps(id, "value", value); + updateGuiProps(id, {value: value}); messageSender({ type: "GuiUpdateMessage", id: id, - prop_name: "value", - prop_value: value, + updates: { value: value }, }); } return ( diff --git a/src/viser/client/src/ControlPanel/GuiState.tsx b/src/viser/client/src/ControlPanel/GuiState.tsx index ea15f8c93..b8e244784 100644 --- a/src/viser/client/src/ControlPanel/GuiState.tsx +++ b/src/viser/client/src/ControlPanel/GuiState.tsx @@ -34,7 +34,7 @@ interface GuiActions { addGui: (config: GuiConfig) => void; addModal: (config: Messages.GuiModalMessage) => void; removeModal: (id: string) => void; - updateGuiProps: (id: string, prop_name: string, prop_value: any) => void; + updateGuiProps: (id: string, updates: { [key: string]: any }) => void; removeGui: (id: string) => void; resetGui: () => void; } @@ -121,16 +121,25 @@ export function useGuiState(initialServer: string) { state.guiOrderFromId = {}; state.guiConfigFromId = {}; }), - updateGuiProps: (id, name, value) => { + updateGuiProps: (id, updates) => { set((state) => { const config = state.guiConfigFromId[id]; if (config === undefined) { console.error("Tried to update non-existent component", id); return; } + + // Double-check that key exists. + Object.keys(updates).forEach((key) => { + if (!(key in config)) + console.error( + `Tried to update nonexistent property '${key}' of GUI element ${id}!`, + ); + }); + state.guiConfigFromId[id] = { ...config, - [name]: value, + ...updates, } as GuiConfig; }); }, diff --git a/src/viser/client/src/WebsocketInterface.tsx b/src/viser/client/src/WebsocketInterface.tsx index faece469a..0ed6f0e41 100644 --- a/src/viser/client/src/WebsocketInterface.tsx +++ b/src/viser/client/src/WebsocketInterface.tsx @@ -748,7 +748,7 @@ function useMessageHandler() { } // Update props of a GUI component case "GuiUpdateMessage": { - updateGuiProps(message.id, message.prop_name, message.prop_value); + updateGuiProps(message.id, message.updates); return; } // Remove a GUI input. diff --git a/src/viser/client/src/WebsocketMessages.tsx b/src/viser/client/src/WebsocketMessages.tsx index 0d0cd47df..87b46e4e4 100644 --- a/src/viser/client/src/WebsocketMessages.tsx +++ b/src/viser/client/src/WebsocketMessages.tsx @@ -622,8 +622,7 @@ export interface GuiRemoveMessage { export interface GuiUpdateMessage { type: "GuiUpdateMessage"; id: string; - prop_name: string; - prop_value: any; + updates: { [key: string]: any }; } /** Message from server->client to configure parts of the GUI. * diff --git a/src/viser/client/src/components/Button.tsx b/src/viser/client/src/components/Button.tsx index fb8803917..2e1336d62 100644 --- a/src/viser/client/src/components/Button.tsx +++ b/src/viser/client/src/components/Button.tsx @@ -32,8 +32,7 @@ export default function ButtonComponent({ messageSender({ type: "GuiUpdateMessage", id: id, - prop_name: "value", - prop_value: true, + updates: { value: true }, }) } style={{ height: "2.125em" }} diff --git a/src/viser/client/src/components/ButtonGroup.tsx b/src/viser/client/src/components/ButtonGroup.tsx index 1fc1795ab..d76f8539a 100644 --- a/src/viser/client/src/components/ButtonGroup.tsx +++ b/src/viser/client/src/components/ButtonGroup.tsx @@ -24,8 +24,7 @@ export default function ButtonGroupComponent({ messageSender({ type: "GuiUpdateMessage", id: id, - prop_name: "value", - prop_value: option, + updates: { value: option }, }) } style={{ flexGrow: 1, width: 0 }} diff --git a/src/viser/infra/_typescript_interface_gen.py b/src/viser/infra/_typescript_interface_gen.py index 3204d9a33..aa3929c23 100644 --- a/src/viser/infra/_typescript_interface_gen.py +++ b/src/viser/infra/_typescript_interface_gen.py @@ -56,6 +56,16 @@ def _get_ts_type(typ: Type[Any]) -> str: elif origin_typ is list: args = get_args(typ) return _get_ts_type(args[0]) + "[]" + elif origin_typ is dict: + args = get_args(typ) + assert len(args) == 2 + return ( + "{ [key: " + + _get_ts_type(args[0]) + + "]: " + + _get_ts_type(args[1]) + + " }" + ) elif is_typeddict(typ): hints = get_type_hints(typ) optional_keys = getattr(typ, "__optional_keys__", []) From 9c3ad2de92f4e17dc17e053cda8eb9d9d13ec0b2 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Wed, 7 Feb 2024 19:07:18 -0800 Subject: [PATCH 2/6] ruff --- src/viser/infra/_typescript_interface_gen.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/viser/infra/_typescript_interface_gen.py b/src/viser/infra/_typescript_interface_gen.py index aa3929c23..741796288 100644 --- a/src/viser/infra/_typescript_interface_gen.py +++ b/src/viser/infra/_typescript_interface_gen.py @@ -59,13 +59,7 @@ def _get_ts_type(typ: Type[Any]) -> str: elif origin_typ is dict: args = get_args(typ) assert len(args) == 2 - return ( - "{ [key: " - + _get_ts_type(args[0]) - + "]: " - + _get_ts_type(args[1]) - + " }" - ) + return "{ [key: " + _get_ts_type(args[0]) + "]: " + _get_ts_type(args[1]) + " }" elif is_typeddict(typ): hints = get_type_hints(typ) optional_keys = getattr(typ, "__optional_keys__", []) From 4191866dad626fb45904bba217ad50b93d6f0d71 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Wed, 7 Feb 2024 20:53:31 -0800 Subject: [PATCH 3/6] Fix value casting --- examples/02_gui.py | 1 + src/viser/_gui_api.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/examples/02_gui.py b/examples/02_gui.py index 7344323a4..8185f8616 100644 --- a/examples/02_gui.py +++ b/examples/02_gui.py @@ -100,6 +100,7 @@ def main() -> None: * color_coeffs[:, None] ).astype(onp.uint8), position=gui_vector2.value + (0,), + point_shape="circle", ) # We can use `.visible` and `.disabled` to toggle GUI elements. diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index 9dabe99e9..74cf2fafb 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -131,14 +131,11 @@ def _handle_gui_updates( handle_state = handle._impl has_changed = False + updates_cast = {} for prop_name, prop_value in message.updates.items(): assert hasattr(handle_state, prop_name) current_value = getattr(handle_state, prop_name) - if current_value != prop_value: - has_changed = True - setattr(handle_state, prop_name, prop_value) - # Do some type casting. This is brittle, but necessary when we # expect floats but the Javascript side gives us integers. if prop_name == "value": @@ -151,6 +148,14 @@ def _handle_gui_updates( else: prop_value = handle_state.typ(prop_value) + # Update handle property. + if current_value != prop_value: + has_changed = True + setattr(handle_state, prop_name, prop_value) + + # Save value, which might have been cast. + updates_cast[prop_name] = prop_value + # Only call update when value has actually changed. if not handle_state.is_button and not has_changed: return @@ -171,7 +176,7 @@ def _handle_gui_updates( cb(GuiEvent(client, client_id, handle)) if handle_state.sync_cb is not None: - handle_state.sync_cb(client_id, message.updates) + handle_state.sync_cb(client_id, updates_cast) def _get_container_id(self) -> str: """Get container ID associated with the current thread.""" From a901845af7797a6a5b063fd9b8feaf398fd03ce2 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Thu, 8 Feb 2024 00:37:34 -0800 Subject: [PATCH 4/6] Remove now-unused interface gen code --- src/viser/infra/_typescript_interface_gen.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/viser/infra/_typescript_interface_gen.py b/src/viser/infra/_typescript_interface_gen.py index 741796288..7b451ddd3 100644 --- a/src/viser/infra/_typescript_interface_gen.py +++ b/src/viser/infra/_typescript_interface_gen.py @@ -102,19 +102,11 @@ def generate_typescript_interfaces(message_cls: Type[Message]) -> str: for tag in getattr(cls, "_tags", []): tag_map[tag].append(cls.__name__) - get_ts_type = getattr(cls, "_get_ts_type", None) - if get_ts_type is not None: - assert callable(get_ts_type) - out_lines.append(get_ts_type()) - continue - 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: + if name in field_names: typ = _get_ts_type(typ) else: continue From 86a4c07261d06e4a69e0069ca6543e60043e560d Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Thu, 8 Feb 2024 00:46:02 -0800 Subject: [PATCH 5/6] Stronger client-side type for GuiUpdateMessage --- src/viser/_messages.py | 7 +++-- .../client/src/ControlPanel/Generated.tsx | 2 +- src/viser/client/src/WebsocketMessages.tsx | 2 +- src/viser/infra/__init__.py | 3 ++ src/viser/infra/_typescript_interface_gen.py | 30 +++++++++++++++++-- 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/viser/_messages.py b/src/viser/_messages.py index 7115cd259..452658695 100644 --- a/src/viser/_messages.py +++ b/src/viser/_messages.py @@ -8,7 +8,7 @@ import numpy as onp import numpy.typing as onpt -from typing_extensions import Literal, NotRequired, TypedDict, override +from typing_extensions import Annotated, Literal, NotRequired, TypedDict, override from . import infra, theme @@ -552,7 +552,10 @@ class GuiUpdateMessage(Message): """Sent client<->server when any property of a GUI component is changed.""" id: str - updates: Dict[str, Any] + updates: Annotated[ + Dict[str, Any], + infra.TypeScriptAnnotationOverride("Partial"), + ] """Mapping from property name to new value.""" @override diff --git a/src/viser/client/src/ControlPanel/Generated.tsx b/src/viser/client/src/ControlPanel/Generated.tsx index 3051bd6d1..b23e470e8 100644 --- a/src/viser/client/src/ControlPanel/Generated.tsx +++ b/src/viser/client/src/ControlPanel/Generated.tsx @@ -31,7 +31,7 @@ export default function GeneratedGuiContainer({ const messageSender = makeThrottledMessageSender(viewer.websocketRef, 50); function setValue(id: string, value: any) { - updateGuiProps(id, {value: value}); + updateGuiProps(id, { value: value }); messageSender({ type: "GuiUpdateMessage", id: id, diff --git a/src/viser/client/src/WebsocketMessages.tsx b/src/viser/client/src/WebsocketMessages.tsx index 87b46e4e4..08139e525 100644 --- a/src/viser/client/src/WebsocketMessages.tsx +++ b/src/viser/client/src/WebsocketMessages.tsx @@ -622,7 +622,7 @@ export interface GuiRemoveMessage { export interface GuiUpdateMessage { type: "GuiUpdateMessage"; id: string; - updates: { [key: string]: any }; + updates: Partial; } /** Message from server->client to configure parts of the GUI. * diff --git a/src/viser/infra/__init__.py b/src/viser/infra/__init__.py index fa67e909d..dc25d9a8c 100644 --- a/src/viser/infra/__init__.py +++ b/src/viser/infra/__init__.py @@ -16,6 +16,9 @@ from ._infra import MessageHandler as MessageHandler from ._infra import Server as Server from ._messages import Message as Message +from ._typescript_interface_gen import ( + TypeScriptAnnotationOverride as TypeScriptAnnotationOverride, +) from ._typescript_interface_gen import ( generate_typescript_interfaces as generate_typescript_interfaces, ) diff --git a/src/viser/infra/_typescript_interface_gen.py b/src/viser/infra/_typescript_interface_gen.py index 7b451ddd3..52d638280 100644 --- a/src/viser/infra/_typescript_interface_gen.py +++ b/src/viser/infra/_typescript_interface_gen.py @@ -1,9 +1,17 @@ import dataclasses from collections import defaultdict -from typing import Any, ClassVar, Type, Union, cast, get_type_hints +from typing import Any, Type, Union, cast import numpy as onp -from typing_extensions import Literal, NotRequired, get_args, get_origin, is_typeddict +from typing_extensions import ( + Annotated, + Literal, + NotRequired, + get_args, + get_origin, + get_type_hints, + is_typeddict, +) try: from typing import Literal as LiteralAlt @@ -29,6 +37,17 @@ def _get_ts_type(typ: Type[Any]) -> str: origin_typ = get_origin(typ) + # Look for TypeScriptAnnotationOverride in the annotations. + if origin_typ is Annotated: + args = get_args(typ) + for arg in args[1:]: + if isinstance(arg, TypeScriptAnnotationOverride): + return arg.annotation + + # If no override is found, just use the unwrapped type. + origin_typ = args[0] + + # Automatic Python => TypeScript conversion. if origin_typ is tuple: args = get_args(typ) if len(args) == 2 and args[1] == ...: @@ -82,6 +101,11 @@ def fmt(key): return _raw_type_mapping[typ] +@dataclasses.dataclass(frozen=True) +class TypeScriptAnnotationOverride: + annotation: str + + def generate_typescript_interfaces(message_cls: Type[Message]) -> str: """Generate TypeScript definitions for all subclasses of a base message class.""" out_lines = [] @@ -105,7 +129,7 @@ def generate_typescript_interfaces(message_cls: Type[Message]) -> str: 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(): + for name, typ in get_type_hints(cls, include_extras=True).items(): if name in field_names: typ = _get_ts_type(typ) else: From 94831c52073e0d3567083974ebfddc6404189303 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Thu, 8 Feb 2024 00:52:30 -0800 Subject: [PATCH 6/6] Docstring --- src/viser/infra/_typescript_interface_gen.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/viser/infra/_typescript_interface_gen.py b/src/viser/infra/_typescript_interface_gen.py index 52d638280..7cd324938 100644 --- a/src/viser/infra/_typescript_interface_gen.py +++ b/src/viser/infra/_typescript_interface_gen.py @@ -103,6 +103,9 @@ def fmt(key): @dataclasses.dataclass(frozen=True) class TypeScriptAnnotationOverride: + """Use with `typing.Annotated[]` to override the automatically-generated + TypeScript annotation corresponding to a dataclass field.""" + annotation: str