diff --git a/examples/02_gui/02_layouts.py b/examples/02_gui/02_layouts.py index 57570be39..02680da24 100644 --- a/examples/02_gui/02_layouts.py +++ b/examples/02_gui/02_layouts.py @@ -5,6 +5,7 @@ **Features:** * :meth:`viser.GuiApi.add_folder` for grouping related controls +* :meth:`viser.GuiApi.add_row` for side-by-side controls * :meth:`viser.GuiApi.add_form` for groups that commit together on submit * :meth:`viser.GuiApi.add_tab_group` and :meth:`viser.GuiTabGroupHandle.add_tab` for tabbed interfaces * :meth:`viser.GuiApi.add_divider` for separating sections with a horizontal line @@ -48,8 +49,9 @@ def _(_) -> None: server.gui.add_divider() - show_axes = server.gui.add_checkbox("Show Coordinate Axes", initial_value=True) - server.gui.add_checkbox("Show Grid", initial_value=False) + with server.gui.add_row(): + show_axes = server.gui.add_checkbox("Show Coordinate Axes", initial_value=True) + server.gui.add_checkbox("Show Grid", initial_value=False) with server.gui.add_folder("Sphere"): sphere_radius = server.gui.add_slider( diff --git a/src/viser/__init__.py b/src/viser/__init__.py index d53193f4c..ca02943b9 100644 --- a/src/viser/__init__.py +++ b/src/viser/__init__.py @@ -20,6 +20,7 @@ from ._gui_handles import GuiPlotlyHandle as GuiPlotlyHandle from ._gui_handles import GuiRgbaHandle as GuiRgbaHandle from ._gui_handles import GuiRgbHandle as GuiRgbHandle +from ._gui_handles import GuiRowHandle as GuiRowHandle from ._gui_handles import GuiSliderHandle as GuiSliderHandle from ._gui_handles import GuiTabGroupHandle as GuiTabGroupHandle from ._gui_handles import GuiTabHandle as GuiTabHandle diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index 3c42424c8..8398d40e9 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -58,6 +58,7 @@ GuiProgressBarHandle, GuiRgbaHandle, GuiRgbHandle, + GuiRowHandle, GuiSliderHandle, GuiTabGroupHandle, GuiTabHandle, @@ -709,6 +710,51 @@ def add_folder( ) ) + @deprecated_positional_shim + def add_row( + self, + *, + order: float | None = None, + visible: bool = True, + ) -> GuiRowHandle: + """Add a row, and return a handle that can be used to populate it. + + GUI elements added inside the returned context are laid out + horizontally with equal-width slots, matching the built-in controls for + Save Canvas / Reset View in configuration and diagnostics panel. + + Args: + order: Optional ordering, smallest values will be displayed first. + visible: Whether the component is visible. + + Returns: + A handle that can be used as a context to populate the row. + """ + row_container_id = _make_uuid() + order = _apply_default_order(order) + props = _messages.GuiFolderProps( + order=order, + label=None, + expand_by_default=True, + visible=visible, + ) + self._websock_interface.queue_message( + _messages.GuiRowMessage( + uuid=row_container_id, + container_uuid=self._get_container_uuid(), + props=props, + ) + ) + return GuiRowHandle( + _GuiHandleState( + row_container_id, + self, + None, + props=props, + parent_container_id=self._get_container_uuid(), + ) + ) + @deprecated_positional_shim def add_form( self, diff --git a/src/viser/_gui_handles.py b/src/viser/_gui_handles.py index b9de5df03..38caba104 100644 --- a/src/viser/_gui_handles.py +++ b/src/viser/_gui_handles.py @@ -792,6 +792,10 @@ def remove(self) -> None: gui_api._container_handle_from_uuid.pop(self._impl.uuid) +class GuiRowHandle(GuiFolderHandle): + """Use as a context to place GUI elements side by side.""" + + class GuiFormHandle(GuiFolderHandle): """Use as a context to place GUI elements into a form. diff --git a/src/viser/_messages.py b/src/viser/_messages.py index 511d60c4f..cb69261f7 100644 --- a/src/viser/_messages.py +++ b/src/viser/_messages.py @@ -1498,6 +1498,14 @@ class GuiFolderMessage(_CreateGuiComponentMessage): props: GuiFolderProps +@dataclasses.dataclass +class GuiRowMessage(_CreateGuiComponentMessage): + """A row lays out its children horizontally.""" + + container_uuid: str + props: GuiFolderProps + + @dataclasses.dataclass class GuiFormMessage(_CreateGuiComponentMessage): """A form is a folder whose children's values can be committed together. diff --git a/src/viser/client/src/ControlPanel/Generated.tsx b/src/viser/client/src/ControlPanel/Generated.tsx index 9ffff97f3..5faa43402 100644 --- a/src/viser/client/src/ControlPanel/Generated.tsx +++ b/src/viser/client/src/ControlPanel/Generated.tsx @@ -1,9 +1,10 @@ import { ViewerContext } from "../ViewerContext"; +import { GuiRowMessage } from "../WebsocketMessages"; import { useThrottledMessageSender } from "../WebsocketUtils"; import { GuiComponentContext } from "./GuiComponentContext"; import { shallowObjectKeysEqual } from "../utils/shallowObjectKeysEqual"; -import { Box } from "@mantine/core"; +import { Box, Group } from "@mantine/core"; import React from "react"; import ButtonComponent from "../components/Button"; import SliderComponent from "../components/Slider"; @@ -64,11 +65,13 @@ export default function GeneratedGuiContainer({ function GuiContainer({ containerUuid, unwrapped = false, + layout = "stack", }: { containerUuid: string; /** If true, don't wrap children in a padded Box. Used by label=null * folders and forms, which should be transparent for layout purposes. */ unwrapped?: boolean; + layout?: "stack" | "row"; }) { const viewer = React.useContext(ViewerContext)!; @@ -97,6 +100,9 @@ function GuiContainer({ nextGuiUuid={guiUuidOrderPairArray[index + 1]?.uuid ?? null} /> )); + if (layout === "row") { + return {children}; + } if (unwrapped) { return <>{children}; } @@ -117,6 +123,8 @@ function GeneratedInput(props: { switch (conf.type) { case "GuiFolderMessage": return ; + case "GuiRowMessage": + return ; case "GuiFormMessage": return ; case "GuiTabGroupMessage": @@ -166,6 +174,12 @@ function GeneratedInput(props: { } } +function RowComponent({ uuid, props: { visible } }: GuiRowMessage) { + const guiContext = React.useContext(GuiComponentContext)!; + if (!visible) return null; + return ; +} + function assertNeverType(x: never): never { throw new Error("Unexpected object: " + (x as any).type); } diff --git a/src/viser/client/src/ControlPanel/GuiComponentContext.ts b/src/viser/client/src/ControlPanel/GuiComponentContext.ts index 02ac7560b..59f31d69b 100644 --- a/src/viser/client/src/ControlPanel/GuiComponentContext.ts +++ b/src/viser/client/src/ControlPanel/GuiComponentContext.ts @@ -5,7 +5,11 @@ interface GuiComponentContext { folderDepth: number; setValue: (id: string, value: NonNullable) => void; messageSender: (message: Messages.Message) => void; - GuiContainer: React.FC<{ containerUuid: string; unwrapped?: boolean }>; + GuiContainer: React.FC<{ + containerUuid: string; + unwrapped?: boolean; + layout?: "stack" | "row"; + }>; } export const GuiComponentContext = React.createContext({ diff --git a/src/viser/client/src/VersionInfo.ts b/src/viser/client/src/VersionInfo.ts index c36a16a9a..85d8151e2 100644 --- a/src/viser/client/src/VersionInfo.ts +++ b/src/viser/client/src/VersionInfo.ts @@ -13,14 +13,14 @@ export const GITHUB_CONTRIBUTORS: Contributor[] = [ login: "brentyi", html_url: "https://github.com/brentyi", }, - { - login: "chungmin99", - html_url: "https://github.com/chungmin99", - }, { login: "kerrj", html_url: "https://github.com/kerrj", }, + { + login: "chungmin99", + html_url: "https://github.com/chungmin99", + }, { login: "tancik", html_url: "https://github.com/tancik", @@ -61,6 +61,10 @@ export const GITHUB_CONTRIBUTORS: Contributor[] = [ login: "ethanweber", html_url: "https://github.com/ethanweber", }, + { + login: "zoechann", + html_url: "https://github.com/zoechann", + }, { login: "zerolover", html_url: "https://github.com/zerolover", @@ -89,10 +93,6 @@ export const GITHUB_CONTRIBUTORS: Contributor[] = [ login: "AdamRashid96", html_url: "https://github.com/AdamRashid96", }, - { - login: "zoechann", - html_url: "https://github.com/zoechann", - }, { login: "slecleach", html_url: "https://github.com/slecleach", diff --git a/src/viser/client/src/WebsocketMessages.ts b/src/viser/client/src/WebsocketMessages.ts index aa52bab78..195455836 100644 --- a/src/viser/client/src/WebsocketMessages.ts +++ b/src/viser/client/src/WebsocketMessages.ts @@ -526,6 +526,21 @@ export interface GuiFolderMessage { expand_by_default: boolean; }; } +/** A row lays out its children horizontally. + * + * (automatically generated) + */ +export interface GuiRowMessage { + type: "GuiRowMessage"; + uuid: string; + container_uuid: string; + props: { + order: number; + label: string | null; + visible: boolean; + expand_by_default: boolean; + }; +} /** A form is a folder whose children's values can be committed together. * * Reuses ``GuiFolderProps`` because the visual shape is identical to a @@ -1954,6 +1969,7 @@ export type Message = | GaussianSplatsMessage | RemoveSceneNodeMessage | GuiFolderMessage + | GuiRowMessage | GuiFormMessage | GuiMarkdownMessage | GuiHtmlMessage @@ -2060,6 +2076,7 @@ export type SceneNodeMessage = | GaussianSplatsMessage; export type GuiComponentMessage = | GuiFolderMessage + | GuiRowMessage | GuiFormMessage | GuiMarkdownMessage | GuiHtmlMessage @@ -2119,6 +2136,7 @@ export function isSceneNodeMessage( } const typeSetGuiComponentMessage = new Set([ "GuiFolderMessage", + "GuiRowMessage", "GuiFormMessage", "GuiMarkdownMessage", "GuiHtmlMessage", diff --git a/tests/e2e/test_gui_controls.py b/tests/e2e/test_gui_controls.py index c06d58cbc..b928c1a90 100644 --- a/tests/e2e/test_gui_controls.py +++ b/tests/e2e/test_gui_controls.py @@ -175,6 +175,32 @@ def test_folder_collapse_toggle( expect(inner_label).to_be_visible(timeout=3_000) +def test_row_lays_out_children_side_by_side( + viser_server: viser.ViserServer, + viser_page: Page, +) -> None: + """A row should render child GUI handles side by side.""" + with viser_server.gui.add_row(): + viser_server.gui.add_button("Apply") + viser_server.gui.add_checkbox("Enabled", initial_value=True) + + button = viser_page.get_by_role("button", name="Apply") + checkbox = viser_page.get_by_role("checkbox", name="Enabled") + expect(button).to_be_visible(timeout=5_000) + expect(checkbox).to_be_visible(timeout=5_000) + + viser_page.wait_for_timeout(300) + button_box = button.bounding_box() + checkbox_box = checkbox.bounding_box() + assert button_box is not None + assert checkbox_box is not None + + assert abs(button_box["y"] - checkbox_box["y"]) < max( + button_box["height"], checkbox_box["height"] + ) + assert button_box["x"] < checkbox_box["x"] + + def test_multiple_gui_elements_order( viser_server: viser.ViserServer, viser_page: Page,