diff --git a/examples/08_smplx_visualizer.py b/examples/08_smplx_visualizer.py index cce06a480..769cdc86b 100644 --- a/examples/08_smplx_visualizer.py +++ b/examples/08_smplx_visualizer.py @@ -36,6 +36,7 @@ def main( ext: Literal["npz", "pkl"] = "npz", ) -> None: server = viser.ViserServer() + server.configure_theme(control_layout="collapsible", dark_mode=True) model = smplx.create( model_path=str(model_path), model_type=model_type, diff --git a/examples/13_theming.py b/examples/13_theming.py index 19ac08882..7229acc7c 100644 --- a/examples/13_theming.py +++ b/examples/13_theming.py @@ -1,6 +1,9 @@ +# mypy: disable-error-code="arg-type" +# +# Waiting on PEP 675 support in mypy. https://github.com/python/mypy/issues/12554 """Theming -Viser is adding support for theming. Work-in-progress. +Viser includes support for light theming. """ import time @@ -33,15 +36,34 @@ image_alt="NerfStudio Logo", href="https://docs.nerf.studio/", ) - -# image = None - titlebar_theme = TitlebarConfig(buttons=buttons, image=image) -server.configure_theme( - dark_mode=True, titlebar_content=titlebar_theme, control_layout="fixed" +server.add_gui_markdown( + "Viser includes support for light theming via the `.configure_theme()` method." +) + +# GUI elements for controllable values. +titlebar = server.add_gui_checkbox("Titlebar", initial_value=True) +dark_mode = server.add_gui_checkbox("Dark mode", initial_value=True) +control_layout = server.add_gui_dropdown( + "Control layout", ("floating", "fixed", "collapsible") ) -server.world_axes.visible = True +brand_color = server.add_gui_rgb("Brand color", (230, 180, 30)) +synchronize = server.add_gui_button("Apply theme") + + +@synchronize.on_click +def synchronize_theme(_) -> None: + server.configure_theme( + dark_mode=dark_mode.value, + titlebar_content=titlebar_theme if titlebar.value else None, + control_layout=control_layout.value, + brand_color=brand_color.value, + ) + server.world_axes.visible = True + + +synchronize_theme(synchronize) while True: time.sleep(10.0) diff --git a/examples/16_modal.py b/examples/16_modal.py index 80b0721ef..3789be43a 100644 --- a/examples/16_modal.py +++ b/examples/16_modal.py @@ -14,7 +14,9 @@ def main(): def _(client: viser.ClientHandle) -> None: with client.add_gui_modal("Modal example"): client.add_gui_markdown( - markdown="**The slider below determines how many modals will appear...**" + markdown=( + "**The slider below determines how many modals will appear...**" + ) ) gui_slider = client.add_gui_slider( diff --git a/pyproject.toml b/pyproject.toml index ddb587f60..145c685dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,7 @@ select = [ "PLW", # Pylint warnings. ] ignore = [ + "E741", # Ambiguous variable name. (l, O, or I) "E501", # Line too long. "F722", # Forward annotation false positive from jaxtyping. Should be caught by pyright. "F821", # Forward annotation false positive from jaxtyping. Should be caught by pyright. diff --git a/viser/_message_api.py b/viser/_message_api.py index 83ea1aa0a..d37a80d3a 100644 --- a/viser/_message_api.py +++ b/viser/_message_api.py @@ -9,6 +9,7 @@ import abc import base64 +import colorsys import io import threading import time @@ -45,6 +46,16 @@ P = ParamSpec("P") +def _hex_from_hls(h: float, l: float, s: float) -> str: + """Converts HLS values in [0.0, 1.0] to a hex-formatted string, eg 0xffffff.""" + return "#" + "".join( + [ + int(min(255, max(0, channel * 255.0)) + 0.5).to_bytes(1, "little").hex() + for channel in colorsys.hls_to_rgb(h, l, s) + ] + ) + + def _colors_to_uint8(colors: onp.ndarray) -> onpt.NDArray[onp.uint8]: """Convert intensity values to uint8. We assume the range [0,1] for floats, and [0,255] for integers.""" @@ -143,13 +154,51 @@ def configure_theme( titlebar_content: Optional[theme.TitlebarConfig] = None, control_layout: Literal["floating", "collapsible", "fixed"] = "floating", dark_mode: bool = False, + brand_color: Optional[Tuple[int, int, int]] = None, ) -> None: """Configure the viser front-end's visual appearance.""" + + colors_cast: Optional[ + Tuple[str, str, str, str, str, str, str, str, str, str] + ] = None + + if brand_color is not None: + assert len(brand_color) in (3, 10) + if len(brand_color) == 3: + assert all( + map(lambda val: isinstance(val, int), brand_color) + ), "All channels should be integers." + + # RGB => HLS. + h, l, s = colorsys.rgb_to_hls( + brand_color[0] / 255.0, + brand_color[1] / 255.0, + brand_color[2] / 255.0, + ) + + # Automatically generate a 10-color palette. + min_l = max(l - 0.08, 0.0) + max_l = min(0.8 + 0.5, 0.9) + l = max(min_l, min(max_l, l)) + + primary_index = 8 + ls = tuple( + onp.interp( + x=onp.arange(10), xp=(0, primary_index, 9), fp=(max_l, l, min_l) + ) + ) + colors_cast = tuple(_hex_from_hls(h, ls[i], s) for i in range(10)) # type: ignore + + assert colors_cast is None or all( + [isinstance(val, str) and val.startswith("#") for val in colors_cast] + ), "All string colors should be in hexadecimal + prefixed with #, eg #ffffff." + self._queue( _messages.ThemeConfigurationMessage( titlebar_content=titlebar_content, control_layout=control_layout, dark_mode=dark_mode, + colors=colors_cast, ), ) diff --git a/viser/_messages.py b/viser/_messages.py index fbfbec70e..05568bb68 100644 --- a/viser/_messages.py +++ b/viser/_messages.py @@ -451,4 +451,5 @@ class ThemeConfigurationMessage(Message): titlebar_content: Optional[theme.TitlebarConfig] control_layout: Literal["floating", "collapsible", "fixed"] + colors: Optional[Tuple[str, str, str, str, str, str, str, str, str, str]] dark_mode: bool diff --git a/viser/client/src/App.tsx b/viser/client/src/App.tsx index a4ae61bfc..4f7ec9086 100644 --- a/viser/client/src/App.tsx +++ b/viser/client/src/App.tsx @@ -24,20 +24,28 @@ import "./index.css"; import ControlPanel from "./ControlPanel/ControlPanel"; import { UseGui, useGuiState } from "./ControlPanel/GuiState"; import { searchParamKey } from "./SearchParamsUtils"; -import WebsocketInterface from "./WebsocketInterface"; +import { + WebsocketMessageProducer, + FrameSynchronizedMessageHandler, +} from "./WebsocketInterface"; import { Titlebar } from "./Titlebar"; import { ViserModal } from "./Modal"; import { useSceneTreeState } from "./SceneTreeState"; +import { Message } from "./WebsocketMessages"; export type ViewerContextContents = { + // Zustand hooks. useSceneTree: UseSceneTree; useGui: UseGui; + // Useful references. websocketRef: React.MutableRefObject; canvasRef: React.MutableRefObject; sceneRef: React.MutableRefObject; cameraRef: React.MutableRefObject; cameraControlRef: React.MutableRefObject; + // Scene node attributes. + // This is intentionally placed outside of the Zustand state to reduce overhead. nodeAttributesFromName: React.MutableRefObject<{ [name: string]: | undefined @@ -47,6 +55,7 @@ export type ViewerContextContents = { visibility?: boolean; }; }>; + messageQueueRef: React.MutableRefObject; }; export const ViewerContext = React.createContext( null, @@ -54,8 +63,8 @@ export const ViewerContext = React.createContext( THREE.ColorManagement.enabled = true; -function SingleViewer() { - // Default server logic. +function ViewerRoot() { + // What websocket server should we connect to? function getDefaultServerFromUrl() { // https://localhost:8080/ => ws://localhost:8080 // https://localhost:8080/?server=some_url => ws://localhost:8080 @@ -80,18 +89,23 @@ function SingleViewer() { sceneRef: React.useRef(null), cameraRef: React.useRef(null), cameraControlRef: React.useRef(null), - // Scene node attributes that aren't placed in the zustand state, for performance reasons. + // Scene node attributes that aren't placed in the zustand state for performance reasons. nodeAttributesFromName: React.useRef({}), + messageQueueRef: React.useRef([]), }; - // Memoize the websocket interface so it isn't remounted when the theme or - // viewer context changes. - const memoizedWebsocketInterface = React.useMemo( - () => , - [], + return ( + + + + ); +} +function ViewerContents() { + const viewer = React.useContext(ViewerContext)!; const control_layout = viewer.useGui((state) => state.theme.control_layout); + const colors = viewer.useGui((state) => state.theme.colors); return ( state.theme.dark_mode) ? "dark" : "light", + primaryColor: colors === null ? undefined : "custom", + colors: + colors === null + ? undefined + : { + custom: colors, + }, }} > - - - - - - ({ - top: 0, - bottom: 0, - left: 0, - right: control_layout === "fixed" ? "20em" : 0, - position: "absolute", - backgroundColor: - theme.colorScheme === "light" ? "#fff" : theme.colors.dark[9], - })} - > - {memoizedWebsocketInterface} - - - - - + + + + + ({ + backgroundColor: + theme.colorScheme === "light" ? "#fff" : theme.colors.dark[9], + flexGrow: 1, + width: "10em", + })} + > + + + + + + + ); } @@ -195,7 +215,7 @@ export function Root() { flexDirection: "column", }} > - + ); } diff --git a/viser/client/src/ControlPanel/BottomPanel.tsx b/viser/client/src/ControlPanel/BottomPanel.tsx index 898c0477e..8be1ce2ac 100644 --- a/viser/client/src/ControlPanel/BottomPanel.tsx +++ b/viser/client/src/ControlPanel/BottomPanel.tsx @@ -1,8 +1,13 @@ import { Box, Collapse, Paper } from "@mantine/core"; import React from "react"; -import { FloatingPanelContext } from "./FloatingPanel"; import { useDisclosure } from "@mantine/hooks"; +const BottomPanelContext = React.createContext; + expanded: boolean; + toggleExpanded: () => void; +}>(null); + export default function BottomPanel({ children, }: { @@ -11,7 +16,7 @@ export default function BottomPanel({ const panelWrapperRef = React.useRef(null); const [expanded, { toggle: toggleExpanded }] = useDisclosure(true); return ( - {children} - + ); } BottomPanel.Handle = function BottomPanelHandle({ @@ -46,7 +51,7 @@ BottomPanel.Handle = function BottomPanelHandle({ }: { children: string | React.ReactNode; }) { - const panelContext = React.useContext(FloatingPanelContext)!; + const panelContext = React.useContext(BottomPanelContext)!; return ( { panelContext.toggleExpanded(); }} > - - {children} - + {children} ); }; @@ -82,6 +83,6 @@ BottomPanel.Contents = function BottomPanelContents({ }: { children: string | React.ReactNode; }) { - const panelContext = React.useContext(FloatingPanelContext)!; + const panelContext = React.useContext(BottomPanelContext)!; return {children}; }; diff --git a/viser/client/src/ControlPanel/ControlPanel.tsx b/viser/client/src/ControlPanel/ControlPanel.tsx index e5b9c8224..7f18db5d2 100644 --- a/viser/client/src/ControlPanel/ControlPanel.tsx +++ b/viser/client/src/ControlPanel/ControlPanel.tsx @@ -1,10 +1,9 @@ import { useDisclosure, useMediaQuery } from "@mantine/hooks"; import GeneratedGuiContainer from "./Generated"; import { ViewerContext } from "../App"; -import ServerControls from "./Server"; +import ServerControls from "./ServerControls"; import { ActionIcon, - Aside, Box, Collapse, Tooltip, @@ -15,116 +14,48 @@ import { IconCloudCheck, IconCloudOff, IconArrowBack, - IconChevronLeft, - IconChevronRight, } from "@tabler/icons-react"; import React from "react"; import BottomPanel from "./BottomPanel"; -import FloatingPanel, { FloatingPanelContext } from "./FloatingPanel"; +import FloatingPanel from "./FloatingPanel"; import { ThemeConfigurationMessage } from "../WebsocketMessages"; +import SidebarPanel from "./SidebarPanel"; // Must match constant in Python. const ROOT_CONTAINER_ID = "root"; -/** Hides contents when floating panel is collapsed. */ -function HideWhenCollapsed({ children }: { children: React.ReactNode }) { - const expanded = React.useContext(FloatingPanelContext)?.expanded ?? true; - return expanded ? children : null; -} - export default function ControlPanel(props: { control_layout: ThemeConfigurationMessage["control_layout"]; }) { const theme = useMantineTheme(); const useMobileView = useMediaQuery(`(max-width: ${theme.breakpoints.xs})`); - // TODO: will result in unnecessary re-renders + // TODO: will result in unnecessary re-renders. const viewer = React.useContext(ViewerContext)!; const showGenerated = viewer.useGui( (state) => "root" in state.guiIdSetFromContainerId, ); const [showSettings, { toggle }] = useDisclosure(false); - const [collapsed, { toggle: toggleCollapse }] = useDisclosure(false); - const handleContents = ( - <> - - - {/* We can't apply translateY directly to the ActionIcon, since it's used by - Mantine for the active/click indicator. */} - - { - evt.stopPropagation(); - toggle(); - }} - > - - {showSettings ? : } - - - - - - { - evt.stopPropagation(); - toggleCollapse(); - }} - > - {} - - - - ); - const collapsedView = ( -
+ const generatedServerToggleButton = ( + { evt.stopPropagation(); - toggleCollapse(); + toggle(); }} > - {} + + {showSettings ? ( + + ) : ( + + )} + -
+ ); const panelContents = ( @@ -139,75 +70,40 @@ export default function ControlPanel(props: { ); if (useMobileView) { + /* Mobile layout. */ return ( - {handleContents} + + + {generatedServerToggleButton} + {panelContents} ); - } else if (props.control_layout !== "floating") { - return ( - <> - - {collapsedView} - - - - ); - } else { + } else if (props.control_layout === "floating") { + /* Floating layout. */ return ( - {handleContents} + + + + {generatedServerToggleButton} + + {panelContents} ); + } else { + /* Sidebar view. */ + return ( + + + + {generatedServerToggleButton} + + {panelContents} + + ); } } @@ -220,9 +116,7 @@ function ConnectionStatus() { const StatusIcon = connected ? IconCloudCheck : IconCloudOff; return ( - + <> -     - {label === "" ? server : label} - + + {label === "" ? server : label} + + ); } diff --git a/viser/client/src/ControlPanel/FloatingPanel.tsx b/viser/client/src/ControlPanel/FloatingPanel.tsx index c26d9042f..8c4354a42 100644 --- a/viser/client/src/ControlPanel/FloatingPanel.tsx +++ b/viser/client/src/ControlPanel/FloatingPanel.tsx @@ -5,7 +5,7 @@ import React from "react"; import { isMouseEvent, isTouchEvent, mouseEvents, touchEvents } from "../Utils"; import { useDisclosure } from "@mantine/hooks"; -export const FloatingPanelContext = React.createContext; expanded: boolean; toggleExpanded: () => void; @@ -30,16 +30,16 @@ export default function FloatingPanel({ > { const state = dragInfo.current; @@ -245,14 +249,7 @@ FloatingPanel.Handle = function FloatingPanelHandle({ dragHandler(event); }} > - - {children} - + {children} ); }; @@ -265,3 +262,13 @@ FloatingPanel.Contents = function FloatingPanelContents({ const context = React.useContext(FloatingPanelContext); return {children}; }; + +/** Hides contents when floating panel is collapsed. */ +FloatingPanel.HideWhenCollapsed = function FloatingPanelHideWhenCollapsed({ + children, +}: { + children: React.ReactNode; +}) { + const expanded = React.useContext(FloatingPanelContext)?.expanded ?? true; + return expanded ? children : null; +}; diff --git a/viser/client/src/ControlPanel/Generated.tsx b/viser/client/src/ControlPanel/Generated.tsx index 78a392bb6..ae5720331 100644 --- a/viser/client/src/ControlPanel/Generated.tsx +++ b/viser/client/src/ControlPanel/Generated.tsx @@ -47,7 +47,7 @@ export default function GeneratedGuiContainer({ {[...guiIdSet] .map((id) => guiConfigFromId[id]) .sort((a, b) => a.order - b.order) - .map((conf, index) => { + .map((conf) => { return ; })} @@ -116,6 +116,7 @@ function GeneratedInput({