Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions examples/toolbar_control.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 2 additions & 1 deletion src/compas_threejs/viewer/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
39 changes: 39 additions & 0 deletions src/compas_threejs/viewer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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):
Expand Down
33 changes: 33 additions & 0 deletions src/compas_threejs/viewer/inbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions src/compas_threejs/viewer/toolbar.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions src/compas_threejs/viewer/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down
160 changes: 160 additions & 0 deletions tests/test_toolbar.py
Original file line number Diff line number Diff line change
@@ -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()
Loading