import random from PyQt5.QtCore import QEvent, Qt from PyQt5.QtGui import QCursor from .algorithms import Algorithms from .colors import Color from .exceptions import ExceededWindowBoundsError from .mode import Mode from .opengl_widget import (set_drawing_event, set_move_bb_top_left, set_move_bb_bottom_right, reset_move_bbs, viewport_height, viewport_width) from .points import PointSet from .point_manager import PointManager class __ClickFlag: # This is the first stage. On mouse release it goes to # SELECTION_MOVE. NONE = 0 # We are now in selection box mode. SELECTION_BOX = 1 # Second stage - we have selected a number of points # and now we are going to track the left mouse button # to translate those points. After a left click # this moves to SELECTED_MOVED. SELECTION_MOVE = 2 # Any subsequent click in this mode will send it back # to NONE - we are done. SELECTED_MOVED = 3 # Size of point for drawing __POINT_SIZE = 8 # Canvas pixel border - empirical, not sure where this is stored officially __CANVAS_BORDER = 1 # PointManager is a class that is filled with static methods # designed for managing state. PointManager.point_set = PointSet(__POINT_SIZE, viewport_height(), viewport_width()) # Module level flag for left click events (used to detect a left # click hold drag) __left_click_flag = __ClickFlag.NONE # Variable to track the mouse state during selection movement __last_mouse_pos = None # Used to implement mouse dragging when clicked __left_click_down = False # TODO: WHEN THE GROUPING ENDS AND THE USER CANCELS THE CENTROID COUNT # SHOULD BE ZEROED AND REMAINING COLORS SHOULD BE REPOPULATED. # Count of centroids for comparison with the spin widget __centroid_count = 0 __remaining_colors = [c for c in Color if c not in [Color.BLUE, Color.GREY]] def refresh_point_list(ctx): """ Refreshes the point list display. @param ctx A handle to the window context. """ # In order to make some guarantees and avoid duplicate # data we will clear the point list widget and re-populate # it using the current __point_set. ctx.point_list_widget.clear() for p in PointManager.point_set.points: ctx.point_list_widget.addItem("({}, {})".format(p.x, p.y)) ctx.point_list_widget.update() def __handle_add_point(ctx, event): """ Event handler for the add point mode. Sets the drawing mode for the OpenGL Widget using `set_drawing_mode`, converts a point to our point representation, and adds it to the list. @param ctx A context handle to the main window. @param event The click event. """ # Update information as needed __handle_info_updates(ctx, event) if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonPress): # At this point we can be sure resize_gl has been called # at least once, so set the viewport properties of the # point set so it knows the canvas bounds. PointManager.point_set.viewport_width = viewport_width() PointManager.point_set.viewport_height = viewport_height() # Clear any existing selections PointManager.point_set.clear_selection() try: # No attribute at the moment, default point color is Color.GREY. PointManager.point_set.add_point(event.x(), event.y(), Color.GREY) except ExceededWindowBoundsError: # The user tried to place a point whos edges would be # on the outside of the window. We will just ignore it. return refresh_point_list(ctx) set_drawing_event(event) ctx.opengl_widget.update() ctx.point_list_widget.update() def __handle_edit_point(ctx, event): # TODO: This function and delete definitely need to make sure they are # on a point we have. # # Since points are unique consider a hashmap of points to make O(1) # lookups for addition and deletion. This list can be maintained here # in this module. It should be a dictionary - from point to # attributes in the case of algorithms that require points to have # weights or something. # # Should move the associated point in the list to the new location if # applicable. __handle_info_updates(ctx, event) PointManager.point_set.clear_selection() # Store old x, y from event set_drawing_event(event) ctx.update() # after this remove the point from the list def ogl_keypress_handler(ctx, event): """ A keypress handler attached to the OpenGL widget. It primarily exists to allow the user to cancel selection. Also allows users to escape from modes. @param ctx A handle to the window context. @param event The event associated with this handler. """ global __left_click_flag global __last_mouse_pos if event.key() == Qt.Key_Escape: if ctx.mode is Mode.MOVE: if __left_click_flag is not __ClickFlag.NONE: __last_mouse_pos = None __left_click_flag = __ClickFlag.NONE PointManager.point_set.clear_selection() reset_move_bbs() refresh_point_list(ctx) elif ctx.mode is not Mode.OFF: ctx.mode = Mode.OFF # Also change the mouse back to normal ctx.opengl_widget.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) ctx.status_bar.showMessage("") ctx.opengl_widget.update() def __handle_move_points(ctx, event): """ A relatively complicated state machine that handles the process of selection, clicking, and dragging. @param ctx The context to the window. @param event The event. """ global __left_click_flag global __left_mouse_down global __last_mouse_pos set_drawing_event(event) __handle_info_updates(ctx, event) # If we release the mouse, we want to quickly alert drag mode. if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonRelease): __left_mouse_down = False # This if statement block is used to set the bounding box for # drawing and call the selection procedure. if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonPress): __left_mouse_down = True if __left_click_flag is __ClickFlag.NONE: __left_click_flag = __ClickFlag.SELECTION_BOX set_move_bb_top_left(event.x(), event.y()) elif (__left_click_flag is __ClickFlag.SELECTION_BOX and __left_mouse_down): # We are now in the click-and-hold to signal move # tracking and translation __left_click_flag = __ClickFlag.SELECTION_MOVE __last_mouse_pos = (event.x(), event.y()) # Post-selection handlers if (__left_click_flag is __ClickFlag.SELECTION_BOX and event.type() == QEvent.MouseMove): set_move_bb_bottom_right(event.x(), event.y()) elif (__left_click_flag is __ClickFlag.SELECTION_MOVE and __last_mouse_pos is not None and __left_mouse_down and event.type() == QEvent.MouseMove): dx = abs(__last_mouse_pos[0] - event.x()) dy = abs(__last_mouse_pos[1] - event.y()) for p in PointManager.point_set.points: if p.selected: # Use the deltas to decide what direction to move. # We only want to move in small unit increments. # If we used the deltas directly the points would # fly off screen quickly as we got farther from our # start. try: if event.x() < __last_mouse_pos[0]: p.move(-dx, 0) if event.y() < __last_mouse_pos[1]: p.move(0, -dy) if event.x() > __last_mouse_pos[0]: p.move(dx, 0) if event.y() > __last_mouse_pos[1]: p.move(0, dy) except ExceededWindowBoundsError: # This point has indicated a move would exceed # it's bounds, so we'll just go to the next # point. continue __last_mouse_pos = (event.x(), event.y()) elif (__left_click_flag is not __ClickFlag.NONE and event.type() == QEvent.MouseButtonRelease): if __left_click_flag is __ClickFlag.SELECTION_BOX: set_move_bb_bottom_right(event.x(), event.y()) # Satisfy the post condition by resetting the bounding box reset_move_bbs() ctx.opengl_widget.update() def __handle_delete_point(ctx, event): __handle_info_updates(ctx, event) if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonPress): set_drawing_event(event) PointManager.point_set.remove_point(event.x(), event.y()) refresh_point_list(ctx) ctx.opengl_widget.update() ctx.point_list_widget.update() def __handle_info_updates(ctx, event): """ Updates data under the "information" header. @param ctx The context to the main window. @param event The event. """ if event.type() == QEvent.MouseMove: ctx.mouse_position_label.setText(f"{event.x(), event.y()}") def __handle_choose_centroids(ctx, event): """ Similar to move in terms of selecting points, however this function assigns a random color up to the maximum number of centroids, and after the maximum number has been selected it will enable the group button. """ global __centroid_count global __remaining_colors __handle_info_updates(ctx, event) if __centroid_count == ctx.number_of_centroids.value(): # We have specified the number of centroids required return if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonPress): point = None for test_point in PointManager.point_set.points: if test_point.hit(event.x(), event.y()): point = test_point if point is None: # No point was found on the click, do nothing return if point in PointManager.centroids: # Centroids must be unique return __centroid_count += 1 color = random.choice(__remaining_colors) __remaining_colors.remove(color) point.color = color # Recolor the point and restash the point in centroids PointManager.centroids.append(point) if __centroid_count == ctx.number_of_centroids.value(): # Prevent the user from changing the centroids ctx.number_of_centroids.setEnabled(False) ctx.choose_centroids_button.setEnabled(False) ctx.group_button.setEnabled(True) ctx.opengl_widget.update() def group(ctx): """ Group handler. Basically just a wrapper around the distance grouping algorithm that recolors points. This is one of the only functions that operates only on a button click and as a result is made public since the dispatcher is not needed. """ groups = Algorithms.euclidean_grouping(PointManager.centroids, PointManager.point_set) for centroid_group in groups: for point in centroid_group.points: point.color = centroid_group.centroid.color ctx.opengl_widget.update() def reset_centroid_count_and_colors(): global __centroid_count global __remaining_colors __centroid_count = 0 __remaining_colors = [c for c in Color if c not in [Color.BLUE, Color.GREY]] for point in PointManager.point_set.points: point.color = Color.GREY # Simple dispatcher to make it easy to dispatch the right mode # function when the OpenGL window is acted on. MODE_HANDLER_MAP = { Mode.OFF: __handle_info_updates, Mode.LOADED: __handle_info_updates, Mode.ADD: __handle_add_point, Mode.EDIT: __handle_edit_point, Mode.MOVE: __handle_move_points, Mode.DELETE: __handle_delete_point, Mode.CHOOSE_CENTROIDS: __handle_choose_centroids }