From 93177d8f7c456791a79c35e0d69664d09086103d Mon Sep 17 00:00:00 2001 From: Lars Briem Date: Tue, 13 Oct 2026 13:46:16 +0200 Subject: [PATCH 1/7] Complete drawing of new sections on WebUI canvas Show a preview line from the last point to the mouse position, finish drawing with a right click in addition to Enter and only finish if enough points have been added (two for lines, three for areas). Areas are closed before they are saved. Key presses are only evaluated while an editor is active and only on key down. Co-Authored-By: Claude Opus 5.5 --- .../application/resources/resource_manager.py | 4 + .../canvas_and_files_form/canvas_form.py | 126 ++++++-- .../canvas_and_files_form/section_geometry.py | 30 ++ .../canvas_and_files_form/test_canvas_form.py | 271 ++++++++++++++++++ .../test_section_geometry.py | 55 ++++ 5 files changed, 464 insertions(+), 22 deletions(-) create mode 100644 OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/section_geometry.py create mode 100644 tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_section_geometry.py diff --git a/OTAnalytics/application/resources/resource_manager.py b/OTAnalytics/application/resources/resource_manager.py index 36e32b92b..c046449a8 100644 --- a/OTAnalytics/application/resources/resource_manager.py +++ b/OTAnalytics/application/resources/resource_manager.py @@ -22,6 +22,7 @@ class CanvasKeys(ResourceKey): LABEL_ADD_SECTION = "label-add-section" LABEL_EDIT_SECTION = "label-edit-section" IMAGE_DEFAULT = "image-default" + MESSAGE_NOT_ENOUGH_POINTS = "message-not-enough-points" class ConfigurationBarKeys(ResourceKey): @@ -210,6 +211,9 @@ class WorkspaceKeys(ResourceKey): AnalysisKeys.BUTTON_TEXT_EXPORT_TRACK_STATISTICS: "Export track statistics", CanvasKeys.LABEL_ADD_SECTION: "Add section", CanvasKeys.LABEL_EDIT_SECTION: "Edit section", + CanvasKeys.MESSAGE_NOT_ENOUGH_POINTS: ( + "Please add at least {count} points to finish the section." + ), ConfigurationBarKeys.LABEL_CONFIGURATION_BAR_FORM_HEADER: "Configuration Bar", FlowAndSectionKeys.TAB_SECTION: "Section", FlowAndSectionKeys.TAB_FLOW: "Flow", diff --git a/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py index d030aa2e7..953677ff2 100644 --- a/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py +++ b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py @@ -1,4 +1,4 @@ -from typing import Iterable, Optional, Self +from typing import Any, Final, Iterable, Optional, Self from nicegui import events, ui from nicegui.elements.interactive_image import InteractiveImage @@ -27,6 +27,12 @@ from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.section_resources import ( SectionResource, ) +from OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.section_geometry import ( # noqa + Point, + close_area, + has_enough_knobs, + minimum_knob_count, +) from OTAnalytics.plugin_ui.nicegui_gui.test_constants import TEST_ID NORMAL_COLOR = "green" @@ -38,6 +44,13 @@ MARKER_INTERACTIVE_IMAGE = "marker-interactive-image" CLICK = "click" +MOUSE_MOVE = "mousemove" +MOUSE_UP = "mouseup" +CONTEXT_MENU = "contextmenu.prevent" +PASSIVE_MOUSE_EVENTS = [CLICK] +EDITOR_MOUSE_EVENTS = [CLICK, MOUSE_MOVE, MOUSE_UP] +EVENTS_PROP = "events" +NOTIFICATION_TYPE_WARNING: Final = "warning" POINTER_EVENT_ALL = "all" POINTER_EVENT_NONE = "" IMAGE_X = "image_x" @@ -94,6 +107,7 @@ def __init__( self._new_point: Circle | None = None self.add_preview_image() self._new_section_points: list[Circle] = [] + self._preview_point: Point | None = None self._new_section = False self._new_area_section = False self._sections: SectionResource = SectionResource({}) @@ -110,7 +124,7 @@ def add_preview_image(self) -> None: def build(self) -> Self: self._background_image = ui.interactive_image( - "", on_mouse=self._on_pointer_down, events=[CLICK] + "", on_mouse=self.on_mouse, events=list(PASSIVE_MOUSE_EVENTS) ) self._background_image.props(f"{TEST_ID}={MARKER_INTERACTIVE_IMAGE}") self._background_image.on( @@ -122,33 +136,68 @@ def build(self) -> Self: self._background_image.on( "svg:pointerup", lambda e: self.on_svg_pointer_up(e.args) ) + self._background_image.on(CONTEXT_MENU, self.on_right_click) self._change_image() ui.keyboard(on_key=self.handle_key) self.load_sections() return self + def _is_editor_active(self) -> bool: + return self._new_section or self._current_section is not None + + def _is_hotkey(self, e: KeyEventArguments, hotkey: HotKeys) -> bool: + return e.key == self._resource_manager.get_hotkey(hotkey) + async def handle_key(self, e: KeyEventArguments) -> None: - if e.key == self._resource_manager.get_hotkey(HotKeys.SAVE_SECTION_HOTKEY): - if self._new_section: - await self._save_new_section() - elif self._current_section: - self._save_edit_section() - if e.key == self._resource_manager.get_hotkey( - HotKeys.CANCEL_SECTION_GEOMETRY_HOTKEY - ): + """Handle key presses. Keys are only evaluated while an editor is active. + + This prevents key presses in other parts of the page from being handled by + the canvas. + """ + if not self._is_editor_active() or not e.action.keydown: + return + if self._is_hotkey(e, HotKeys.SAVE_SECTION_HOTKEY): + await self._finish_editor() + elif self._is_hotkey(e, HotKeys.CANCEL_SECTION_GEOMETRY_HOTKEY): self._cancel_action() + async def on_right_click(self, _: Any = None) -> None: + """Finish the active editor like the save hotkey does.""" + if self._is_editor_active(): + await self._finish_editor() + + async def _finish_editor(self) -> None: + if self._new_section: + await self._save_new_section() + elif self._current_section: + self._save_edit_section() + async def _save_new_section(self) -> None: - """Save a new section with the current points.""" + """Save a new section with the current points. + + The section is only saved if enough points have been added. Otherwise, the + user is notified and can continue adding points. + """ coordinates = [circle.to_tuple() for circle in self._new_section_points] + is_area_section = self._new_area_section + if not has_enough_knobs(coordinates, is_area_section): + self._notify_not_enough_points(is_area_section) + return self.__reset_editor() await self._viewmodel.add_new_section( - coordinates=coordinates, - is_area_section=self._new_area_section, + coordinates=close_area(coordinates, is_area_section), + is_area_section=is_area_section, get_metadata=self.__get_metadata, ) + def _notify_not_enough_points(self, is_area_section: bool) -> None: + message = self._resource_manager.get(CanvasKeys.MESSAGE_NOT_ENOUGH_POINTS) + ui.notify( + message.format(count=minimum_knob_count(is_area_section)), + type=NOTIFICATION_TYPE_WARNING, + ) + def _save_edit_section(self) -> None: """Save changes to an existing section.""" if self._current_section is None: @@ -175,6 +224,21 @@ def __reset_editor(self) -> None: self._current_section = None self._current_point = None self._new_section_points = [] + self._preview_point = None + self._update_mouse_events() + + def _update_mouse_events(self) -> None: + """Subscribe to mouse movements only while an editor is active. + + This avoids sending every mouse movement to the server if nobody needs it. + """ + if self._background_image is None: + return + events = ( + EDITOR_MOUSE_EVENTS if self._is_editor_active() else PASSIVE_MOUSE_EVENTS + ) + self._background_image._props[EVENTS_PROP] = list(events) + self._background_image.update() async def __get_metadata(self) -> dict: title = self._resource_manager.get( @@ -199,16 +263,18 @@ def draw_all(self) -> None: self.draw_new_section() def draw_new_section(self) -> None: - for point in self._new_section_points: - if self._background_image: - self._background_image.content += point.to_svg() - if len(self._new_section_points) >= 2: - coordinates = circle_to_coordinates(self._new_section_points) + if not self._background_image: + return + coordinates = circle_to_coordinates(self._new_section_points) + if coordinates and self._preview_point: + coordinates.append(self._preview_point) + if len(coordinates) >= 2: if self._new_area_section: - coordinates.append(coordinates[0]) + coordinates = close_area(coordinates, is_area_section=True) line = Polyline(id=NEW_SECTION_ID, points=coordinates, color=EDIT_COLOR) - if self._background_image: - self._background_image.content += line.to_svg() + self._background_image.content += line.to_svg() + for point in self._new_section_points: + self._background_image.content += point.to_svg() def _edit_geometry(self) -> str: if self._current_section is None: @@ -218,13 +284,25 @@ def _edit_geometry(self) -> str: id=section_id, points=self._current_section_geometry(), color=EDIT_COLOR ).to_svg() + def on_mouse(self, e: events.MouseEventArguments) -> None: + """Dispatch mouse events of the interactive image.""" + if e.type == MOUSE_MOVE: + self._on_mouse_move(e) + elif e.type == CLICK: + self._on_pointer_down(e) + + def _on_mouse_move(self, e: events.MouseEventArguments) -> None: + if self._new_section: + self._preview_point = (round(e.image_x), round(e.image_y)) + self.draw_all() + def _on_pointer_down(self, e: events.MouseEventArguments) -> None: if self._new_section: self._new_section_points.append( Circle( x=round(e.image_x), y=round(e.image_y), - pointer_event=POINTER_EVENT_ALL, + pointer_event=POINTER_EVENT_NONE, cursor=CURSOR, fill=EDIT_COLOR, id=f"new_point-{len(self._new_section_points)}", @@ -359,6 +437,10 @@ def start_section_builder( ) -> None: self._new_section = True self._new_area_section = is_area_section + self._new_section_points = [] + self._preview_point = None + self._update_mouse_events() + self.draw_all() def get_position(self, offset: tuple[float, float] = (0.5, 0.5)) -> tuple[int, int]: return 0, 0 diff --git a/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/section_geometry.py b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/section_geometry.py new file mode 100644 index 000000000..da4c7b209 --- /dev/null +++ b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/section_geometry.py @@ -0,0 +1,30 @@ +"""UI independent geometry helpers for the section editor on the WebUI canvas.""" + +Point = tuple[int, int] + +MINIMUM_LINE_SECTION_KNOBS = 2 +MINIMUM_AREA_SECTION_KNOBS = 3 + + +def minimum_knob_count(is_area_section: bool) -> int: + """Minimal number of knobs a section must have. + + A line section needs a start and an end. An area needs at least three distinct + knobs to enclose a surface. + """ + if is_area_section: + return MINIMUM_AREA_SECTION_KNOBS + return MINIMUM_LINE_SECTION_KNOBS + + +def has_enough_knobs(coordinates: list[Point], is_area_section: bool) -> bool: + return len(coordinates) >= minimum_knob_count(is_area_section) + + +def close_area(coordinates: list[Point], is_area_section: bool) -> list[Point]: + """Append the first coordinate to an area so that it forms a closed polygon.""" + if not is_area_section or not coordinates: + return list(coordinates) + if len(coordinates) > 1 and coordinates[0] == coordinates[-1]: + return list(coordinates) + return list(coordinates) + [coordinates[0]] diff --git a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py index 86de904af..30a88c6fe 100644 --- a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py +++ b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py @@ -10,6 +10,7 @@ from OTAnalytics.adapter_ui.view_model import ViewModel from OTAnalytics.application.resources.resource_manager import ( CANCEL_SECTION_GEOMETRY_HOTKEY_VALUE, + DEFAULT_HOTKEYS, SAVE_SECTION_HOTKEY_VALUE, HotKeys, ResourceManager, @@ -20,10 +21,13 @@ from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.line import Line from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.polyline import Polyline from OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form import ( + CLICK, EDIT_COLOR, ELEMENT_ID, IMAGE_X, IMAGE_Y, + MOUSE_MOVE, + MOUSE_UP, MOVING_COLOR, NORMAL_COLOR, SELECTED_COLOR, @@ -688,6 +692,7 @@ def hotkey_side_effect(key: Any) -> str: mock_resource_manager, hotkey_side_effect=hotkey_side_effect, ) + canvas_form.start_section_builder() key_event = MagicMock(spec=KeyEventArguments) key_event.key = CANCEL_SECTION_GEOMETRY_HOTKEY_VALUE @@ -1105,3 +1110,269 @@ def test_delete_element( assert len(canvas_form._sections.lines) == 0 # CircleResources.clear() only clears the circles dict, not by_section assert len(canvas_form._circles.circles) == 0 + + +def create_mouse_event(event_type: str, x: float, y: float) -> MagicMock: + event = MagicMock(spec=events.MouseEventArguments) + event.type = event_type + event.image_x = x + event.image_y = y + return event + + +def create_key_event(key: str, keydown: bool = True) -> MagicMock: + event = MagicMock(spec=KeyEventArguments) + event.key = key + event.action = MagicMock() + event.action.keydown = keydown + return event + + +def hotkeys_by_default(key: HotKeys) -> str | None: + return DEFAULT_HOTKEYS.get(key) + + +class TestCanvasFormSectionBuilder: + """Drawing a new section on the canvas (reference: SectionBuilder).""" + + @patch( + "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" + ) + def test_start_section_builder_subscribes_mouse_move( + self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock + ) -> None: + canvas_form = setup_canvas_form_with_mocks( + mock_ui, mock_viewmodel, mock_resource_manager + ) + + canvas_form.start_section_builder() + + background_image = mock_ui.interactive_image.return_value + background_image._props.__setitem__.assert_called_with( + "events", [CLICK, MOUSE_MOVE, MOUSE_UP] + ) + + @patch( + "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" + ) + def test_mouse_move_draws_preview_line_from_last_point( + self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock + ) -> None: + canvas_form = setup_canvas_form_with_mocks( + mock_ui, mock_viewmodel, mock_resource_manager + ) + canvas_form.start_section_builder() + canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20)) + + canvas_form.on_mouse(create_mouse_event(MOUSE_MOVE, 50.4, 60.6)) + + assert canvas_form._preview_point == (50, 61) + assert len(canvas_form._new_section_points) == 1 + content = mock_ui.interactive_image.return_value.content + assert 'points="10,20 50,61"' in content + + @patch( + "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" + ) + def test_mouse_move_without_builder_does_nothing( + self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock + ) -> None: + canvas_form = setup_canvas_form_with_mocks( + mock_ui, mock_viewmodel, mock_resource_manager + ) + + canvas_form.on_mouse(create_mouse_event(MOUSE_MOVE, 50, 60)) + + assert canvas_form._preview_point is None + + @patch( + "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" + ) + def test_click_adds_points( + self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock + ) -> None: + canvas_form = setup_canvas_form_with_mocks( + mock_ui, mock_viewmodel, mock_resource_manager + ) + canvas_form.start_section_builder() + + canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20)) + canvas_form.on_mouse(create_mouse_event(CLICK, 30, 40)) + + assert circle_to_coordinates(canvas_form._new_section_points) == [ + (10, 20), + (30, 40), + ] + + @patch( + "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" + ) + @pytest.mark.asyncio + async def test_right_click_finishes_new_section( + self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock + ) -> None: + canvas_form = setup_canvas_form_with_mocks( + mock_ui, mock_viewmodel, mock_resource_manager + ) + canvas_form.start_section_builder() + canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20)) + canvas_form.on_mouse(create_mouse_event(CLICK, 30, 40)) + + await canvas_form.on_right_click() + + mock_viewmodel.add_new_section.assert_called_once() + call_args = mock_viewmodel.add_new_section.call_args + assert call_args[1]["coordinates"] == [(10, 20), (30, 40)] + assert canvas_form._new_section is False + + @patch( + "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" + ) + @pytest.mark.asyncio + async def test_right_click_without_editor_does_nothing( + self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock + ) -> None: + canvas_form = setup_canvas_form_with_mocks( + mock_ui, mock_viewmodel, mock_resource_manager + ) + + await canvas_form.on_right_click() + + mock_viewmodel.add_new_section.assert_not_called() + mock_viewmodel.update_section_coordinates.assert_not_called() + + @patch( + "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" + ) + @pytest.mark.asyncio + async def test_finish_line_section_with_one_point_is_ignored( + self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock + ) -> None: + canvas_form = setup_canvas_form_with_mocks( + mock_ui, + mock_viewmodel, + mock_resource_manager, + hotkey_side_effect=hotkeys_by_default, + ) + canvas_form.start_section_builder() + canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20)) + + await canvas_form.handle_key(create_key_event(SAVE_SECTION_HOTKEY_VALUE)) + + mock_viewmodel.add_new_section.assert_not_called() + mock_ui.notify.assert_called_once() + assert canvas_form._new_section is True + assert len(canvas_form._new_section_points) == 1 + + @patch( + "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" + ) + @pytest.mark.asyncio + async def test_finish_area_section_with_two_points_is_ignored( + self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock + ) -> None: + canvas_form = setup_canvas_form_with_mocks( + mock_ui, mock_viewmodel, mock_resource_manager + ) + canvas_form.start_section_builder(is_area_section=True) + canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20)) + canvas_form.on_mouse(create_mouse_event(CLICK, 30, 40)) + + await canvas_form.on_right_click() + + mock_viewmodel.add_new_section.assert_not_called() + mock_ui.notify.assert_called_once() + assert canvas_form._new_section is True + + @patch( + "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" + ) + @pytest.mark.asyncio + async def test_finish_area_section_closes_area( + self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock + ) -> None: + canvas_form = setup_canvas_form_with_mocks( + mock_ui, mock_viewmodel, mock_resource_manager + ) + canvas_form.start_section_builder(is_area_section=True) + for x, y in [(10, 20), (30, 40), (50, 20)]: + canvas_form.on_mouse(create_mouse_event(CLICK, x, y)) + + await canvas_form.on_right_click() + + mock_viewmodel.add_new_section.assert_called_once() + call_args = mock_viewmodel.add_new_section.call_args + assert call_args[1]["coordinates"] == [(10, 20), (30, 40), (50, 20), (10, 20)] + assert call_args[1]["is_area_section"] is True + + @patch( + "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" + ) + @pytest.mark.asyncio + async def test_escape_aborts_new_section( + self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock + ) -> None: + canvas_form = setup_canvas_form_with_mocks( + mock_ui, + mock_viewmodel, + mock_resource_manager, + hotkey_side_effect=hotkeys_by_default, + ) + canvas_form.start_section_builder() + canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20)) + + await canvas_form.handle_key( + create_key_event(CANCEL_SECTION_GEOMETRY_HOTKEY_VALUE) + ) + + mock_viewmodel.cancel_action.assert_called_once() + assert canvas_form._new_section is False + assert canvas_form._new_section_points == [] + assert canvas_form._preview_point is None + + @patch( + "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" + ) + @pytest.mark.asyncio + async def test_keys_are_ignored_without_active_editor( + self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock + ) -> None: + canvas_form = setup_canvas_form_with_mocks( + mock_ui, + mock_viewmodel, + mock_resource_manager, + hotkey_side_effect=hotkeys_by_default, + ) + + await canvas_form.handle_key( + create_key_event(CANCEL_SECTION_GEOMETRY_HOTKEY_VALUE) + ) + await canvas_form.handle_key(create_key_event(SAVE_SECTION_HOTKEY_VALUE)) + + mock_viewmodel.cancel_action.assert_not_called() + mock_viewmodel.add_new_section.assert_not_called() + mock_viewmodel.update_section_coordinates.assert_not_called() + + @patch( + "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" + ) + @pytest.mark.asyncio + async def test_key_up_is_ignored( + self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock + ) -> None: + canvas_form = setup_canvas_form_with_mocks( + mock_ui, + mock_viewmodel, + mock_resource_manager, + hotkey_side_effect=hotkeys_by_default, + ) + canvas_form.start_section_builder() + canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20)) + canvas_form.on_mouse(create_mouse_event(CLICK, 30, 40)) + + await canvas_form.handle_key( + create_key_event(SAVE_SECTION_HOTKEY_VALUE, keydown=False) + ) + + mock_viewmodel.add_new_section.assert_not_called() + assert canvas_form._new_section is True diff --git a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_section_geometry.py b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_section_geometry.py new file mode 100644 index 000000000..e1400a9f9 --- /dev/null +++ b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_section_geometry.py @@ -0,0 +1,55 @@ +import pytest + +from OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.section_geometry import ( # noqa + close_area, + has_enough_knobs, + minimum_knob_count, +) + + +class TestMinimumKnobCount: + def test_line_section_needs_two_knobs(self) -> None: + assert minimum_knob_count(is_area_section=False) == 2 + + def test_area_section_needs_three_knobs(self) -> None: + assert minimum_knob_count(is_area_section=True) == 3 + + @pytest.mark.parametrize( + "coordinates, is_area_section, expected", + [ + ([], False, False), + ([(0, 0)], False, False), + ([(0, 0), (1, 1)], False, True), + ([(0, 0), (1, 1)], True, False), + ([(0, 0), (1, 1), (2, 0)], True, True), + ], + ) + def test_has_enough_knobs( + self, + coordinates: list[tuple[int, int]], + is_area_section: bool, + expected: bool, + ) -> None: + assert has_enough_knobs(coordinates, is_area_section) is expected + + +class TestCloseArea: + def test_closes_area(self) -> None: + coordinates = [(0, 0), (10, 0), (10, 10)] + + assert close_area(coordinates, is_area_section=True) == [ + (0, 0), + (10, 0), + (10, 10), + (0, 0), + ] + + def test_keeps_line_untouched(self) -> None: + coordinates = [(0, 0), (10, 0)] + + assert close_area(coordinates, is_area_section=False) == coordinates + + def test_does_not_close_already_closed_area(self) -> None: + coordinates = [(0, 0), (10, 0), (10, 10), (0, 0)] + + assert close_area(coordinates, is_area_section=True) == coordinates From 97e9732bff1dded2b416b39b41e0fc37182648ef Mon Sep 17 00:00:00 2001 From: Lars Briem Date: Tue, 13 Oct 2026 13:51:08 +0200 Subject: [PATCH 2/7] Complete section geometry editor on WebUI canvas Highlight the knob under the mouse, select knobs by click or drag, move and place them. Add hotkeys to insert a knob at the mouse position between the closest knobs (+), delete the selected knob while keeping the minimum number of knobs (Delete/Backspace) and shift the selected knob in the order of knobs (arrow left/right). Enter or a right click saves, Escape deselects the knob or cancels editing. The geometry logic lives in UI independent functions in section_geometry.py. Co-Authored-By: Claude Opus 5.5 --- .../application/resources/resource_manager.py | 15 + .../canvas_and_files_form/canvas_form.py | 313 ++++++++++--- .../canvas_and_files_form/section_geometry.py | 153 +++++++ .../canvas_and_files_form/test_canvas_form.py | 427 +++++++++++++----- .../test_section_geometry.py | 135 ++++++ 5 files changed, 857 insertions(+), 186 deletions(-) diff --git a/OTAnalytics/application/resources/resource_manager.py b/OTAnalytics/application/resources/resource_manager.py index c046449a8..103fe0bca 100644 --- a/OTAnalytics/application/resources/resource_manager.py +++ b/OTAnalytics/application/resources/resource_manager.py @@ -143,6 +143,11 @@ class RemarkKeys(ResourceKey): class HotKeys(ResourceKey): SAVE_SECTION_HOTKEY = "save-section-hotkey" CANCEL_SECTION_GEOMETRY_HOTKEY = "cancel-section-geometry-hotkey" + ADD_KNOB_HOTKEY = "add-knob-hotkey" + DELETE_KNOB_HOTKEY = "delete-knob-hotkey" + DELETE_KNOB_ALTERNATIVE_HOTKEY = "delete-knob-alternative-hotkey" + SHIFT_KNOB_BACKWARD_HOTKEY = "shift-knob-backward-hotkey" + SHIFT_KNOB_FORWARD_HOTKEY = "shift-knob-forward-hotkey" class VisualizationFiltersKeys(ResourceKey): @@ -324,10 +329,20 @@ class WorkspaceKeys(ResourceKey): # Hotkey values as constants for reuse SAVE_SECTION_HOTKEY_VALUE = "Enter" CANCEL_SECTION_GEOMETRY_HOTKEY_VALUE = "Escape" +ADD_KNOB_HOTKEY_VALUE = "+" +DELETE_KNOB_HOTKEY_VALUE = "Delete" +DELETE_KNOB_ALTERNATIVE_HOTKEY_VALUE = "Backspace" +SHIFT_KNOB_BACKWARD_HOTKEY_VALUE = "ArrowLeft" +SHIFT_KNOB_FORWARD_HOTKEY_VALUE = "ArrowRight" DEFAULT_HOTKEYS: dict[HotKeys, str] = { HotKeys.SAVE_SECTION_HOTKEY: SAVE_SECTION_HOTKEY_VALUE, HotKeys.CANCEL_SECTION_GEOMETRY_HOTKEY: CANCEL_SECTION_GEOMETRY_HOTKEY_VALUE, + HotKeys.ADD_KNOB_HOTKEY: ADD_KNOB_HOTKEY_VALUE, + HotKeys.DELETE_KNOB_HOTKEY: DELETE_KNOB_HOTKEY_VALUE, + HotKeys.DELETE_KNOB_ALTERNATIVE_HOTKEY: DELETE_KNOB_ALTERNATIVE_HOTKEY_VALUE, + HotKeys.SHIFT_KNOB_BACKWARD_HOTKEY: SHIFT_KNOB_BACKWARD_HOTKEY_VALUE, + HotKeys.SHIFT_KNOB_FORWARD_HOTKEY: SHIFT_KNOB_FORWARD_HOTKEY_VALUE, } diff --git a/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py index 953677ff2..7b4a6c74f 100644 --- a/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py +++ b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py @@ -30,8 +30,14 @@ from OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.section_geometry import ( # noqa Point, close_area, + closest_knob_index, + delete_knob, has_enough_knobs, + insert_knob, minimum_knob_count, + open_area, + shift_knob_backward, + shift_knob_forward, ) from OTAnalytics.plugin_ui.nicegui_gui.test_constants import TEST_ID @@ -39,6 +45,7 @@ SELECTED_COLOR = "red" EDIT_COLOR = "orange" MOVING_COLOR = "blue" +HOVERED_KNOB_COLOR = "yellow" MOVING_STROKE_WIDTH = 400 MOVING_STROKE_OPACITY = 0.0 MARKER_INTERACTIVE_IMAGE = "marker-interactive-image" @@ -58,34 +65,67 @@ ELEMENT_ID = "element_id" CURSOR = "pointer" +CURSOR_MOVE = "move" NEW_SECTION_ID = "new-section-id" +EDIT_KNOB_ID_PREFIX = "edit-knob-" +KNOB_RADIUS = 6 +HIGHLIGHTED_KNOB_RADIUS = 8 +HIGHLIGHTED_KNOB_STROKE_WIDTH = 3 +KNOB_CATCH_RADIUS = 15 def circle_to_coordinates(circles: Iterable[Circle]) -> list[tuple[int, int]]: return [circle.to_tuple() for circle in circles] -def create_circle(e: dict, fill: str = NORMAL_COLOR) -> Circle: - return Circle( - x=round(e[IMAGE_X]), - y=round(e[IMAGE_Y]), - pointer_event=POINTER_EVENT_ALL, - id=e[ELEMENT_ID], - fill=fill, - ) +def to_point(x: float, y: float) -> Point: + return round(x), round(y) + + +def svg_event_to_point(e: dict) -> Point: + return to_point(e[IMAGE_X], e[IMAGE_Y]) -def create_moving_circle(e: dict, fill: str = NORMAL_COLOR) -> Circle: - return Circle( - x=round(e[IMAGE_X]), - y=round(e[IMAGE_Y]), +def knob_index_from_element_id(element_id: str | None) -> int | None: + if not element_id or not element_id.startswith(EDIT_KNOB_ID_PREFIX): + return None + try: + return int(element_id.removeprefix(EDIT_KNOB_ID_PREFIX)) + except ValueError: + return None + + +def create_knob( + index: int, point: Point, is_hovered: bool = False, is_selected: bool = False +) -> Circle: + """Create the circle of a knob of the section geometry editor. + + A selected knob gets a large transparent stroke. Thereby, the svg keeps + receiving pointer events while the knob is moved. + """ + knob = Circle( + id=f"{EDIT_KNOB_ID_PREFIX}{index}", + x=point[0], + y=point[1], + fill=EDIT_COLOR, pointer_event=POINTER_EVENT_ALL, - id=e[ELEMENT_ID], - fill=fill, - stroke=MOVING_COLOR, - stroke_width=MOVING_STROKE_WIDTH, - stroke_opacity=MOVING_STROKE_OPACITY, + radius=KNOB_RADIUS, + stroke=EDIT_COLOR, + stroke_width=0, + cursor=CURSOR_MOVE, ) + if is_selected: + knob.fill = MOVING_COLOR + knob.radius = HIGHLIGHTED_KNOB_RADIUS + knob.stroke = MOVING_COLOR + knob.stroke_width = MOVING_STROKE_WIDTH + knob.stroke_opacity = MOVING_STROKE_OPACITY + elif is_hovered: + knob.radius = HIGHLIGHTED_KNOB_RADIUS + knob.stroke = HOVERED_KNOB_COLOR + knob.stroke_width = HIGHLIGHTED_KNOB_STROKE_WIDTH + knob.stroke_opacity = 1.0 + return knob class CanvasForm(AbstractCanvas, AbstractFrameCanvas, AbstractTreeviewInterface): @@ -103,8 +143,15 @@ def __init__( self._new_sections = self._current_sections self._introduce_to_viewmodel() self._current_section: Section | None = None - self._current_point: Circle | None = None - self._new_point: Circle | None = None + self._edit_is_area_section = False + self._edit_coordinates: list[Point] = [] + self._edit_temporary: list[Point] = [] + self._hovered_knob_index: int | None = None + self._selected_knob_index: int | None = None + self._dragging = False + self._drop_on_release = False + self._knob_moved = False + self._mouse_position: Point | None = None self.add_preview_image() self._new_section_points: list[Circle] = [] self._preview_point: Point | None = None @@ -160,8 +207,77 @@ async def handle_key(self, e: KeyEventArguments) -> None: if self._is_hotkey(e, HotKeys.SAVE_SECTION_HOTKEY): await self._finish_editor() elif self._is_hotkey(e, HotKeys.CANCEL_SECTION_GEOMETRY_HOTKEY): + self._on_cancel_key() + elif self._current_section is not None: + self._handle_geometry_editor_key(e) + + def _on_cancel_key(self) -> None: + if self._current_section is not None and self._selected_knob_index is not None: + self._deselect_knob(revert=True) + self.draw_all() + else: self._cancel_action() + def _handle_geometry_editor_key(self, e: KeyEventArguments) -> None: + if self._is_hotkey(e, HotKeys.ADD_KNOB_HOTKEY): + self._add_knob() + elif self._is_hotkey(e, HotKeys.DELETE_KNOB_HOTKEY) or self._is_hotkey( + e, HotKeys.DELETE_KNOB_ALTERNATIVE_HOTKEY + ): + self._delete_selected_knob() + elif self._is_hotkey(e, HotKeys.SHIFT_KNOB_BACKWARD_HOTKEY): + self._shift_selected_knob(forward=False) + elif self._is_hotkey(e, HotKeys.SHIFT_KNOB_FORWARD_HOTKEY): + self._shift_selected_knob(forward=True) + + def _add_knob(self) -> None: + """Insert a knob at the mouse position between the closest knobs. + + The new knob is selected and follows the mouse until it is placed by a click. + """ + if self._selected_knob_index is not None or self._mouse_position is None: + return + self._edit_temporary, self._selected_knob_index = insert_knob( + self._edit_temporary, self._mouse_position, self._edit_is_area_section + ) + self._hovered_knob_index = None + self._dragging = False + self.draw_all() + + def _delete_selected_knob(self) -> None: + if self._selected_knob_index is None: + return + self._edit_temporary = delete_knob( + self._edit_temporary, + self._selected_knob_index, + self._edit_is_area_section, + ) + self._commit_edit() + self._deselect_knob(revert=False) + self.draw_all() + + def _shift_selected_knob(self, forward: bool) -> None: + if self._selected_knob_index is None: + return + shift = shift_knob_forward if forward else shift_knob_backward + self._edit_temporary, self._selected_knob_index = shift( + self._edit_temporary, + self._selected_knob_index, + self._edit_is_area_section, + ) + self.draw_all() + + def _commit_edit(self) -> None: + self._edit_coordinates = list(self._edit_temporary) + + def _deselect_knob(self, revert: bool) -> None: + if revert: + self._edit_temporary = list(self._edit_coordinates) + self._selected_knob_index = None + self._dragging = False + self._drop_on_release = False + self._knob_moved = False + async def on_right_click(self, _: Any = None) -> None: """Finish the active editor like the save hotkey does.""" if self._is_editor_active(): @@ -203,7 +319,7 @@ def _save_edit_section(self) -> None: if self._current_section is None: return metadata = self._current_section.to_dict() - coordinates = self._current_section_geometry() + coordinates = close_area(self._edit_temporary, self._edit_is_area_section) self.__reset_editor() self._viewmodel.update_section_coordinates(metadata, coordinates) @@ -213,20 +329,21 @@ def _cancel_action(self) -> None: self._viewmodel.cancel_action() self._viewmodel.refresh_items_on_canvas() - def _current_section_geometry(self) -> list[tuple[int, int]]: - if self._current_section is None: - return [] - circles = self._circles.by_section.get(self._current_section.id.id, {}) - return circle_to_coordinates(circles.values()) - def __reset_editor(self) -> None: self._new_section = False self._current_section = None - self._current_point = None self._new_section_points = [] self._preview_point = None + self._reset_geometry_editor() self._update_mouse_events() + def _reset_geometry_editor(self) -> None: + self._edit_coordinates = [] + self._edit_temporary = [] + self._hovered_knob_index = None + self._mouse_position = None + self._deselect_knob(revert=False) + def _update_mouse_events(self) -> None: """Subscribe to mouse movements only while an editor is active. @@ -252,14 +369,10 @@ async def __get_metadata(self) -> dict: def draw_all(self) -> None: if self._background_image: - self._background_image.content = self._edit_geometry() - self._background_image.content += self._sections.to_svg() + self._background_image.content = self._sections.to_svg() self._background_image.content += self._flows.to_svg() self._background_image.content += self._circles.to_svg() - if self._current_point: - self._background_image.content += self._current_point.to_svg() - if self._new_point: - self._background_image.content += self._new_point.to_svg() + self._background_image.content += self._edit_geometry() self.draw_new_section() def draw_new_section(self) -> None: @@ -280,22 +393,84 @@ def _edit_geometry(self) -> str: if self._current_section is None: return "" section_id = f"{self._current_section.id.id}-edit" - return Polyline( - id=section_id, points=self._current_section_geometry(), color=EDIT_COLOR + svg = Polyline( + id=section_id, + points=close_area(self._edit_temporary, self._edit_is_area_section), + color=EDIT_COLOR, ).to_svg() + for index, point in enumerate(self._edit_temporary): + knob = create_knob( + index, + point, + is_hovered=index == self._hovered_knob_index, + is_selected=index == self._selected_knob_index, + ) + svg += knob.to_svg() + return svg def on_mouse(self, e: events.MouseEventArguments) -> None: """Dispatch mouse events of the interactive image.""" + point = to_point(e.image_x, e.image_y) if e.type == MOUSE_MOVE: - self._on_mouse_move(e) + self._on_move(point) + elif e.type == MOUSE_UP: + self._on_release(point) elif e.type == CLICK: - self._on_pointer_down(e) + if self._current_section is not None: + self._on_canvas_click(point) + else: + self._on_pointer_down(e) - def _on_mouse_move(self, e: events.MouseEventArguments) -> None: + def _on_move(self, point: Point) -> None: if self._new_section: - self._preview_point = (round(e.image_x), round(e.image_y)) + self._preview_point = point + self.draw_all() + elif self._current_section is not None: + self._mouse_position = point + if self._selected_knob_index is not None: + self._move_selected_knob(point) + else: + self._hover_knob(point) + + def _move_selected_knob(self, point: Point) -> None: + if self._selected_knob_index is None: + return + self._edit_temporary[self._selected_knob_index] = point + if self._dragging: + self._knob_moved = True + self.draw_all() + + def _hover_knob(self, point: Point) -> None: + hovered = closest_knob_index(self._edit_temporary, point, KNOB_CATCH_RADIUS) + if hovered != self._hovered_knob_index: + self._hovered_knob_index = hovered self.draw_all() + def _on_release(self, point: Point) -> None: + """Place the selected knob if it was dragged or picked up before.""" + if self._current_section is None or not self._dragging: + return + self._dragging = False + if self._selected_knob_index is None: + return + if self._knob_moved or self._drop_on_release: + self._place_selected_knob(point) + self.draw_all() + + def _on_canvas_click(self, point: Point) -> None: + if self._selected_knob_index is not None and not self._dragging: + self._place_selected_knob(point) + self.draw_all() + + def _place_selected_knob(self, point: Point) -> None: + index = self._selected_knob_index + if index is None: + return + self._edit_temporary[index] = point + self._commit_edit() + self._deselect_knob(revert=False) + self._hovered_knob_index = index + def _on_pointer_down(self, e: events.MouseEventArguments) -> None: if self._new_section: self._new_section_points.append( @@ -311,27 +486,32 @@ def _on_pointer_down(self, e: events.MouseEventArguments) -> None: self.draw_all() def on_svg_pointer_down(self, e: dict) -> None: - if self._new_section: - pass - elif self._current_section: - self._current_point = create_moving_circle(e, fill=EDIT_COLOR) - self.draw_all() + """Select a knob of the edited section or pick up the selected knob.""" + if self._current_section is None: + return + point = svg_event_to_point(e) + self._mouse_position = point + if self._selected_knob_index is None: + index = knob_index_from_element_id(e.get(ELEMENT_ID)) + if index is None: + index = closest_knob_index( + self._edit_temporary, point, KNOB_CATCH_RADIUS + ) + if index is None: + return + self._selected_knob_index = index + self._drop_on_release = False + else: + self._drop_on_release = True + self._dragging = True + self._knob_moved = False + self.draw_all() def on_svg_pointer_move(self, e: dict) -> None: - if self._current_section and self._current_point: - self._current_point = create_moving_circle(e, fill=EDIT_COLOR) - self._circles.add(self._current_section.id.id, self._current_point) - self.draw_all() + self._on_move(svg_event_to_point(e)) def on_svg_pointer_up(self, e: dict) -> None: - if self._new_section: - pass - elif self._current_section and self._current_point: - self._circles.add( - self._current_section.id.id, create_circle(e, fill=EDIT_COLOR) - ) - self._current_point = None - self.draw_all() + self._on_release(svg_event_to_point(e)) def update_background(self, image: TrackImage) -> None: self._current_image = image.as_image() @@ -350,11 +530,6 @@ def clear_image(self) -> None: def load_sections(self) -> None: self._viewmodel.refresh_items_on_canvas() - def draw_current_point(self) -> None: - if self._background_image: - if self._current_point: - self._background_image.content += self._current_point.to_svg() - def draw_section( self, id: str, @@ -388,8 +563,6 @@ def draw_section( self._circles.add(id, circle) polyline = Polyline(id, coordinates, color=color) self._sections.add(id, polyline) - if self._current_point: - self.draw_current_point() self.draw_all() def start_section_geometry_editor( @@ -398,7 +571,19 @@ def start_section_geometry_editor( hovered_knob_style: dict | None = None, is_area_section: bool = False, ) -> None: + self._new_section = False + self._new_section_points = [] + self._preview_point = None + self._reset_geometry_editor() self._current_section = section + self._edit_is_area_section = is_area_section + coordinates = [ + to_point(coordinate.x, coordinate.y) + for coordinate in section.get_coordinates() + ] + self._edit_coordinates = open_area(coordinates, is_area_section) + self._edit_temporary = list(self._edit_coordinates) + self._update_mouse_events() self.draw_all() def draw_arrow( @@ -435,6 +620,8 @@ def start_section_builder( is_area_section: bool = False, section: Optional[Section] = None, ) -> None: + self._current_section = None + self._reset_geometry_editor() self._new_section = True self._new_area_section = is_area_section self._new_section_points = [] diff --git a/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/section_geometry.py b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/section_geometry.py index da4c7b209..5e78fbab4 100644 --- a/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/section_geometry.py +++ b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/section_geometry.py @@ -28,3 +28,156 @@ def close_area(coordinates: list[Point], is_area_section: bool) -> list[Point]: if len(coordinates) > 1 and coordinates[0] == coordinates[-1]: return list(coordinates) return list(coordinates) + [coordinates[0]] + + +def open_area(coordinates: list[Point], is_area_section: bool) -> list[Point]: + """Remove the closing coordinate of an area so that every knob is unique. + + The first and the last coordinate of a stored area are equal. To edit the area, + the closing coordinate has to be removed. + """ + if is_area_section and len(coordinates) > 1 and coordinates[0] == coordinates[-1]: + return list(coordinates[:-1]) + return list(coordinates) + + +def _squared_distance(first: Point, second: Point) -> float: + return (first[0] - second[0]) ** 2 + (first[1] - second[1]) ** 2 + + +def closest_knob_index( + coordinates: list[Point], point: Point, radius: float +) -> int | None: + """Index of the knob closest to the given point within the given radius.""" + closest: int | None = None + closest_distance = radius**2 + for index, knob in enumerate(coordinates): + distance = _squared_distance(knob, point) + if distance <= closest_distance: + closest = index + closest_distance = distance + return closest + + +def _projection(start: Point, end: Point, point: Point) -> float: + """Relative position of the point projected onto the segment from start to end. + + 0 means the projection is at start, 1 at end. Values outside [0, 1] lie + outside the segment. + """ + dx = end[0] - start[0] + dy = end[1] - start[1] + length = dx**2 + dy**2 + if length == 0: + return 0.0 + return ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / length + + +def _squared_distance_to_segment(start: Point, end: Point, point: Point) -> float: + position = min(max(_projection(start, end, point), 0.0), 1.0) + closest = ( + start[0] + position * (end[0] - start[0]), + start[1] + position * (end[1] - start[1]), + ) + return (closest[0] - point[0]) ** 2 + (closest[1] - point[1]) ** 2 + + +def _segments(coordinates: list[Point], is_area_section: bool) -> list[int]: + """Indices of the start knobs of all segments of a section.""" + number_of_segments = len(coordinates) - 1 + if is_area_section and len(coordinates) > 2: + number_of_segments = len(coordinates) + return list(range(max(number_of_segments, 0))) + + +def insert_index(coordinates: list[Point], point: Point, is_area_section: bool) -> int: + """Index at which a new knob at the given point should be inserted. + + The knob is inserted into the segment closest to the point. For line sections, + points beyond the first or the last knob extend the line at that end. + """ + segments = _segments(coordinates, is_area_section) + if not segments: + return len(coordinates) + closest_segment = min( + segments, + key=lambda index: _squared_distance_to_segment( + coordinates[index], coordinates[(index + 1) % len(coordinates)], point + ), + ) + if not is_area_section: + start = coordinates[closest_segment] + end = coordinates[closest_segment + 1] + projection = _projection(start, end, point) + if closest_segment == 0 and projection < 0: + return 0 + if closest_segment == segments[-1] and projection > 1: + return len(coordinates) + return closest_segment + 1 + + +def insert_knob( + coordinates: list[Point], point: Point, is_area_section: bool +) -> tuple[list[Point], int]: + """Insert a knob at the given point between the closest knobs.""" + index = insert_index(coordinates, point, is_area_section) + new_coordinates = list(coordinates) + new_coordinates.insert(index, point) + return new_coordinates, index + + +def _swap(coordinates: list[Point], index: int, other: int) -> list[Point]: + new_coordinates = list(coordinates) + new_coordinates[index], new_coordinates[other] = ( + new_coordinates[other], + new_coordinates[index], + ) + return new_coordinates + + +def shift_knob_forward( + coordinates: list[Point], index: int, is_area_section: bool +) -> tuple[list[Point], int]: + """Move the knob at index one position forward in the order of knobs. + + The last knob of an area wraps around to the start. The last knob of a line + section cannot be moved forward. + """ + if not 0 <= index < len(coordinates): + return list(coordinates), index + if index == len(coordinates) - 1: + if not is_area_section: + return list(coordinates), index + return _swap(coordinates, index, 0), 0 + return _swap(coordinates, index, index + 1), index + 1 + + +def shift_knob_backward( + coordinates: list[Point], index: int, is_area_section: bool +) -> tuple[list[Point], int]: + """Move the knob at index one position backward in the order of knobs. + + The first knob of an area wraps around to the end. The first knob of a line + section cannot be moved backward. + """ + if not 0 <= index < len(coordinates): + return list(coordinates), index + if index == 0: + if not is_area_section: + return list(coordinates), index + last = len(coordinates) - 1 + return _swap(coordinates, index, last), last + return _swap(coordinates, index, index - 1), index - 1 + + +def delete_knob( + coordinates: list[Point], index: int, is_area_section: bool +) -> list[Point]: + """Delete the knob at index unless the section would have too few knobs.""" + if not 0 <= index < len(coordinates): + return list(coordinates) + if len(coordinates) <= minimum_knob_count(is_area_section): + return list(coordinates) + new_coordinates = list(coordinates) + del new_coordinates[index] + return new_coordinates diff --git a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py index 30a88c6fe..fc3bdea11 100644 --- a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py +++ b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Iterator from unittest.mock import MagicMock, Mock, patch import pytest @@ -9,12 +9,18 @@ from OTAnalytics.adapter_ui.view_model import ViewModel from OTAnalytics.application.resources.resource_manager import ( + ADD_KNOB_HOTKEY_VALUE, CANCEL_SECTION_GEOMETRY_HOTKEY_VALUE, DEFAULT_HOTKEYS, + DELETE_KNOB_ALTERNATIVE_HOTKEY_VALUE, + DELETE_KNOB_HOTKEY_VALUE, SAVE_SECTION_HOTKEY_VALUE, + SHIFT_KNOB_BACKWARD_HOTKEY_VALUE, + SHIFT_KNOB_FORWARD_HOTKEY_VALUE, HotKeys, ResourceManager, ) +from OTAnalytics.domain.geometry import Coordinate from OTAnalytics.domain.section import Section from OTAnalytics.domain.track import TrackImage from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.circle import Circle @@ -23,18 +29,16 @@ from OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form import ( CLICK, EDIT_COLOR, + EDIT_KNOB_ID_PREFIX, ELEMENT_ID, + HOVERED_KNOB_COLOR, IMAGE_X, IMAGE_Y, MOUSE_MOVE, MOUSE_UP, - MOVING_COLOR, - NORMAL_COLOR, SELECTED_COLOR, CanvasForm, circle_to_coordinates, - create_circle, - create_moving_circle, ) @@ -77,6 +81,11 @@ def mock_section() -> Mock: section.id = Mock() section.id.id = "test-section-id" section.to_dict.return_value = {"id": "test-section-id", "name": "Test Section"} + section.get_coordinates.return_value = [ + Coordinate(10, 20), + Coordinate(110, 20), + Coordinate(210, 20), + ] return section @@ -113,7 +122,7 @@ def setup_canvas_form_with_mocks( canvas_form.build() if current_section is not None: - canvas_form._current_section = current_section + canvas_form.start_section_geometry_editor(current_section) return canvas_form @@ -121,45 +130,6 @@ def setup_canvas_form_with_mocks( class TestCanvasFormHelperFunctions: """Test helper functions used by CanvasForm.""" - def test_create_circle(self) -> None: - # Arrange - event_data = {IMAGE_X: 100.7, IMAGE_Y: 200.3, ELEMENT_ID: "test-element"} - - # Act - circle = create_circle(event_data, fill="red") - - # Assert - assert circle.x == 101 # rounded - assert circle.y == 200 # rounded - assert circle.id == "test-element" - assert circle.fill == "red" - assert circle.pointer_event == "all" - - def test_create_circle_with_default_fill(self) -> None: - # Arrange - event_data = {IMAGE_X: 50, IMAGE_Y: 75, ELEMENT_ID: "default-element"} - - # Act - circle = create_circle(event_data) - - # Assert - assert circle.fill == NORMAL_COLOR - - def test_create_moving_circle(self) -> None: - # Arrange - event_data = {IMAGE_X: 150.2, IMAGE_Y: 250.8, ELEMENT_ID: "moving-element"} - - # Act - circle = create_moving_circle(event_data, fill="blue") - - # Assert - assert circle.x == 150 # rounded - assert circle.y == 251 # rounded - assert circle.id == "moving-element" - assert circle.fill == "blue" - assert circle.stroke == MOVING_COLOR - assert circle.pointer_event == "all" - def test_circle_to_coordinates(self) -> None: # Arrange circles = [ @@ -465,84 +435,6 @@ def test_on_pointer_down_multiple_points( assert canvas_form._new_section_points[1].x == 150 assert canvas_form._new_section_points[2].x == 200 - @patch( - "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" - ) - def test_on_svg_pointer_down_edit_mode( - self, - mock_ui: Any, - mock_viewmodel: Mock, - mock_resource_manager: Mock, - mock_section: Mock, - ) -> None: - # Arrange - canvas_form = setup_canvas_form_with_mocks( - mock_ui, mock_viewmodel, mock_resource_manager, current_section=mock_section - ) - - event_data = {IMAGE_X: 100, IMAGE_Y: 200, ELEMENT_ID: "test-element"} - - # Act - canvas_form.on_svg_pointer_down(event_data) - - # Assert - assert canvas_form._current_point is not None - assert canvas_form._current_point.x == 100 - assert canvas_form._current_point.y == 200 - assert canvas_form._current_point.fill == EDIT_COLOR - - @patch( - "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" - ) - def test_on_svg_pointer_move_edit_mode( - self, - mock_ui: Any, - mock_viewmodel: Mock, - mock_resource_manager: Mock, - mock_section: Mock, - ) -> None: - # Arrange - canvas_form = setup_canvas_form_with_mocks( - mock_ui, mock_viewmodel, mock_resource_manager, current_section=mock_section - ) - canvas_form._current_point = MagicMock() - - event_data = {IMAGE_X: 150, IMAGE_Y: 250, ELEMENT_ID: "test-element"} - - # Act - canvas_form.on_svg_pointer_move(event_data) - - # Assert - assert canvas_form._current_point.x == 150 - assert canvas_form._current_point.y == 250 - - @patch( - "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" - ) - def test_on_svg_pointer_up_edit_mode( - self, - mock_ui: Any, - mock_viewmodel: Mock, - mock_resource_manager: Mock, - mock_section: Mock, - ) -> None: - # Arrange - canvas_form = setup_canvas_form_with_mocks( - mock_ui, mock_viewmodel, mock_resource_manager, current_section=mock_section - ) - canvas_form._current_point = MagicMock() - - event_data = {IMAGE_X: 175, IMAGE_Y: 275, ELEMENT_ID: "test-element"} - - # Act - canvas_form.on_svg_pointer_up(event_data) - - # Assert - assert canvas_form._current_point is None - # Verify circle was added to the section - circles = canvas_form._circles.by_section.get(mock_section.id.id, {}) - assert len(circles) > 0 - class TestCanvasFormKeyboardEvents: """Test keyboard event handling in CanvasForm.""" @@ -1376,3 +1268,292 @@ async def test_key_up_is_ignored( mock_viewmodel.add_new_section.assert_not_called() assert canvas_form._new_section is True + + +def svg_event(x: float, y: float, element_id: str = "") -> dict: + return {IMAGE_X: x, IMAGE_Y: y, ELEMENT_ID: element_id} + + +def knob_id(index: int) -> str: + return f"{EDIT_KNOB_ID_PREFIX}{index}" + + +ORIGINAL_COORDINATES = [(10, 20), (110, 20), (210, 20)] +CANVAS_UI = ( + "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" +) + + +class TestCanvasFormSectionGeometryEditor: + """Editing an existing section (reference: SectionGeometryEditor).""" + + @pytest.fixture + def canvas_form( + self, mock_viewmodel: Mock, mock_resource_manager: Mock, mock_section: Mock + ) -> Iterator[Any]: + with patch(CANVAS_UI) as mock_ui: + canvas_form = setup_canvas_form_with_mocks( + mock_ui, + mock_viewmodel, + mock_resource_manager, + hotkey_side_effect=hotkeys_by_default, + current_section=mock_section, + ) + canvas_form.mock_ui = mock_ui # type: ignore[attr-defined] + yield canvas_form + + @pytest.fixture + def area_section(self, mock_section: Mock) -> Mock: + mock_section.get_coordinates.return_value = [ + Coordinate(0, 0), + Coordinate(10, 0), + Coordinate(10, 10), + Coordinate(0, 0), + ] + return mock_section + + def test_start_editor_loads_coordinates(self, canvas_form: Any) -> None: + assert canvas_form._edit_coordinates == ORIGINAL_COORDINATES + assert canvas_form._edit_temporary == ORIGINAL_COORDINATES + assert canvas_form._selected_knob_index is None + + def test_start_editor_for_area_drops_closing_coordinate( + self, mock_viewmodel: Mock, mock_resource_manager: Mock, area_section: Mock + ) -> None: + with patch(CANVAS_UI) as mock_ui: + canvas_form = setup_canvas_form_with_mocks( + mock_ui, mock_viewmodel, mock_resource_manager + ) + canvas_form.start_section_geometry_editor( + area_section, is_area_section=True + ) + + assert canvas_form._edit_coordinates == [(0, 0), (10, 0), (10, 10)] + + def test_start_editor_subscribes_mouse_move(self, canvas_form: Any) -> None: + background_image = canvas_form.mock_ui.interactive_image.return_value + background_image._props.__setitem__.assert_called_with( + "events", [CLICK, MOUSE_MOVE, MOUSE_UP] + ) + + def test_hover_highlights_knob_under_mouse(self, canvas_form: Any) -> None: + canvas_form.on_svg_pointer_move(svg_event(112, 22)) + + assert canvas_form._hovered_knob_index == 1 + content = canvas_form.mock_ui.interactive_image.return_value.content + assert HOVERED_KNOB_COLOR in content + + def test_hover_far_from_knob_highlights_nothing(self, canvas_form: Any) -> None: + canvas_form.on_mouse(create_mouse_event(MOUSE_MOVE, 60, 100)) + + assert canvas_form._hovered_knob_index is None + + def test_drag_knob_moves_it(self, canvas_form: Any) -> None: + canvas_form.on_svg_pointer_down(svg_event(110, 20, knob_id(1))) + assert canvas_form._selected_knob_index == 1 + + canvas_form.on_svg_pointer_move(svg_event(120, 50, knob_id(1))) + assert canvas_form._edit_temporary[1] == (120, 50) + assert canvas_form._edit_coordinates == ORIGINAL_COORDINATES + + canvas_form.on_svg_pointer_up(svg_event(121, 51, knob_id(1))) + + assert canvas_form._edit_coordinates == [(10, 20), (121, 51), (210, 20)] + assert canvas_form._selected_knob_index is None + + def test_drag_released_outside_of_svg_moves_knob(self, canvas_form: Any) -> None: + canvas_form.on_svg_pointer_down(svg_event(10, 20, knob_id(0))) + canvas_form.on_mouse(create_mouse_event(MOUSE_MOVE, 30, 70)) + + canvas_form.on_mouse(create_mouse_event(MOUSE_UP, 31, 71)) + + assert canvas_form._edit_coordinates == [(31, 71), (110, 20), (210, 20)] + assert canvas_form._selected_knob_index is None + + def test_click_selects_knob_and_second_click_places_it( + self, canvas_form: Any + ) -> None: + canvas_form.on_svg_pointer_down(svg_event(210, 20, knob_id(2))) + canvas_form.on_svg_pointer_up(svg_event(210, 20, knob_id(2))) + assert canvas_form._selected_knob_index == 2 + + canvas_form.on_svg_pointer_move(svg_event(250, 80, knob_id(2))) + canvas_form.on_svg_pointer_down(svg_event(250, 80, knob_id(2))) + canvas_form.on_svg_pointer_up(svg_event(250, 80, knob_id(2))) + + assert canvas_form._edit_coordinates == [(10, 20), (110, 20), (250, 80)] + assert canvas_form._selected_knob_index is None + + def test_click_on_image_places_selected_knob(self, canvas_form: Any) -> None: + canvas_form.on_svg_pointer_down(svg_event(210, 20, knob_id(2))) + canvas_form.on_svg_pointer_up(svg_event(210, 20, knob_id(2))) + + canvas_form.on_mouse(create_mouse_event(CLICK, 230, 90)) + + assert canvas_form._edit_coordinates == [(10, 20), (110, 20), (230, 90)] + assert canvas_form._selected_knob_index is None + + def test_pointer_down_next_to_knob_selects_closest_knob( + self, canvas_form: Any + ) -> None: + canvas_form.on_svg_pointer_down(svg_event(113, 18, "other-element")) + + assert canvas_form._selected_knob_index == 1 + + def test_pointer_down_far_from_knobs_selects_nothing( + self, canvas_form: Any + ) -> None: + canvas_form.on_svg_pointer_down(svg_event(60, 100, "other-element")) + + assert canvas_form._selected_knob_index is None + + @pytest.mark.asyncio + async def test_plus_inserts_knob_at_mouse_position(self, canvas_form: Any) -> None: + canvas_form.on_mouse(create_mouse_event(MOUSE_MOVE, 60, 30)) + + await canvas_form.handle_key(create_key_event(ADD_KNOB_HOTKEY_VALUE)) + + assert canvas_form._edit_temporary == [ + (10, 20), + (60, 30), + (110, 20), + (210, 20), + ] + assert canvas_form._selected_knob_index == 1 + + @pytest.mark.asyncio + async def test_inserted_knob_is_placed_with_click(self, canvas_form: Any) -> None: + canvas_form.on_mouse(create_mouse_event(MOUSE_MOVE, 60, 30)) + await canvas_form.handle_key(create_key_event(ADD_KNOB_HOTKEY_VALUE)) + + canvas_form.on_svg_pointer_move(svg_event(65, 35, knob_id(1))) + canvas_form.on_svg_pointer_down(svg_event(65, 35, knob_id(1))) + canvas_form.on_svg_pointer_up(svg_event(65, 35, knob_id(1))) + + assert canvas_form._edit_coordinates == [ + (10, 20), + (65, 35), + (110, 20), + (210, 20), + ] + assert canvas_form._selected_knob_index is None + + @pytest.mark.asyncio + async def test_escape_after_insert_removes_new_knob( + self, canvas_form: Any, mock_viewmodel: Mock + ) -> None: + canvas_form.on_mouse(create_mouse_event(MOUSE_MOVE, 60, 30)) + await canvas_form.handle_key(create_key_event(ADD_KNOB_HOTKEY_VALUE)) + + await canvas_form.handle_key( + create_key_event(CANCEL_SECTION_GEOMETRY_HOTKEY_VALUE) + ) + + assert canvas_form._edit_temporary == ORIGINAL_COORDINATES + assert canvas_form._selected_knob_index is None + assert canvas_form._current_section is not None + mock_viewmodel.cancel_action.assert_not_called() + + @pytest.mark.asyncio + async def test_escape_without_selected_knob_cancels( + self, canvas_form: Any, mock_viewmodel: Mock + ) -> None: + await canvas_form.handle_key( + create_key_event(CANCEL_SECTION_GEOMETRY_HOTKEY_VALUE) + ) + + mock_viewmodel.cancel_action.assert_called_once() + mock_viewmodel.update_section_coordinates.assert_not_called() + assert canvas_form._current_section is None + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "key", [DELETE_KNOB_HOTKEY_VALUE, DELETE_KNOB_ALTERNATIVE_HOTKEY_VALUE] + ) + async def test_delete_removes_selected_knob( + self, canvas_form: Any, key: str + ) -> None: + canvas_form.on_svg_pointer_down(svg_event(110, 20, knob_id(1))) + canvas_form.on_svg_pointer_up(svg_event(110, 20, knob_id(1))) + + await canvas_form.handle_key(create_key_event(key)) + + assert canvas_form._edit_coordinates == [(10, 20), (210, 20)] + assert canvas_form._selected_knob_index is None + + @pytest.mark.asyncio + async def test_delete_keeps_minimum_number_of_knobs(self, canvas_form: Any) -> None: + canvas_form.on_svg_pointer_down(svg_event(110, 20, knob_id(1))) + canvas_form.on_svg_pointer_up(svg_event(110, 20, knob_id(1))) + await canvas_form.handle_key(create_key_event(DELETE_KNOB_HOTKEY_VALUE)) + canvas_form.on_svg_pointer_down(svg_event(10, 20, knob_id(0))) + canvas_form.on_svg_pointer_up(svg_event(10, 20, knob_id(0))) + + await canvas_form.handle_key(create_key_event(DELETE_KNOB_HOTKEY_VALUE)) + + assert canvas_form._edit_coordinates == [(10, 20), (210, 20)] + + @pytest.mark.asyncio + async def test_arrow_keys_shift_selected_knob(self, canvas_form: Any) -> None: + canvas_form.on_svg_pointer_down(svg_event(10, 20, knob_id(0))) + canvas_form.on_svg_pointer_up(svg_event(10, 20, knob_id(0))) + + await canvas_form.handle_key(create_key_event(SHIFT_KNOB_FORWARD_HOTKEY_VALUE)) + + assert canvas_form._edit_temporary == [(110, 20), (10, 20), (210, 20)] + assert canvas_form._selected_knob_index == 1 + + await canvas_form.handle_key(create_key_event(SHIFT_KNOB_BACKWARD_HOTKEY_VALUE)) + + assert canvas_form._edit_temporary == ORIGINAL_COORDINATES + assert canvas_form._selected_knob_index == 0 + + @pytest.mark.asyncio + async def test_enter_saves_edited_coordinates( + self, canvas_form: Any, mock_viewmodel: Mock, mock_section: Mock + ) -> None: + canvas_form.on_svg_pointer_down(svg_event(110, 20, knob_id(1))) + canvas_form.on_svg_pointer_move(svg_event(120, 50, knob_id(1))) + canvas_form.on_svg_pointer_up(svg_event(120, 50, knob_id(1))) + + await canvas_form.handle_key(create_key_event(SAVE_SECTION_HOTKEY_VALUE)) + + mock_viewmodel.update_section_coordinates.assert_called_once_with( + mock_section.to_dict.return_value, [(10, 20), (120, 50), (210, 20)] + ) + assert canvas_form._current_section is None + + @pytest.mark.asyncio + async def test_right_click_saves_edited_coordinates( + self, canvas_form: Any, mock_viewmodel: Mock, mock_section: Mock + ) -> None: + await canvas_form.on_right_click() + + mock_viewmodel.update_section_coordinates.assert_called_once_with( + mock_section.to_dict.return_value, ORIGINAL_COORDINATES + ) + + @pytest.mark.asyncio + async def test_save_area_closes_area( + self, mock_viewmodel: Mock, mock_resource_manager: Mock, area_section: Mock + ) -> None: + with patch(CANVAS_UI) as mock_ui: + canvas_form = setup_canvas_form_with_mocks( + mock_ui, mock_viewmodel, mock_resource_manager + ) + canvas_form.start_section_geometry_editor( + area_section, is_area_section=True + ) + + await canvas_form.on_right_click() + + mock_viewmodel.update_section_coordinates.assert_called_once_with( + area_section.to_dict.return_value, [(0, 0), (10, 0), (10, 10), (0, 0)] + ) + + def test_edit_knobs_are_drawn(self, canvas_form: Any) -> None: + content = canvas_form.mock_ui.interactive_image.return_value.content + + for index in range(len(ORIGINAL_COORDINATES)): + assert f'id="{knob_id(index)}"' in content + assert EDIT_COLOR in content diff --git a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_section_geometry.py b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_section_geometry.py index e1400a9f9..811d5e487 100644 --- a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_section_geometry.py +++ b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_section_geometry.py @@ -2,10 +2,19 @@ from OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.section_geometry import ( # noqa close_area, + closest_knob_index, + delete_knob, has_enough_knobs, + insert_knob, minimum_knob_count, + open_area, + shift_knob_backward, + shift_knob_forward, ) +LINE = [(0, 0), (10, 0), (20, 0)] +SQUARE = [(0, 0), (10, 0), (10, 10), (0, 10)] + class TestMinimumKnobCount: def test_line_section_needs_two_knobs(self) -> None: @@ -53,3 +62,129 @@ def test_does_not_close_already_closed_area(self) -> None: coordinates = [(0, 0), (10, 0), (10, 10), (0, 0)] assert close_area(coordinates, is_area_section=True) == coordinates + + +class TestOpenArea: + def test_drops_closing_coordinate_of_area(self) -> None: + assert open_area(SQUARE + [(0, 0)], is_area_section=True) == SQUARE + + def test_keeps_open_area(self) -> None: + assert open_area(SQUARE, is_area_section=True) == SQUARE + + def test_keeps_line(self) -> None: + assert open_area([(0, 0), (0, 0)], is_area_section=False) == [(0, 0), (0, 0)] + + +class TestClosestKnobIndex: + def test_returns_closest_knob_in_radius(self) -> None: + assert closest_knob_index(LINE, (12, 3), radius=5) == 1 + + def test_returns_none_if_no_knob_in_radius(self) -> None: + assert closest_knob_index(LINE, (5, 8), radius=5) is None + + def test_returns_none_for_empty_coordinates(self) -> None: + assert closest_knob_index([], (5, 8), radius=5) is None + + def test_prefers_closer_knob(self) -> None: + assert closest_knob_index([(0, 0), (4, 0)], (3, 0), radius=5) == 1 + + +class TestInsertKnob: + def test_inserts_between_closest_knobs_of_line(self) -> None: + coordinates, index = insert_knob(LINE, (14, 2), is_area_section=False) + + assert coordinates == [(0, 0), (10, 0), (14, 2), (20, 0)] + assert index == 2 + + def test_prepends_before_start_of_line(self) -> None: + coordinates, index = insert_knob(LINE, (-5, 1), is_area_section=False) + + assert coordinates == [(-5, 1), (0, 0), (10, 0), (20, 0)] + assert index == 0 + + def test_appends_after_end_of_line(self) -> None: + coordinates, index = insert_knob(LINE, (25, 1), is_area_section=False) + + assert coordinates == [(0, 0), (10, 0), (20, 0), (25, 1)] + assert index == 3 + + def test_inserts_into_closing_segment_of_area(self) -> None: + coordinates, index = insert_knob(SQUARE, (-1, 5), is_area_section=True) + + assert coordinates == SQUARE + [(-1, 5)] + assert index == 4 + + def test_inserts_between_knobs_of_area(self) -> None: + coordinates, index = insert_knob(SQUARE, (11, 5), is_area_section=True) + + assert coordinates == [(0, 0), (10, 0), (11, 5), (10, 10), (0, 10)] + assert index == 2 + + def test_appends_to_single_knob(self) -> None: + coordinates, index = insert_knob([(0, 0)], (5, 5), is_area_section=False) + + assert coordinates == [(0, 0), (5, 5)] + assert index == 1 + + def test_does_not_modify_input(self) -> None: + original = list(LINE) + + insert_knob(original, (14, 2), is_area_section=False) + + assert original == LINE + + +class TestShiftKnob: + def test_shift_forward(self) -> None: + coordinates, index = shift_knob_forward(LINE, 0, is_area_section=False) + + assert coordinates == [(10, 0), (0, 0), (20, 0)] + assert index == 1 + + def test_shift_forward_at_end_of_line_is_ignored(self) -> None: + coordinates, index = shift_knob_forward(LINE, 2, is_area_section=False) + + assert coordinates == LINE + assert index == 2 + + def test_shift_forward_at_end_of_area_wraps_around(self) -> None: + coordinates, index = shift_knob_forward(SQUARE, 3, is_area_section=True) + + assert coordinates == [(0, 10), (10, 0), (10, 10), (0, 0)] + assert index == 0 + + def test_shift_backward(self) -> None: + coordinates, index = shift_knob_backward(LINE, 2, is_area_section=False) + + assert coordinates == [(0, 0), (20, 0), (10, 0)] + assert index == 1 + + def test_shift_backward_at_start_of_line_is_ignored(self) -> None: + coordinates, index = shift_knob_backward(LINE, 0, is_area_section=False) + + assert coordinates == LINE + assert index == 0 + + def test_shift_backward_at_start_of_area_wraps_around(self) -> None: + coordinates, index = shift_knob_backward(SQUARE, 0, is_area_section=True) + + assert coordinates == [(0, 10), (10, 0), (10, 10), (0, 0)] + assert index == 3 + + +class TestDeleteKnob: + def test_deletes_knob(self) -> None: + assert delete_knob(LINE, 1, is_area_section=False) == [(0, 0), (20, 0)] + + def test_keeps_minimum_of_line(self) -> None: + coordinates = [(0, 0), (10, 0)] + + assert delete_knob(coordinates, 1, is_area_section=False) == coordinates + + def test_keeps_minimum_of_area(self) -> None: + coordinates = [(0, 0), (10, 0), (10, 10)] + + assert delete_knob(coordinates, 1, is_area_section=True) == coordinates + + def test_deletes_knob_of_area(self) -> None: + assert delete_knob(SQUARE, 0, is_area_section=True) == SQUARE[1:] From 1e73fb8bef9fdca6a037a01cb3235aad70938208 Mon Sep 17 00:00:00 2001 From: Lars Briem Date: Tue, 13 Oct 2026 13:53:55 +0200 Subject: [PATCH 3/7] Improve presentation of sections and flows on WebUI canvas Draw area sections closed and semi-transparently filled, show section names passed as text, draw flows as arrows that respect the given arrow style and ignore canvas input while an action is running unless the editor was started by this action. Co-Authored-By: Claude Opus 5.5 --- .../nicegui_gui/nicegui/svg/arrow.py | 54 ++++++ .../nicegui_gui/nicegui/svg/line_resources.py | 5 +- .../nicegui_gui/nicegui/svg/polygon.py | 23 +++ .../nicegui/svg/section_resources.py | 17 +- .../plugin_ui/nicegui_gui/nicegui/svg/text.py | 22 +++ .../canvas_and_files_form/canvas_form.py | 93 +++++++--- .../nicegui_gui/nicegui/svg/test_arrow.py | 43 +++++ .../nicegui_gui/nicegui/svg/test_polygon.py | 22 +++ .../nicegui_gui/nicegui/svg/test_text.py | 23 +++ .../canvas_and_files_form/test_canvas_form.py | 167 ++++++++++++++++++ 10 files changed, 441 insertions(+), 28 deletions(-) create mode 100644 OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/arrow.py create mode 100644 OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/polygon.py create mode 100644 OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/text.py create mode 100644 tests/unit/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/test_arrow.py create mode 100644 tests/unit/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/test_polygon.py create mode 100644 tests/unit/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/test_text.py diff --git a/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/arrow.py b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/arrow.py new file mode 100644 index 000000000..df9e3d3fb --- /dev/null +++ b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/arrow.py @@ -0,0 +1,54 @@ +import math +from dataclasses import dataclass + +DEFAULT_ARROW_WIDTH = 2 +DEFAULT_HEAD_LENGTH = 20 +DEFAULT_HEAD_HALF_WIDTH = 8 + + +@dataclass +class Arrow: + """Line from start to end with an arrow head at the end, e.g. a flow.""" + + id: str + x1: int + y1: int + x2: int + y2: int + color: str + width: int = DEFAULT_ARROW_WIDTH + dash: tuple[int, ...] | None = None + head_length: int = DEFAULT_HEAD_LENGTH + head_half_width: int = DEFAULT_HEAD_HALF_WIDTH + + def to_svg(self) -> str: + dash = ( + f' stroke-dasharray="{" ".join(str(d) for d in self.dash)}"' + if self.dash + else "" + ) + svg = f""" + """ + if head := self.head_points(): + points = " ".join(f"{x},{y}" for x, y in head) + svg += f""" + """ + return svg + "" + + def head_points(self) -> list[tuple[float, float]]: + """Corners of the arrow head: tip and the two trailing points.""" + dx = self.x2 - self.x1 + dy = self.y2 - self.y1 + length = math.hypot(dx, dy) + if length == 0: + return [] + ux, uy = dx / length, dy / length + base_x = self.x2 - ux * self.head_length + base_y = self.y2 - uy * self.head_length + nx, ny = -uy * self.head_half_width, ux * self.head_half_width + return [ + (float(self.x2), float(self.y2)), + (base_x + nx, base_y + ny), + (base_x - nx, base_y - ny), + ] diff --git a/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/line_resources.py b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/line_resources.py index 6bf4d6276..e019bfeb0 100644 --- a/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/line_resources.py +++ b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/line_resources.py @@ -1,16 +1,17 @@ from dataclasses import dataclass +from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.arrow import Arrow from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.line import Line @dataclass class LineResources: - lines: dict[str, Line] + lines: dict[str, Line | Arrow] def to_svg(self) -> str: return "\n".join([line.to_svg() for line in self.lines.values()]) - def add(self, line: Line) -> None: + def add(self, line: Line | Arrow) -> None: self.lines[line.id] = line def clear(self) -> None: diff --git a/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/polygon.py b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/polygon.py new file mode 100644 index 000000000..5ae6be8e8 --- /dev/null +++ b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/polygon.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass + +AREA_FILL_OPACITY = 0.3 + + +@dataclass +class Polygon: + """Closed shape that is filled semi-transparently, e.g. an area section.""" + + id: str + points: list[tuple[int, int]] + color: str + fill_opacity: float = AREA_FILL_OPACITY + + def to_svg(self) -> str: + return f"""""" + + def __serialized_points(self) -> str: + return " ".join(f"{x},{y}" for x, y in self.points) diff --git a/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/section_resources.py b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/section_resources.py index de5be9aa0..9d34d4303 100644 --- a/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/section_resources.py +++ b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/section_resources.py @@ -1,17 +1,26 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field +from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.polygon import Polygon from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.polyline import Polyline +from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.text import Text @dataclass class SectionResource: - lines: dict[str, Polyline] + lines: dict[str, Polyline | Polygon] + texts: dict[str, Text] = field(default_factory=dict) def to_svg(self) -> str: - return "\n".join(line.to_svg() for line in self.lines.values()) + shapes = [line.to_svg() for line in self.lines.values()] + texts = [text.to_svg() for text in self.texts.values()] + return "\n".join(shapes + texts) def clear(self) -> None: self.lines.clear() + self.texts.clear() - def add(self, section_id: str, line: Polyline) -> None: + def add(self, section_id: str, line: Polyline | Polygon) -> None: self.lines[section_id] = line + + def add_text(self, section_id: str, text: Text) -> None: + self.texts[section_id] = text diff --git a/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/text.py b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/text.py new file mode 100644 index 000000000..1bd022f8a --- /dev/null +++ b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/text.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass +from html import escape + +DEFAULT_FONT_SIZE = 14 + + +@dataclass +class Text: + id: str + x: int + y: int + text: str + color: str + font_size: int = DEFAULT_FONT_SIZE + + def to_svg(self) -> str: + return f"""{escape(self.text)}""" diff --git a/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py index 7b4a6c74f..37580f714 100644 --- a/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py +++ b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py @@ -17,16 +17,18 @@ ) from OTAnalytics.domain.section import Section from OTAnalytics.domain.track import TrackImage +from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.arrow import Arrow from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.circle import Circle from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.circle_resources import ( CircleResources, ) -from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.line import Line from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.line_resources import LineResources +from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.polygon import Polygon from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.polyline import Polyline from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.section_resources import ( SectionResource, ) +from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.text import Text from OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.section_geometry import ( # noqa Point, close_area, @@ -72,12 +74,28 @@ HIGHLIGHTED_KNOB_RADIUS = 8 HIGHLIGHTED_KNOB_STROKE_WIDTH = 3 KNOB_CATCH_RADIUS = 15 +SECTION_TEXT_OFFSET_Y = -12 + +# Keys of the arrow style, named like the options of the customtkinter canvas +ARROW_STYLE_COLOR = "fill" +ARROW_STYLE_WIDTH = "width" +ARROW_STYLE_DASH = "dash" +DEFAULT_ARROW_STYLE: dict = {ARROW_STYLE_COLOR: NORMAL_COLOR, ARROW_STYLE_WIDTH: 2} def circle_to_coordinates(circles: Iterable[Circle]) -> list[tuple[int, int]]: return [circle.to_tuple() for circle in circles] +def create_section_shape( + id: str, coordinates: list[Point], color: str, is_area_section: bool +) -> Polyline | Polygon: + """Area sections are drawn closed and filled, line sections as polyline.""" + if is_area_section: + return Polygon(id=id, points=open_area(coordinates, True), color=color) + return Polyline(id=id, points=coordinates, color=color) + + def to_point(x: float, y: float) -> Point: return round(x), round(y) @@ -152,6 +170,7 @@ def __init__( self._drop_on_release = False self._knob_moved = False self._mouse_position: Point | None = None + self._enabled = True self.add_preview_image() self._new_section_points: list[Circle] = [] self._preview_point: Point | None = None @@ -193,6 +212,9 @@ def build(self) -> Self: def _is_editor_active(self) -> bool: return self._new_section or self._current_section is not None + def _accepts_input(self) -> bool: + return self._enabled and self._is_editor_active() + def _is_hotkey(self, e: KeyEventArguments, hotkey: HotKeys) -> bool: return e.key == self._resource_manager.get_hotkey(hotkey) @@ -202,7 +224,7 @@ async def handle_key(self, e: KeyEventArguments) -> None: This prevents key presses in other parts of the page from being handled by the canvas. """ - if not self._is_editor_active() or not e.action.keydown: + if not self._accepts_input() or not e.action.keydown: return if self._is_hotkey(e, HotKeys.SAVE_SECTION_HOTKEY): await self._finish_editor() @@ -280,7 +302,7 @@ def _deselect_knob(self, revert: bool) -> None: async def on_right_click(self, _: Any = None) -> None: """Finish the active editor like the save hotkey does.""" - if self._is_editor_active(): + if self._accepts_input(): await self._finish_editor() async def _finish_editor(self) -> None: @@ -382,10 +404,10 @@ def draw_new_section(self) -> None: if coordinates and self._preview_point: coordinates.append(self._preview_point) if len(coordinates) >= 2: - if self._new_area_section: - coordinates = close_area(coordinates, is_area_section=True) - line = Polyline(id=NEW_SECTION_ID, points=coordinates, color=EDIT_COLOR) - self._background_image.content += line.to_svg() + shape = create_section_shape( + NEW_SECTION_ID, coordinates, EDIT_COLOR, self._new_area_section + ) + self._background_image.content += shape.to_svg() for point in self._new_section_points: self._background_image.content += point.to_svg() @@ -393,10 +415,8 @@ def _edit_geometry(self) -> str: if self._current_section is None: return "" section_id = f"{self._current_section.id.id}-edit" - svg = Polyline( - id=section_id, - points=close_area(self._edit_temporary, self._edit_is_area_section), - color=EDIT_COLOR, + svg = create_section_shape( + section_id, self._edit_temporary, EDIT_COLOR, self._edit_is_area_section ).to_svg() for index, point in enumerate(self._edit_temporary): knob = create_knob( @@ -410,6 +430,8 @@ def _edit_geometry(self) -> str: def on_mouse(self, e: events.MouseEventArguments) -> None: """Dispatch mouse events of the interactive image.""" + if not self._accepts_input(): + return point = to_point(e.image_x, e.image_y) if e.type == MOUSE_MOVE: self._on_move(point) @@ -487,7 +509,7 @@ def _on_pointer_down(self, e: events.MouseEventArguments) -> None: def on_svg_pointer_down(self, e: dict) -> None: """Select a knob of the edited section or pick up the selected knob.""" - if self._current_section is None: + if self._current_section is None or not self._enabled: return point = svg_event_to_point(e) self._mouse_position = point @@ -508,10 +530,12 @@ def on_svg_pointer_down(self, e: dict) -> None: self.draw_all() def on_svg_pointer_move(self, e: dict) -> None: - self._on_move(svg_event_to_point(e)) + if self._accepts_input(): + self._on_move(svg_event_to_point(e)) def on_svg_pointer_up(self, e: dict) -> None: - self._on_release(svg_event_to_point(e)) + if self._accepts_input(): + self._on_release(svg_event_to_point(e)) def update_background(self, image: TrackImage) -> None: self._current_image = image.as_image() @@ -547,7 +571,8 @@ def draw_section( if is_selected_section: color = SELECTED_COLOR pointer_event = POINTER_EVENT_ALL - for index, coordinate in enumerate(coordinates): + knobs = open_area(coordinates, is_area_section) + for index, coordinate in enumerate(knobs): x = coordinate[0] y = coordinate[1] list_of_circle.append( @@ -561,8 +586,21 @@ def draw_section( ) for circle in list_of_circle: self._circles.add(id, circle) - polyline = Polyline(id, coordinates, color=color) - self._sections.add(id, polyline) + self._sections.add( + id, create_section_shape(id, coordinates, color, is_area_section) + ) + if text is not None and coordinates: + x, y = coordinates[0] + self._sections.add_text( + id, + Text( + id=f"{id}-text", + x=x, + y=y + SECTION_TEXT_OFFSET_Y, + text=text, + color=color, + ), + ) self.draw_all() def start_section_geometry_editor( @@ -575,6 +613,7 @@ def start_section_geometry_editor( self._new_section_points = [] self._preview_point = None self._reset_geometry_editor() + self._enabled = True self._current_section = section self._edit_is_area_section = is_area_section coordinates = [ @@ -597,15 +636,19 @@ def draw_arrow( ) -> None: start_x, start_y = start_refpt_calculator.get_reference_point(start_section) end_x, end_y = end_refpt_calculator.get_reference_point(end_section) - new_line = Line( + style = {**DEFAULT_ARROW_STYLE, **(arrow_style or {})} + dash = style.get(ARROW_STYLE_DASH) + arrow = Arrow( id=f"arrow-{start_section.id}-{end_section.id}", x1=int(start_x), x2=int(end_x), y1=int(start_y), y2=int(end_y), - stroke=NORMAL_COLOR, + color=style[ARROW_STYLE_COLOR], + width=style[ARROW_STYLE_WIDTH], + dash=tuple(dash) if dash else None, ) - self._flows.add(new_line) + self._flows.add(arrow) self.draw_all() def delete_element(self, tag_or_id: str) -> None: @@ -620,6 +663,7 @@ def start_section_builder( is_area_section: bool = False, section: Optional[Section] = None, ) -> None: + self._enabled = True self._current_section = None self._reset_geometry_editor() self._new_section = True @@ -636,10 +680,15 @@ def _notify_viewmodel_about_selected_item_ids(self, ids: list[str]) -> None: pass def disable(self) -> None: - pass + """Ignore canvas input while an action is running. + + An editor started by the running action enables the canvas again, because + the editor is part of this action. + """ + self._enabled = False def enable(self) -> None: - pass + self._enabled = True def update_items(self) -> None: pass diff --git a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/test_arrow.py b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/test_arrow.py new file mode 100644 index 000000000..a761b2aaa --- /dev/null +++ b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/test_arrow.py @@ -0,0 +1,43 @@ +from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.arrow import Arrow + + +class TestArrow: + def test_arrow_to_svg(self) -> None: + arrow = Arrow( + id="arrow", x1=0, y1=0, x2=100, y2=0, color="red", width=4, dash=(5, 5) + ) + + svg = arrow.to_svg() + + assert 'id="arrow"' in svg + assert ' None: + arrow = Arrow( + id="arrow", + x1=0, + y1=0, + x2=100, + y2=0, + color="red", + head_length=20, + head_half_width=8, + ) + + assert arrow.head_points() == [(100.0, 0.0), (80.0, 8.0), (80.0, -8.0)] + + def test_arrow_without_dash(self) -> None: + arrow = Arrow(id="arrow", x1=0, y1=0, x2=100, y2=0, color="red") + + assert "stroke-dasharray" not in arrow.to_svg() + + def test_arrow_without_length_has_no_head(self) -> None: + arrow = Arrow(id="arrow", x1=10, y1=10, x2=10, y2=10, color="red") + + assert arrow.head_points() == [] + assert " None: + polygon = Polygon( + id="area", points=[(0, 0), (10, 0), (10, 10)], color="red", fill_opacity=0.3 + ) + + svg = polygon.to_svg() + + assert " None: + polygon = Polygon(id="area", points=[(0, 0), (10, 0), (10, 10)], color="red") + + assert 0 < polygon.fill_opacity < 1 diff --git a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/test_text.py b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/test_text.py new file mode 100644 index 000000000..cd34819b1 --- /dev/null +++ b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/test_text.py @@ -0,0 +1,23 @@ +from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.text import Text + + +class TestText: + def test_text_to_svg(self) -> None: + text = Text(id="section-text", x=10, y=20, text="North", color="green") + + svg = text.to_svg() + + assert "North" in svg + + def test_text_is_escaped(self) -> None: + text = Text(id="section-text", x=10, y=20, text="&", color="green") + + svg = text.to_svg() + + assert "<b>&</b>" in svg + assert "" not in svg diff --git a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py index fc3bdea11..f0842620b 100644 --- a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py +++ b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py @@ -25,6 +25,7 @@ from OTAnalytics.domain.track import TrackImage from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.circle import Circle from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.line import Line +from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.polygon import Polygon from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.polyline import Polyline from OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form import ( CLICK, @@ -36,6 +37,7 @@ IMAGE_Y, MOUSE_MOVE, MOUSE_UP, + NORMAL_COLOR, SELECTED_COLOR, CanvasForm, circle_to_coordinates, @@ -1557,3 +1559,168 @@ def test_edit_knobs_are_drawn(self, canvas_form: Any) -> None: for index in range(len(ORIGINAL_COORDINATES)): assert f'id="{knob_id(index)}"' in content assert EDIT_COLOR in content + + +class TestCanvasFormPresentation: + """Presentation of sections and flows (reference: SectionPainter, ArrowPainter).""" + + @pytest.fixture + def canvas_form( + self, mock_viewmodel: Mock, mock_resource_manager: Mock + ) -> Iterator[Any]: + with patch(CANVAS_UI) as mock_ui: + canvas_form = setup_canvas_form_with_mocks( + mock_ui, + mock_viewmodel, + mock_resource_manager, + hotkey_side_effect=hotkeys_by_default, + ) + canvas_form.mock_ui = mock_ui # type: ignore[attr-defined] + yield canvas_form + + def content(self, canvas_form: Any) -> str: + return canvas_form.mock_ui.interactive_image.return_value.content + + def test_area_section_is_drawn_as_filled_polygon(self, canvas_form: Any) -> None: + canvas_form.draw_section( + id="area", + coordinates=[(0, 0), (10, 0), (10, 10), (0, 0)], + is_selected_section=False, + is_area_section=True, + ) + + shape = canvas_form._sections.lines["area"] + assert isinstance(shape, Polygon) + assert shape.points == [(0, 0), (10, 0), (10, 10)] + assert 0 < shape.fill_opacity < 1 + assert len(canvas_form._circles.by_section["area"]) == 3 + assert " None: + canvas_form.draw_section( + id="line", coordinates=[(0, 0), (10, 0)], is_selected_section=False + ) + + assert isinstance(canvas_form._sections.lines["line"], Polyline) + + def test_section_name_is_drawn(self, canvas_form: Any) -> None: + canvas_form.draw_section( + id="line", + coordinates=[(5, 30), (10, 0)], + is_selected_section=False, + text="North", + ) + + content = self.content(canvas_form) + assert ">North" in content + assert 'x="5"' in content + + def test_section_without_name_has_no_text(self, canvas_form: Any) -> None: + canvas_form.draw_section( + id="line", coordinates=[(0, 0), (10, 0)], is_selected_section=False + ) + + assert " None: + canvas_form.draw_section( + id="line", + coordinates=[(0, 0), (10, 0)], + is_selected_section=False, + text="North", + ) + + canvas_form.delete_element("line_section") + canvas_form.draw_all() + + assert "North" not in self.content(canvas_form) + + def test_new_area_section_is_drawn_as_polygon(self, canvas_form: Any) -> None: + canvas_form.start_section_builder(is_area_section=True) + for x, y in [(0, 0), (10, 0)]: + canvas_form.on_mouse(create_mouse_event(CLICK, x, y)) + canvas_form.on_mouse(create_mouse_event(MOUSE_MOVE, 10, 10)) + + assert 'points="0,0 10,0 10,10"' in self.content(canvas_form) + assert " None: + start_section, end_section, start_calculator, end_calculator = arrow_mocks() + + canvas_form.draw_arrow( + start_section=start_section, + end_section=end_section, + start_refpt_calculator=start_calculator, + end_refpt_calculator=end_calculator, + arrow_style={"fill": "#8CFFC2", "width": 4, "dash": (5, 5)}, + ) + + arrow = canvas_form._flows.lines["arrow-start-section-end-section"] + assert arrow.color == "#8CFFC2" + assert arrow.width == 4 + assert arrow.dash == (5, 5) + assert 'stroke="#8CFFC2"' in self.content(canvas_form) + + def test_draw_arrow_without_style_uses_default(self, canvas_form: Any) -> None: + start_section, end_section, start_calculator, end_calculator = arrow_mocks() + + canvas_form.draw_arrow( + start_section=start_section, + end_section=end_section, + start_refpt_calculator=start_calculator, + end_refpt_calculator=end_calculator, + ) + + arrow = canvas_form._flows.lines["arrow-start-section-end-section"] + assert arrow.color == NORMAL_COLOR + + def test_disabled_canvas_ignores_editor_input( + self, canvas_form: Any, mock_section: Mock + ) -> None: + canvas_form.start_section_builder() + canvas_form.disable() + + canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20)) + + assert canvas_form._new_section_points == [] + + canvas_form.enable() + canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20)) + + assert len(canvas_form._new_section_points) == 1 + + @pytest.mark.asyncio + async def test_disabled_canvas_ignores_keys_and_right_click( + self, canvas_form: Any, mock_viewmodel: Mock, mock_section: Mock + ) -> None: + canvas_form.start_section_geometry_editor(mock_section) + canvas_form.disable() + + await canvas_form.handle_key(create_key_event(SAVE_SECTION_HOTKEY_VALUE)) + await canvas_form.on_right_click() + canvas_form.on_svg_pointer_down(svg_event(110, 20, knob_id(1))) + + mock_viewmodel.update_section_coordinates.assert_not_called() + assert canvas_form._selected_knob_index is None + + def test_editor_started_by_running_action_is_enabled( + self, canvas_form: Any + ) -> None: + canvas_form.disable() + canvas_form.start_section_builder() + + canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20)) + + assert len(canvas_form._new_section_points) == 1 + + +def arrow_mocks() -> tuple[Mock, Mock, Mock, Mock]: + start_section = Mock() + start_section.id = "start-section" + end_section = Mock() + end_section.id = "end-section" + start_calculator = Mock() + start_calculator.get_reference_point.return_value = (100, 200) + end_calculator = Mock() + end_calculator.get_reference_point.return_value = (300, 400) + return start_section, end_section, start_calculator, end_calculator From d3d699f16b65535f04033cfe17b7cbaa222b0c4e Mon Sep 17 00:00:00 2001 From: Lars Briem Date: Tue, 13 Oct 2026 13:55:49 +0200 Subject: [PATCH 4/7] Enable canvas hotkeys right after starting a section editor Key presses are ignored while a button has the focus. Starting an editor via the section buttons kept the focus on the button, so hotkeys only worked after clicking into the canvas. Release the focus when an editor starts. Co-Authored-By: Claude Opus 5.5 --- .../canvas_and_files_form/canvas_form.py | 12 +++++++++++ .../canvas_and_files_form/test_canvas_form.py | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py index 37580f714..9385332ac 100644 --- a/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py +++ b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py @@ -59,6 +59,7 @@ PASSIVE_MOUSE_EVENTS = [CLICK] EDITOR_MOUSE_EVENTS = [CLICK, MOUSE_MOVE, MOUSE_UP] EVENTS_PROP = "events" +RELEASE_FOCUS = "document.activeElement?.blur()" NOTIFICATION_TYPE_WARNING: Final = "warning" POINTER_EVENT_ALL = "all" POINTER_EVENT_NONE = "" @@ -379,6 +380,15 @@ def _update_mouse_events(self) -> None: self._background_image._props[EVENTS_PROP] = list(events) self._background_image.update() + def _release_focus(self) -> None: + """Release the focus of the button that started the editor. + + Key presses are ignored while a button has the focus. Releasing it enables + the hotkeys of the editor right away. + """ + if self._background_image is not None: + self._background_image.client.run_javascript(RELEASE_FOCUS) + async def __get_metadata(self) -> dict: title = self._resource_manager.get( CanvasKeys.LABEL_EDIT_SECTION @@ -623,6 +633,7 @@ def start_section_geometry_editor( self._edit_coordinates = open_area(coordinates, is_area_section) self._edit_temporary = list(self._edit_coordinates) self._update_mouse_events() + self._release_focus() self.draw_all() def draw_arrow( @@ -671,6 +682,7 @@ def start_section_builder( self._new_section_points = [] self._preview_point = None self._update_mouse_events() + self._release_focus() self.draw_all() def get_position(self, offset: tuple[float, float] = (0.5, 0.5)) -> tuple[int, int]: diff --git a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py index f0842620b..0d83eb9f3 100644 --- a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py +++ b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py @@ -38,6 +38,7 @@ MOUSE_MOVE, MOUSE_UP, NORMAL_COLOR, + RELEASE_FOCUS, SELECTED_COLOR, CanvasForm, circle_to_coordinates, @@ -1046,6 +1047,21 @@ def test_start_section_builder_subscribes_mouse_move( "events", [CLICK, MOUSE_MOVE, MOUSE_UP] ) + @patch( + "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" + ) + def test_start_section_builder_releases_focus_for_hotkeys( + self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock + ) -> None: + canvas_form = setup_canvas_form_with_mocks( + mock_ui, mock_viewmodel, mock_resource_manager + ) + + canvas_form.start_section_builder() + + background_image = mock_ui.interactive_image.return_value + background_image.client.run_javascript.assert_called_once_with(RELEASE_FOCUS) + @patch( "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui" ) @@ -1314,6 +1330,10 @@ def area_section(self, mock_section: Mock) -> Mock: ] return mock_section + def test_start_editor_releases_focus_for_hotkeys(self, canvas_form: Any) -> None: + background_image = canvas_form.mock_ui.interactive_image.return_value + background_image.client.run_javascript.assert_called_with(RELEASE_FOCUS) + def test_start_editor_loads_coordinates(self, canvas_form: Any) -> None: assert canvas_form._edit_coordinates == ORIGINAL_COORDINATES assert canvas_form._edit_temporary == ORIGINAL_COORDINATES From 86518b89ea69ad7e42d7bd16792dc1254662777a Mon Sep 17 00:00:00 2001 From: Lars Briem Date: Tue, 13 Oct 2026 14:01:20 +0200 Subject: [PATCH 5/7] Add acceptance tests for drawing and editing sections on WebUI canvas Co-Authored-By: Claude Opus 5.5 --- tests/acceptance/test_sections_and_flows.py | 193 +++++++++++++++++++- 1 file changed, 192 insertions(+), 1 deletion(-) diff --git a/tests/acceptance/test_sections_and_flows.py b/tests/acceptance/test_sections_and_flows.py index 0df7d7b3c..7da0ce3ac 100644 --- a/tests/acceptance/test_sections_and_flows.py +++ b/tests/acceptance/test_sections_and_flows.py @@ -1,25 +1,39 @@ +import json import time from pathlib import Path +from typing import Any import pytest -from playwright.sync_api import Page # type: ignore # noqa: E402 +from playwright.sync_api import Page, expect # type: ignore # noqa: E402 from OTAnalytics.application.resources.resource_manager import ( FlowAndSectionKeys, ResourceManager, + SectionKeys, ) from OTAnalytics.plugin_ui.nicegui_gui.dialogs.edit_flow_dialog import ( MARKER_NAME as MARKER_FLOW_NAME, ) +from OTAnalytics.plugin_ui.nicegui_gui.dialogs.edit_section_dialog import ( + MARKER_NAME as MARKER_SECTION_NAME, +) from OTAnalytics.plugin_ui.nicegui_gui.nicegui.elements.dialog import ( MARKER_APPLY as MARKER_DIALOG_APPLY, ) +from OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form import ( + EDIT_COLOR, + EDIT_KNOB_ID_PREFIX, + MARKER_INTERACTIVE_IMAGE, +) from OTAnalytics.plugin_ui.nicegui_gui.pages.sections_and_flow_form.flow_form import ( MARKER_BUTTON_GENERATE, MARKER_BUTTON_PROPERTIES, MARKER_BUTTON_REMOVE, MARKER_FLOW_TABLE, ) +from OTAnalytics.plugin_ui.nicegui_gui.pages.sections_and_flow_form.sections_form import ( # noqa + MARKER_SECTION_TABLE, +) from tests.acceptance.conftest import ( ACCEPTANCE_TEST_FINAL_TIMEOUT_MS, ACCEPTANCE_TEST_PYTEST_TIMEOUT, @@ -217,3 +231,180 @@ def test_remove_flow_and_generate_flows_and_save_project( assert saved_path.exists(), f"Expected saved configuration at {saved_path}" reference_path = Path(__file__).parents[1] / "data" / "test_name.otconfig" compare_json_files(saved_path, reference_path) + + +SECTION_EDITOR_SETTLE_MS = 300 +NORTH_SECTION = "North-Section" + + +def _canvas_image(page: Page) -> Any: + return ( + search_for_marker_element(page, MARKER_INTERACTIVE_IMAGE).locator("img").first + ) + + +def _to_page_position(page: Page, x: float, y: float) -> tuple[float, float]: + """Convert image coordinates into page coordinates of the mouse.""" + image = _canvas_image(page) + image.scroll_into_view_if_needed() + box = image.bounding_box() + assert box is not None, "Canvas image is not visible" + scale = image.evaluate("element => element.clientWidth / element.naturalWidth") + return box["x"] + x * scale, box["y"] + y * scale + + +def _edit_knobs(page: Page) -> Any: + return search_for_marker_element(page, MARKER_INTERACTIVE_IMAGE).locator( + f"svg circle[id^='{EDIT_KNOB_ID_PREFIX}']" + ) + + +def _move_mouse(page: Page, x: float, y: float) -> None: + page.mouse.move(*_to_page_position(page, x, y), steps=3) + page.wait_for_timeout(SECTION_EDITOR_SETTLE_MS) + + +def _click(page: Page, x: float, y: float, button: str = "left") -> None: + page.mouse.click(*_to_page_position(page, x, y), button=button) # type: ignore + page.wait_for_timeout(SECTION_EDITOR_SETTLE_MS) + + +def _press(page: Page, key: str) -> None: + page.keyboard.press(key) + page.wait_for_timeout(SECTION_EDITOR_SETTLE_MS) + + +def _open_sections_of_preconfigured_project( + page: Page, external_app: NiceGUITestServer, resource_manager: ResourceManager +) -> None: + load_main_page(page, external_app) + otconfig_path = ( + Path(__file__).parents[1] / "data" / "sections_created_test_file.otconfig" + ) + setup_with_preconfigured_otconfig(page, resource_manager, otconfig_path) + page.get_by_text( + resource_manager.get(FlowAndSectionKeys.TAB_SECTION), exact=True + ).click() + _dismiss_notifications(page) + + +def _dismiss_notifications(page: Page) -> None: + """Close notifications of loading the project, they may cover the canvas.""" + notifications = page.locator(".q-notification") + deadline = time.time() + ACCEPTANCE_TEST_WAIT_TIMEOUT + while notifications.count() and time.time() < deadline: + close_buttons = notifications.locator("button") + if close_buttons.count(): + # Notifications keep moving while others disappear, so no regular click + close_buttons.first.dispatch_event("click") + page.wait_for_timeout(PLAYWRIGHT_POLL_INTERVAL_SLOW_MS) + expect(notifications).to_have_count(0) + + +def _edit_geometry_of( + page: Page, resource_manager: ResourceManager, section_name: str +) -> None: + table = search_for_marker_element(page, MARKER_SECTION_TABLE).first + table.locator("tbody tr").filter(has_text=section_name).first.click() + page.get_by_text(resource_manager.get(SectionKeys.BUTTON_EDIT), exact=True).click() + expect(_edit_knobs(page)).not_to_have_count(0) + + +def _saved_section_coordinates( + page: Page, resource_manager: ResourceManager, path: Path, section_name: str +) -> list[tuple[int, int]]: + save_project_as(page, resource_manager, path) + deadline = time.time() + ACCEPTANCE_TEST_WAIT_TIMEOUT + while not path.exists() and time.time() < deadline: + time.sleep(PLAYWRIGHT_POLL_INTERVAL_SLOW_MS / 1000) + assert path.exists(), f"Expected saved configuration at {path}" + sections = json.loads(path.read_text())["sections"] + section = next(s for s in sections if s["name"] == section_name) + return [(c["x"], c["y"]) for c in section["coordinates"]] + + +class TestSectionEditorOnCanvas: + + @pytest.mark.timeout(ACCEPTANCE_TEST_PYTEST_TIMEOUT) + @pytest.mark.playwright + @pytest.mark.usefixtures("external_app") + def test_draw_line_section_with_preview_and_right_click( + self, + page: Page, + external_app: NiceGUITestServer, + resource_manager: ResourceManager, + ) -> None: + """Draw a line section: preview follows the mouse, right click finishes.""" + _open_sections_of_preconfigured_project(page, external_app, resource_manager) + page.get_by_text( + resource_manager.get(SectionKeys.BUTTON_ADD_LINE), exact=True + ).click() + + _click(page, 40, 200) + _move_mouse(page, 200, 260) + + preview = search_for_marker_element(page, MARKER_INTERACTIVE_IMAGE).locator( + f"svg polyline[stroke='{EDIT_COLOR}']" + ) + expect(preview).to_have_count(1) + + _click(page, 200, 260) + _click(page, 200, 260, button="right") + + name_input = search_for_marker_element(page, MARKER_SECTION_NAME).first + name_input.wait_for(state="visible") + name_input.fill("Drawn-Section") + search_for_marker_element(page, MARKER_DIALOG_APPLY).first.click() + + table = search_for_marker_element(page, MARKER_SECTION_TABLE).first + expect(table).to_contain_text("Drawn-Section") + + @pytest.mark.timeout(ACCEPTANCE_TEST_PYTEST_TIMEOUT) + @pytest.mark.playwright + @pytest.mark.usefixtures("external_app") + def test_insert_and_delete_knob_of_section( + self, + page: Page, + external_app: NiceGUITestServer, + resource_manager: ResourceManager, + test_data_tmp_dir: Path, + ) -> None: + """Insert a knob with '+' and delete it again with 'Delete'.""" + _open_sections_of_preconfigured_project(page, external_app, resource_manager) + + knobs = _edit_knobs(page) + + _edit_geometry_of(page, resource_manager, NORTH_SECTION) + expect(knobs).to_have_count(2) + _move_mouse(page, 88, 60) + _press(page, "+") + expect(knobs).to_have_count(3) + _click(page, 88, 60) + _press(page, "Enter") + expect(knobs).to_have_count(0) + + coordinates = _saved_section_coordinates( + page, + resource_manager, + test_data_tmp_dir / "inserted.otconfig", + NORTH_SECTION, + ) + assert len(coordinates) == 3 + assert coordinates[0] == (22, 22) + assert coordinates[2] == (154, 66) + + _edit_geometry_of(page, resource_manager, NORTH_SECTION) + expect(knobs).to_have_count(3) + _click(page, *coordinates[1]) + _press(page, "Delete") + expect(knobs).to_have_count(2) + _press(page, "Enter") + expect(knobs).to_have_count(0) + + coordinates = _saved_section_coordinates( + page, + resource_manager, + test_data_tmp_dir / "deleted.otconfig", + NORTH_SECTION, + ) + assert coordinates == [(22, 22), (154, 66)] From 1577631f5ecaff0d1454fd7d424426c11b8fa707 Mon Sep 17 00:00:00 2001 From: Lars Briem Date: Fri, 16 Oct 2026 19:26:10 +0200 Subject: [PATCH 6/7] Update OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/section_geometry.py Co-authored-by: Jan <83372587+schubsen@users.noreply.github.com> --- .../pages/canvas_and_files_form/section_geometry.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/section_geometry.py b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/section_geometry.py index 5e78fbab4..08ed00aaf 100644 --- a/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/section_geometry.py +++ b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/section_geometry.py @@ -67,10 +67,11 @@ def _projection(start: Point, end: Point, point: Point) -> float: """ dx = end[0] - start[0] dy = end[1] - start[1] - length = dx**2 + dy**2 - if length == 0: + squared_length = dx**2 + dy**2 + if squared_length == 0: return 0.0 - return ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / length + algebraic_dot = (point[0] - start[0]) * dx + (point[1] - start[1]) * dy + return algebraic_dot / squared_length def _squared_distance_to_segment(start: Point, end: Point, point: Point) -> float: From 13113c95dd39dc995141671a2cf2332a2caf00e8 Mon Sep 17 00:00:00 2001 From: Lars Briem Date: Fri, 16 Oct 2026 21:23:35 +0200 Subject: [PATCH 7/7] OP#10515: use marker instead of name. --- tests/acceptance/test_sections_and_flows.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/acceptance/test_sections_and_flows.py b/tests/acceptance/test_sections_and_flows.py index 3a3b72d86..c355fb6fc 100644 --- a/tests/acceptance/test_sections_and_flows.py +++ b/tests/acceptance/test_sections_and_flows.py @@ -284,9 +284,7 @@ def _open_sections_of_preconfigured_project( Path(__file__).parents[1] / "data" / "sections_created_test_file.otconfig" ) setup_with_preconfigured_otconfig(page, resource_manager, otconfig_path) - page.get_by_text( - resource_manager.get(FlowAndSectionKeys.TAB_SECTION), exact=True - ).click() + search_for_marker_element(page, MARKER_TAB_SECTION).first.click() _dismiss_notifications(page)