From 5e1ad7ddcc923ef0571d19d8a90a8f78ac2de4cc Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 21 Sep 2026 13:15:07 +0200 Subject: [PATCH 1/3] refactor(viewer): shrink Toolbar to id-keyed visible/enabled overrides Toolbar no longer models toolbar items at all - ToolbarItem, add_button/add_checkbox/ add_select/add_separator, and remove are gone. A toolbar button is always defined and rendered by the frontend (built-in or installed from an npm package); the backend can only reference an existing button by its id and flip visible/enabled via set_visible/set_enabled, sent as a single overrides map (dispatch: "toolbar_control") under the toolbar's existing stable persisted obj_id. - Wires Toolbar up for real: Workspace owns one, and App.toolbar delegates to it the same way background_color/world_axis/etc. do, so app.toolbar.set_visible(id, False) now actually works. - Keeps Inbox/App's register_toggle_action/register_select_action - still needed so a custom or npm-installed toolbar button (which drives itself via runtime.handleUiAction(name, value) on the frontend) can register a backend callback, independent of the now-removed Toolbar item model. Co-Authored-By: Claude Sonnet 5 --- src/compas_threejs/viewer/__init__.py | 3 +- src/compas_threejs/viewer/app.py | 39 ++++++ src/compas_threejs/viewer/inbox.py | 33 +++++ src/compas_threejs/viewer/toolbar.py | 50 ++++++++ src/compas_threejs/viewer/workspace.py | 2 + tests/test_toolbar.py | 160 +++++++++++++++++++++++++ 6 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 src/compas_threejs/viewer/toolbar.py create mode 100644 tests/test_toolbar.py diff --git a/src/compas_threejs/viewer/__init__.py b/src/compas_threejs/viewer/__init__.py index 9ba444c..009c279 100644 --- a/src/compas_threejs/viewer/__init__.py +++ b/src/compas_threejs/viewer/__init__.py @@ -1,5 +1,6 @@ from .app import App from .remote import Remote +from .toolbar import Toolbar from .workspace import CameraView, Workspace -__all__ = ["App", "CameraView", "Remote", "Workspace"] +__all__ = ["App", "CameraView", "Remote", "Toolbar", "Workspace"] diff --git a/src/compas_threejs/viewer/app.py b/src/compas_threejs/viewer/app.py index cf3c0e1..55f08c7 100644 --- a/src/compas_threejs/viewer/app.py +++ b/src/compas_threejs/viewer/app.py @@ -243,6 +243,12 @@ def show_edges(self): def show_edges(self, value): self.main.show_edges = value + @property + def toolbar(self): + """The main workspace's `Toolbar` - show/hide or enable/disable a frontend-defined + toolbar button by id, e.g. `app.toolbar.set_visible("add_objects", False)`.""" + return self.main.toolbar + @property def url(self) -> str: """Get the URL to access the viewer in a web browser.""" @@ -378,6 +384,39 @@ def register_action(self, name: str, callable_function): """ self.inbox.register_action(name, callable_function) + def register_toggle_action(self, name: str, callable_function): + """ + Manually registers a callable against a fixed action name for a checkbox-like toggle + control that the *frontend* defines and renders itself (e.g. a custom or + npm-installed toolbar button), dispatched the same way a `Checkbox` added via + `add_ui_element` already is (a `dispatch: "ui_callback"` message) - see + `Inbox.register_toggle_action`. The frontend control should call + `runtime.handleUiAction(name, value)` on toggle, using this same `name`. + + Parameters + ---------- + name : str + The action name the frontend sends in its message. + callable_function : callable + Called with the toggle's current value when the action is received. + """ + self.inbox.register_toggle_action(name, callable_function) + + def register_select_action(self, name: str, callable_function): + """ + Manually registers a callable against a fixed action name for a select/dropdown + control that the *frontend* defines and renders itself - see + `register_toggle_action` just above, which this mirrors exactly. + + Parameters + ---------- + name : str + The action name the frontend sends in its message. + callable_function : callable + Called with the selection's current value when the action is received. + """ + self.inbox.register_select_action(name, callable_function) + # ---- GEOMETRY / LIGHTS / MATERIALS / TEXT / UI (forwarded to the main workspace) ----------- def add_geometry(self, geometry, material=None, metadata=None, actions=None): diff --git a/src/compas_threejs/viewer/inbox.py b/src/compas_threejs/viewer/inbox.py index 4502971..b96636f 100644 --- a/src/compas_threejs/viewer/inbox.py +++ b/src/compas_threejs/viewer/inbox.py @@ -65,6 +65,39 @@ def register_material(self, obj_id, material): def register_button(self, guid, action): self.buttons[guid] = action + def register_toggle_action(self, name, callable_function): + """Manually registers a callable against a fixed action name for a checkbox-like + toggle control, dispatched the same way a `Checkbox` added via `add_ui_element` + already is - a `dispatch: "ui_callback"` message carrying `action: name` and the + toggle's current `value` (see `_handle_ui_callback`) - but, unlike `Checkbox`, with + no render side effect of its own: this only ever touches `self.buttons`, the same + registry `register_button` does, never the `add_ui_element`/`send_bytes` call that + actually pushes a widget to the frontend. Used to wire a backend callback to a + checkbox-like control the *frontend* defines and renders itself - e.g. a custom or + npm-installed toolbar button - which calls `runtime.handleUiAction(name, value)` on + toggle using this same `name`. + """ + self.register_button(name, callable_function) + + def register_select_action(self, name, callable_function): + """Manually registers a callable against a fixed action name for a select/dropdown + control - see `register_toggle_action` just above, which this mirrors exactly (a + `Selection` added via `add_ui_element` dispatches through the same `ui_callback` + path). Kept as a separate method from `register_toggle_action` purely for call-site + clarity about which kind of frontend control is being wired up; both currently do + the same thing. + """ + self.register_button(name, callable_function) + + def unregister_action(self, name) -> None: + """Drops a callable previously registered via `register_action`, + `register_toggle_action`, or `register_select_action` (whichever registry it + happens to be in - a given `name` is only ever registered in one). A no-op if + `name` isn't registered in either. + """ + self.action_registry.pop(name, None) + self.buttons.pop(name, None) + def forget_geometry(self, obj_id: str) -> None: """ Drops every registry entry register_geometry created for `obj_id` - called by diff --git a/src/compas_threejs/viewer/toolbar.py b/src/compas_threejs/viewer/toolbar.py new file mode 100644 index 0000000..d2ea902 --- /dev/null +++ b/src/compas_threejs/viewer/toolbar.py @@ -0,0 +1,50 @@ +class Toolbar: + """Lets the backend show/hide or enable/disable a toolbar button the frontend already + defines, by id. It cannot create, remove, relabel, or re-icon a button - a toolbar + button is always a frontend `.vue` module (built-in or installed from an npm package), + which owns its own icon, label, and click behavior. Ids are opaque strings owned by the + frontend's button set; the backend has no way to validate one, so an id that doesn't + match any button the frontend knows about is silently ignored - the same trust model as + any other id (an object guid, an action name) crossing this wire. + + Every mutating call (`set_visible`, `set_enabled`) re-sends the *entire* current + override map under a single stable, persisted `obj_id` ("toolbar") - the same + "single current value, stable persisted slot" pattern `Workspace`'s own scene/theme/ + spinner messages already use (see `Workspace._send_scene_message`, + `Workspace.start_spinner`). A client that reconnects later always replays whichever + override state was sent most recently, instead of only ever seeing whichever state + happened to be current the moment it first connected. + """ + + #: The persisted state key (see `Outbox.send_dict`'s `obj_id`) this toolbar's overrides + #: always occupy - stable across every mutation, so a reconnecting client always + #: replays the current overrides rather than every historical version of them. + OBJ_ID = "toolbar" + + def __init__(self, workspace): + self.workspace = workspace + # id -> {"visible": bool, "enabled": bool} - only the keys that have been set. + self._overrides: dict = {} + + @property + def app(self): + return self.workspace.app + + def set_visible(self, id: str, visible: bool) -> None: + """Shows or hides the frontend button with the given `id`.""" + self._overrides.setdefault(id, {})["visible"] = visible + self._send() + + def set_enabled(self, id: str, enabled: bool) -> None: + """Enables or disables the frontend button with the given `id`.""" + self._overrides.setdefault(id, {})["enabled"] = enabled + self._send() + + def _send(self) -> None: + # Both an explicit "obj_id" field in the message body (matching the wire contract + # exactly, byte for byte) and the `obj_id=` kwarg below (which controls the actual + # server-side persisted-state key, see `Outbox.send_dict`/`AppServer.broadcast`) are + # needed - they serve different purposes but must agree, so both are set from the + # same `Toolbar.OBJ_ID` constant. + message = {"dispatch": "toolbar_control", "obj_id": self.OBJ_ID, "overrides": self._overrides} + self.app.outbox.send_dict(message, workspace_id=self.workspace.workspace_id, obj_id=self.OBJ_ID) diff --git a/src/compas_threejs/viewer/workspace.py b/src/compas_threejs/viewer/workspace.py index a4199ac..e979dc6 100644 --- a/src/compas_threejs/viewer/workspace.py +++ b/src/compas_threejs/viewer/workspace.py @@ -12,6 +12,7 @@ from compas_threejs.lights.ambientlight import AmbientLight from compas_threejs.lights.sunlight import Sunlight +from compas_threejs.viewer.toolbar import Toolbar console = Console() @@ -64,6 +65,7 @@ class Workspace: def __init__(self, app, workspace_id: str = "main"): self.app = app self.workspace_id = workspace_id + self.toolbar = Toolbar(self) self._background_color = Color(0.9, 0.9, 0.9) self._dark_mode = False diff --git a/tests/test_toolbar.py b/tests/test_toolbar.py new file mode 100644 index 0000000..66f3f85 --- /dev/null +++ b/tests/test_toolbar.py @@ -0,0 +1,160 @@ +import json +import unittest + +import compas_pb + +from compas_threejs.viewer.app import App +from compas_threejs.viewer.toolbar import Toolbar + + +def _send(app, dispatch, **fields): + """Simulates an inbound frontend message, dispatched through the real + `Inbox.handle` routing - not by calling private `_handle_*` methods directly - + exactly like `runtime.handleUiAction(name, value)` sends from a frontend-owned + control (e.g. a custom or npm-installed toolbar button). + """ + message = {"dispatch": dispatch, **fields} + app.inbox.handle(json.dumps(message).encode("utf-8"), app.outbox, "main") + + +def _last_toolbar_message(app) -> dict: + """Decodes the most recently queued "toolbar_control" dispatch message. + + `App()` never starts a real server, so `Outbox.send_bytes` queues (rather than + broadcasts) every call - see `Outbox.flush`/`Outbox._queue` - which is exactly what + lets these tests inspect exactly what would have been sent, with no websocket needed. + """ + for binary_data, obj_id, persist, workspace_id, remove_key, broadcast in reversed(app.outbox._queue): + message = compas_pb.pb_load_bts(binary_data) + if isinstance(message, dict) and message.get("dispatch") == "toolbar_control": + return { + "message": message, + "obj_id": obj_id, + "persist": persist, + "workspace_id": workspace_id, + "remove_key": remove_key, + } + raise AssertionError("No 'toolbar_control' dispatch message was queued.") + + +class ToolbarWiringTest(unittest.TestCase): + """`App.toolbar` delegates to the main workspace's `Toolbar`, same as `background_color`, + `world_axis`, etc.""" + + def test_app_toolbar_is_the_main_workspaces_toolbar(self): + app = App() + self.assertIs(app.toolbar, app.main.toolbar) + self.assertIsInstance(app.toolbar, Toolbar) + + +class ToolbarControlTest(unittest.TestCase): + """`Toolbar` only ever sends id-keyed visible/enabled overrides - never a label, icon, + kind, or anything else describing what a button is or does, since that's the + frontend's job.""" + + def test_set_visible_sends_toolbar_control_with_stable_persisted_obj_id(self): + app = App() + app.toolbar.set_visible("add_objects", False) + + entry = _last_toolbar_message(app) + self.assertEqual(entry["obj_id"], "toolbar") + self.assertTrue(entry["persist"]) + self.assertEqual(entry["workspace_id"], "main") + self.assertIsNone(entry["remove_key"]) + + message = entry["message"] + self.assertEqual( + message, + { + "dispatch": "toolbar_control", + "obj_id": "toolbar", + "overrides": {"add_objects": {"visible": False}}, + }, + ) + + def test_set_enabled_sends_toolbar_control(self): + app = App() + app.toolbar.set_enabled("move", False) + + message = _last_toolbar_message(app)["message"] + self.assertEqual(message["overrides"], {"move": {"enabled": False}}) + + def test_overrides_for_the_same_id_merge_across_calls(self): + app = App() + app.toolbar.set_visible("add_objects", False) + app.toolbar.set_enabled("add_objects", False) + + message = _last_toolbar_message(app)["message"] + self.assertEqual( + message["overrides"], + {"add_objects": {"visible": False, "enabled": False}}, + ) + + def test_overrides_for_different_ids_accumulate(self): + app = App() + app.toolbar.set_visible("add_objects", False) + app.toolbar.set_enabled("move", False) + + message = _last_toolbar_message(app)["message"] + self.assertEqual( + message["overrides"], + {"add_objects": {"visible": False}, "move": {"enabled": False}}, + ) + + def test_every_mutation_resends_the_full_current_override_map(self): + app = App() + app.toolbar.set_visible("add_objects", False) + app.toolbar.set_enabled("move", False) + + # The second call's message carries BOTH overrides, not just its own - a + # reconnecting client always replays the full current state. + message = _last_toolbar_message(app)["message"] + self.assertEqual(len(message["overrides"]), 2) + + def test_unknown_id_is_accepted_and_sent_as_is(self): + # The backend can't validate ids against the frontend's button set - it's the + # frontend's job to ignore an id it doesn't recognize. + app = App() + app.toolbar.set_visible("does_not_exist", False) + + message = _last_toolbar_message(app)["message"] + self.assertEqual(message["overrides"], {"does_not_exist": {"visible": False}}) + + +class CustomToolbarButtonCallbackTest(unittest.TestCase): + """A custom or npm-installed toolbar button is a frontend-owned `.vue` - it isn't built + via `Toolbar`, so it wires up its own backend callback with `App.register_action` (plain + click) or `App.register_toggle_action`/`register_select_action` (value-carrying control), + matching whatever it calls via `runtime.handleUiAction(name, value)` on the frontend.""" + + def test_register_action_round_trips_through_other_action_dispatch(self): + app = App() + calls = [] + app.register_action("custom_export", lambda value=None: calls.append(value)) + + _send(app, "other_action", action="custom_export") + + self.assertEqual(calls, [None]) + + def test_register_toggle_action_round_trips_through_ui_callback_dispatch(self): + app = App() + calls = [] + app.register_toggle_action("custom_wireframe", lambda value: calls.append(value)) + + # Exactly the shape `runtime.handleUiAction("custom_wireframe", False)` sends. + _send(app, "ui_callback", action="custom_wireframe", value=False) + + self.assertEqual(calls, [False]) + + def test_register_select_action_round_trips_through_ui_callback_dispatch(self): + app = App() + calls = [] + app.register_select_action("custom_lod", lambda value: calls.append(value)) + + _send(app, "ui_callback", action="custom_lod", value="Components") + + self.assertEqual(calls, ["Components"]) + + +if __name__ == "__main__": + unittest.main() From 599d069929042e9edb764d63c254959082c6289a Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 21 Sep 2026 13:33:34 +0200 Subject: [PATCH 2/3] docs: add changelog entry for the frontend-owned toolbar Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7a824..96d486b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `Toolbar`: lets the backend show/hide or enable/disable a frontend-defined toolbar button by id (`app.toolbar.set_visible(id, bool)`, `app.toolbar.set_enabled(id, bool)`). `App.toolbar`/`Workspace.toolbar` expose it, delegated the same way as `background_color`, `world_axis`, etc. + ### Changed ### Removed From e2bd3ac8c364d307c7682afce6d1fab519945e43 Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 21 Sep 2026 13:41:31 +0200 Subject: [PATCH 3/3] docs(examples): add a toolbar visible/enabled example Shows app.toolbar.set_visible/set_enabled toggling two of the frontend's built-in toolbar buttons ("add_objects", "move") from Checkbox UI elements - the only two things the backend can do to a toolbar button: show/hide or enable/disable one the frontend already defines, by id. Co-Authored-By: Claude Sonnet 5 --- examples/toolbar_control.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 examples/toolbar_control.py diff --git a/examples/toolbar_control.py b/examples/toolbar_control.py new file mode 100644 index 0000000..e27a931 --- /dev/null +++ b/examples/toolbar_control.py @@ -0,0 +1,37 @@ +from compas.geometry import Box + +from compas_threejs.ui import Checkbox +from compas_threejs.viewer import App + +app = App() +app.add_geometry(Box(1, 1, 1)) + +# Ids of built-in frontend toolbar buttons - see compas_threejs_ts's Toolbar.vue and +# its child components for the full list. The backend can only show/hide or +# enable/disable a button the frontend already defines by its id; it can't create, +# remove, relabel, or re-icon one. + + +def toggle_add_objects_visible(checked): + app.toolbar.set_visible("add_objects", checked) + + +def toggle_move_enabled(checked): + app.toolbar.set_enabled("move", checked) + + +check_visible = Checkbox( + text="Show 'Add object' button", + default_value=True, + action=toggle_add_objects_visible, +) +app.add_ui_element(check_visible) + +check_enabled = Checkbox( + text="Enable 'Move' button", + default_value=True, + action=toggle_move_enabled, +) +app.add_ui_element(check_enabled) + +app.start()