diff --git a/OTAnalytics/application/resources/resource_manager.py b/OTAnalytics/application/resources/resource_manager.py index 04d6783d2..c18b6c2e0 100644 --- a/OTAnalytics/application/resources/resource_manager.py +++ b/OTAnalytics/application/resources/resource_manager.py @@ -23,6 +23,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): @@ -144,6 +145,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): @@ -212,6 +218,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", @@ -324,10 +333,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/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 d030aa2e7..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 @@ -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 @@ -17,27 +17,50 @@ ) 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, + 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 NORMAL_COLOR = "green" 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" 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" +RELEASE_FOCUS = "document.activeElement?.blur()" +NOTIFICATION_TYPE_WARNING: Final = "warning" POINTER_EVENT_ALL = "all" POINTER_EVENT_NONE = "" IMAGE_X = "image_x" @@ -45,34 +68,83 @@ 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 +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_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 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) -def create_moving_circle(e: dict, fill: str = NORMAL_COLOR) -> Circle: - return Circle( - x=round(e[IMAGE_X]), - y=round(e[IMAGE_Y]), +def svg_event_to_point(e: dict) -> Point: + return to_point(e[IMAGE_X], 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): @@ -90,10 +162,19 @@ 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._enabled = True 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 +191,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,39 +203,146 @@ 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 _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) + 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._accepts_input() 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._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._accepts_input(): + 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: 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) @@ -164,17 +352,42 @@ 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. + + 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() + + 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( @@ -188,35 +401,107 @@ 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: + 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: + 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: - 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 self._new_area_section: - coordinates.append(coordinates[0]) - 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 += point.to_svg() 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 = 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( + 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.""" + if not self._accepts_input(): + return + point = to_point(e.image_x, e.image_y) + if e.type == MOUSE_MOVE: + self._on_move(point) + elif e.type == MOUSE_UP: + self._on_release(point) + elif e.type == CLICK: + if self._current_section is not None: + self._on_canvas_click(point) + else: + self._on_pointer_down(e) + + def _on_move(self, point: Point) -> None: + if self._new_section: + 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: @@ -224,7 +509,7 @@ def _on_pointer_down(self, e: events.MouseEventArguments) -> None: 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)}", @@ -233,27 +518,34 @@ 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 or not self._enabled: + 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() + if self._accepts_input(): + 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() + 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() @@ -272,11 +564,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, @@ -294,7 +581,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( @@ -308,10 +596,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) - if self._current_point: - self.draw_current_point() + 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( @@ -320,7 +619,21 @@ 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._enabled = True 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._release_focus() self.draw_all() def draw_arrow( @@ -334,15 +647,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: @@ -357,8 +674,16 @@ 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 self._new_area_section = is_area_section + 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]: return 0, 0 @@ -367,10 +692,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/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..08ed00aaf --- /dev/null +++ b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/section_geometry.py @@ -0,0 +1,184 @@ +"""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]] + + +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] + squared_length = dx**2 + dy**2 + if squared_length == 0: + return 0.0 + 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: + 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/acceptance/test_sections_and_flows.py b/tests/acceptance/test_sections_and_flows.py index 132dd7f61..c355fb6fc 100644 --- a/tests/acceptance/test_sections_and_flows.py +++ b/tests/acceptance/test_sections_and_flows.py @@ -1,19 +1,30 @@ +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.container import ( MARKER_TAB_SECTION, ) @@ -23,6 +34,9 @@ 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, @@ -219,3 +233,178 @@ 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) + search_for_marker_element(page, MARKER_TAB_SECTION).first.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)] 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 86de904af..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 @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Iterator from unittest.mock import MagicMock, Mock, patch import pytest @@ -9,28 +9,39 @@ 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 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, EDIT_COLOR, + EDIT_KNOB_ID_PREFIX, ELEMENT_ID, + HOVERED_KNOB_COLOR, IMAGE_X, IMAGE_Y, - MOVING_COLOR, + MOUSE_MOVE, + MOUSE_UP, NORMAL_COLOR, + RELEASE_FOCUS, SELECTED_COLOR, CanvasForm, circle_to_coordinates, - create_circle, - create_moving_circle, ) @@ -73,6 +84,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 @@ -109,7 +125,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 @@ -117,45 +133,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 = [ @@ -461,84 +438,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.""" @@ -688,6 +587,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 +1005,742 @@ 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_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" + ) + 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 + + +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_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 + 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 + + +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 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..811d5e487 --- /dev/null +++ b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_section_geometry.py @@ -0,0 +1,190 @@ +import pytest + +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: + 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 + + +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:]