diff --git a/examples/14_markdown.py b/examples/14_markdown.py index 3579cbde5..4d85ac6cc 100644 --- a/examples/14_markdown.py +++ b/examples/14_markdown.py @@ -11,24 +11,32 @@ server = viser.ViserServer() server.world_axes.visible = True +markdown_counter = server.add_gui_markdown("Counter: 0") -@server.on_client_connect -def _(client: viser.ClientHandle) -> None: - here = Path(__file__).absolute().parent - markdown_source = (here / "./assets/mdx_example.mdx").read_text() - markdown = client.add_gui_markdown(markdown=markdown_source, image_root=here) +here = Path(__file__).absolute().parent - button = client.add_gui_button("Remove Markdown") - checkbox = client.add_gui_checkbox("Visibility", initial_value=True) +button = server.add_gui_button("Remove blurb") +checkbox = server.add_gui_checkbox("Visibility", initial_value=True) - @button.on_click - def _(_): - markdown.remove() +markdown_source = (here / "./assets/mdx_example.mdx").read_text() +markdown_blurb = server.add_gui_markdown( + content=markdown_source, + image_root=here, +) - @checkbox.on_update - def _(_): - markdown.visible = checkbox.value +@button.on_click +def _(_): + markdown_blurb.remove() + +@checkbox.on_update +def _(_): + markdown_blurb.visible = checkbox.value + + +counter = 0 while True: - time.sleep(10.0) + markdown_counter.content = f"Counter: {counter}" + counter += 1 + time.sleep(0.1) diff --git a/pyproject.toml b/pyproject.toml index b8450a711..ca29e0cc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ warn_unused_configs = true exclude="viser/client/.nodeenv" [tool.pyright] -exclude = ["./docs/**/*", "./examples/assets/**/*", "./viser/client/.nodeenv", "./build"] +exclude = ["./docs/**/*", "./examples/assets/**/*", "./src/viser/client/.nodeenv", "./build"] [tool.black] exclude = "viser/client/.nodeenv" diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index cacc3a027..039f5d127 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -6,10 +6,8 @@ import abc import dataclasses -import re import threading import time -import urllib.parse import warnings from pathlib import Path from typing import ( @@ -24,7 +22,6 @@ overload, ) -import imageio.v3 as iio import numpy as onp from typing_extensions import Literal, LiteralString @@ -47,7 +44,7 @@ ) from ._icons import base64_from_icon from ._icons_enum import Icon -from ._message_api import MessageApi, _encode_image_base64, cast_vector +from ._message_api import MessageApi, cast_vector if TYPE_CHECKING: from .infra import ClientId @@ -87,38 +84,6 @@ def _compute_precision_digits(x: float) -> int: return digits -def _get_data_url(url: str, image_root: Optional[Path]) -> str: - if not url.startswith("http") and not image_root: - warnings.warn( - "No `image_root` provided. All relative paths will be scoped to viser's installation path.", - stacklevel=2, - ) - if url.startswith("http"): - return url - if image_root is None: - image_root = Path(__file__).parent - try: - image = iio.imread(image_root / url) - data_uri = _encode_image_base64(image, "png") - url = urllib.parse.quote(f"{data_uri[1]}") - return f"data:{data_uri[0]};base64,{url}" - except (IOError, FileNotFoundError): - warnings.warn( - f"Failed to read image {url}, with image_root set to {image_root}.", - stacklevel=2, - ) - return url - - -def _parse_markdown(markdown: str, image_root: Optional[Path]) -> str: - markdown = re.sub( - r"\!\[([^]]*)\]\(([^]]*)\)", - lambda match: f"![{match.group(1)}]({_get_data_url(match.group(2), image_root)})", - markdown, - ) - return markdown - - @dataclasses.dataclass class _RootGuiContainer: _children: Dict[str, SupportsRemoveProtocol] @@ -277,30 +242,25 @@ def add_gui_tab_group( def add_gui_markdown( self, - markdown: str, + content: str, image_root: Optional[Path] = None, order: Optional[float] = None, ) -> GuiMarkdownHandle: """Add markdown to the GUI.""" - markdown = _parse_markdown(markdown, image_root) - markdown_id = _make_unique_id() - order = _apply_default_order(order) - self._get_api()._queue( - _messages.GuiAddMarkdownMessage( - order=order, - id=markdown_id, - markdown=markdown, - container_id=self._get_container_id(), - ) - ) - return GuiMarkdownHandle( + handle = GuiMarkdownHandle( _gui_api=self, - _id=markdown_id, + _id=_make_unique_id(), _visible=True, _container_id=self._get_container_id(), - _order=order, + _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, diff --git a/src/viser/_gui_handles.py b/src/viser/_gui_handles.py index c3bad52cf..50c5556b4 100644 --- a/src/viser/_gui_handles.py +++ b/src/viser/_gui_handles.py @@ -1,9 +1,13 @@ from __future__ import annotations import dataclasses +import re import threading import time +import urllib.parse import uuid +import warnings +from pathlib import Path from typing import ( TYPE_CHECKING, Callable, @@ -18,13 +22,16 @@ Union, ) +import imageio.v3 as iio import numpy as onp from typing_extensions import Protocol from ._icons import base64_from_icon from ._icons_enum import Icon +from ._message_api import _encode_image_base64 from ._messages import ( GuiAddDropdownMessage, + GuiAddMarkdownMessage, GuiAddTabGroupMessage, GuiCloseModalMessage, GuiRemoveMessage, @@ -493,6 +500,38 @@ def remove(self) -> None: child.remove() +def _get_data_url(url: str, image_root: Optional[Path]) -> str: + if not url.startswith("http") and not image_root: + warnings.warn( + "No `image_root` provided. All relative paths will be scoped to viser's installation path.", + stacklevel=2, + ) + if url.startswith("http"): + return url + if image_root is None: + image_root = Path(__file__).parent + try: + image = iio.imread(image_root / url) + data_uri = _encode_image_base64(image, "png") + url = urllib.parse.quote(f"{data_uri[1]}") + return f"data:{data_uri[0]};base64,{url}" + except (IOError, FileNotFoundError): + warnings.warn( + f"Failed to read image {url}, with image_root set to {image_root}.", + stacklevel=2, + ) + return url + + +def _parse_markdown(markdown: str, image_root: Optional[Path]) -> str: + markdown = re.sub( + r"\!\[([^]]*)\]\(([^]]*)\)", + lambda match: f"![{match.group(1)}]({_get_data_url(match.group(2), image_root)})", + markdown, + ) + return markdown + + @dataclasses.dataclass class GuiMarkdownHandle: """Use to remove markdown.""" @@ -502,6 +541,26 @@ class GuiMarkdownHandle: _visible: bool _container_id: str # Parent. _order: float + _image_root: Optional[Path] + _content: Optional[str] + + @property + def content(self) -> str: + """Current content of this markdown element. Synchronized automatically when assigned.""" + assert self._content is not None + return self._content + + @content.setter + def content(self, content: str) -> None: + self._content = content + self._gui_api._get_api()._queue( + GuiAddMarkdownMessage( + order=self._order, + id=self._id, + markdown=_parse_markdown(content, self._image_root), + container_id=self._container_id, + ) + ) @property def order(self) -> float: diff --git a/src/viser/client/src/Markdown.tsx b/src/viser/client/src/Markdown.tsx index 9d3ce865b..91826438b 100644 --- a/src/viser/client/src/Markdown.tsx +++ b/src/viser/client/src/Markdown.tsx @@ -177,7 +177,7 @@ export default function Markdown(props: { children?: string }) { } catch { setChild(Error Parsing Markdown...); } - }, []); + }, [props.children]); return child; }