From 4f6564c2a740cce3b00ddc364854d6c0456cc518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20S=C3=A1nchez?= Date: Sun, 25 Dec 2022 02:03:04 +0100 Subject: [PATCH] Multi-line direct support, input handling and error solving (#2) * Rename module * Update requirements.txt * Remove debugging statement * Solve repeated coordinate issue Solved an issue that caused spline generation to fail when two consecutive equal coordinates were drawn. * Update warning message * Add comments * Add input handling method Added a method that allows extensive input handling, including input from the command line interface and adding standard Python input as fallback method. * Relocate modules * Distribute configuration constants * Prevent spline generation error on empty line * Add connection-related methods Added methods that allow connection and disconnection from the `matplotlib` figure. Also added a status check. * Add support for line switching Added line switching functionality via numeric keyboard input. This prevents the requirement to close and reopen the plot on every new line, as well as the single-line-editing restriction. * Set constant axis * Distribute calback functions * Rename variables and organize code * Update title styles * Fix imports * Update line index handling and display Updated the whole line index handling process since displaying line indices from 1 would be more intuitive than displaying them from 0. --- src/coordinate-canvas/__main__.py | 164 ++++++++---------- src/coordinate-canvas/config.py | 16 -- src/coordinate-canvas/core/config.py | 16 ++ src/coordinate-canvas/core/input_handler.py | 96 ++++++++++ .../{ => core}/line_builder.py | 48 +++-- 5 files changed, 223 insertions(+), 117 deletions(-) delete mode 100644 src/coordinate-canvas/config.py create mode 100644 src/coordinate-canvas/core/config.py create mode 100644 src/coordinate-canvas/core/input_handler.py rename src/coordinate-canvas/{ => core}/line_builder.py (72%) diff --git a/src/coordinate-canvas/__main__.py b/src/coordinate-canvas/__main__.py index 6460a32..f7342b7 100644 --- a/src/coordinate-canvas/__main__.py +++ b/src/coordinate-canvas/__main__.py @@ -15,123 +15,107 @@ import json +import sys from itertools import cycle import matplotlib.pyplot as plt -from bidimensional import Coordinate -from bidimensional.functions import Spline -from .config import CONFIG -from .line_builder import LineBuilder +from .core.config import COLORS, POSITIONS +from .core import input_handler as ih +from .core.line_builder import LineBuilder -def validate_input(message: str) -> float: - """Input validation method. +# Constants' definition: - This method validates a given input. If the input is not numeric, then a - ValueError is raised. +COLORS = cycle(COLORS) +AX = plt.gca() +FIG = plt.gcf() - Args: - message (str): The message to be displayed to the user. - Raises: - ValueError: If the input is not numeric. +# Methods' definition: - Returns: - float: The validated input. - """ +def decide(event): - value = input(message) + if event.key.isnumeric() and 1 <= int(event.key) <= LINE_COUNT: + lines[current_data[1]].get("line_builder").disconnect() + lines[int(event.key) - 1].get("line_builder").connect() - if not value.isnumeric(): - raise ValueError("the input value must be numeric") + current_data[0] = lines[int(event.key) - 1].get("line") + current_data[1] = int(event.key) - 1 - return float(value) + FIG.suptitle( + f"Click to add points for line number {current_data[1] + 1}...", + fontsize="large", fontweight="bold" + ) -# Constants' definition: +def close(event): + if event.key == "escape": + exit(0) -COLORS = cycle(CONFIG.get("colors")) # Parameter input: -width = validate_input("Enter width: ") -height = validate_input("Enter height: ") -line_count = int(validate_input("Enter the number of lines to draw: ")) +input_data = ih.cli_input(sys.argv) + +if input_data is None: + input_data = ( + ih.python_input("Width: "), + ih.python_input("Height: "), + ih.python_input("Number of lines to draw: ") + ) + +WIDTH, HEIGHT, LINE_COUNT = ih.output_format(input_data) + +# Constants' configuration: + +FIG.canvas.mpl_connect("key_press_event", decide) +FIG.canvas.mpl_connect("key_release_event", close) + +plt.grid(True) +AX.set_xlim(0, WIDTH) +AX.set_ylim(0, HEIGHT) # Data output template: data = { - f"line_{index + 1}": { + f"line_{index}": { "x": [], "y": [] - } for index in range(line_count) + } for index in range(1, LINE_COUNT + 1) } -# Main loop: - -color_cache = [] -for index in range(line_count): - color_cache.append(next(COLORS)) - - # Figure setup: - - fig, ax = plt.subplots() - plt.grid(True) - ax.set_xlim(0, width) - ax.set_ylim(0, height) - ax.set_title(f"Click to add points for line number {index + 1}...") - - # Previous drawings' plotting: - - if index > 0: - for i in range(index): - sub_color = color_cache[i] - - coordinates = [ - Coordinate(x_, y_) - for x_, y_ in zip( - data[f"line_{i + 1}"]['x'], - data[f"line_{i + 1}"]['y'] - ) - ] - - sp = Spline( - coordinates, - gen_step=min(width, height) / 1000 - ) - - sp.plot_input( - CONFIG.get("input").get("shape"), - ms=CONFIG.get("input").get("size"), - alpha=CONFIG.get("input").get("alpha"), - color=f"dark{sub_color}", - ) - - sp.plot_positions( - CONFIG.get("positions").get("shape"), - lw=CONFIG.get("positions").get("size"), - alpha=CONFIG.get("positions").get("alpha"), - color=sub_color - ) - - # Line drawing and display: - - line, = ax.plot( - [], [], - CONFIG.get("positions").get("shape"), - lw=CONFIG.get("positions").get("size") * 2, # Highlights the line. - alpha=CONFIG.get("positions").get("alpha"), - color=color_cache[-1] - ) - builder = LineBuilder(line, ax, width, height, color_cache[-1]) - - plt.show() - - # Data storage: - - data[f"line_{index + 1}"]['x'].extend(builder.x) - data[f"line_{index + 1}"]['y'].extend(builder.y) +lines = [ + { + "color": (color := next(COLORS)), + "line": (line := AX.plot( + [], [], + POSITIONS.get("shape"), + lw=POSITIONS.get("size"), + alpha=POSITIONS.get("alpha"), + color=color + )[0]), + "line_builder": LineBuilder(line, AX, WIDTH, HEIGHT, color) + } + for _ in range(LINE_COUNT) +] + +# Initial connection and setting: + +current_data = [lines[0].get("line"), 0] +lines[0].get("line_builder").connect() +FIG.suptitle("Click to add points for line number 1...", + fontsize="large", fontweight="bold") +AX.set_title(f"Press keys 1 - {LINE_COUNT} to change lines or ESC to exit", + fontsize="medium", fontstyle="italic") + +plt.show() + +# Data storage: + +for index in range(LINE_COUNT): + data[f"line_{index + 1}"]['x'].extend(lines[index].get("line_builder").x) + data[f"line_{index + 1}"]['y'].extend(lines[index].get("line_builder").y) # Data output: diff --git a/src/coordinate-canvas/config.py b/src/coordinate-canvas/config.py deleted file mode 100644 index 8bd381b..0000000 --- a/src/coordinate-canvas/config.py +++ /dev/null @@ -1,16 +0,0 @@ -CONFIG = { - "colors": [ - "red", "green", "salmon", "blue", - "orange", "violet", "goldenrod", "gray" - ], - "input": { - "shape": "o", - "size": 5, - "alpha": 1 - }, - "positions": { - "shape": "-", - "size": 1, - "alpha": 0.5 - } -} diff --git a/src/coordinate-canvas/core/config.py b/src/coordinate-canvas/core/config.py new file mode 100644 index 0000000..8692753 --- /dev/null +++ b/src/coordinate-canvas/core/config.py @@ -0,0 +1,16 @@ +COLORS = [ + "red", "green", "salmon", "blue", + "orange", "violet", "goldenrod", "gray" +] + +INPUT = { + "shape": "o", + "size": 5, + "alpha": 1 +} + +POSITIONS = { + "shape": "-", + "size": 1, + "alpha": 0.5 +} diff --git a/src/coordinate-canvas/core/input_handler.py b/src/coordinate-canvas/core/input_handler.py new file mode 100644 index 0000000..2eec897 --- /dev/null +++ b/src/coordinate-canvas/core/input_handler.py @@ -0,0 +1,96 @@ +"""Input handling module. + +This module contains all methods needed to handle the input of the package. +It is mean to be used internally by the package and not by the user, since +method implementations are highly specific to the package's needs. + +Author: + Paulo Sanchez (@erlete) +""" + + +import re + + +def output_format(values: tuple[str]) -> tuple[float, float, int] | None: + """Formats the output of the input. + + This method receives a tuple of strings and validates them. If the + amount of values is less than 3, then a ValueError is raised. If any + of the values is not a string, then a TypeError is raised. Otherwise, + the formatted input values are returned. + + The method is meant to be used in combination with the `cli_input` + and the `python_input` methods. + + Args: + values (tuple): The input values. + + Raises: + ValueError: If the input does not have at least 3 values. + TypeError: If any of the input values is not a string. + + Returns: + tuple[float, float, int]: The formatted input values. + """ + + if len(values) < 3: + raise ValueError("the input must have at least 3 values") + + if any(not isinstance(value, str) for value in values): + raise TypeError("the input values must be strings") + + return (float(values[0]), float(values[1]), int(float(values[2]))) + + +def cli_input(arguments: list[str]) -> tuple[str, str, str] | None: + """Handles CLI input. + + This class receives a list of arguments and validates them. If the + amount of arguments is less than 4, then None is returned. If any of + the arguments is not numeric, then None is returned. Otherwise, the + raw input values are returned. + + Args: + arguments (list[str]): The list of arguments from `sys.argv`. + + Returns: + tuple[str, str, str] | None: The raw input values or None. + """ + + if len(arguments) < 4: + return None + + values = arguments[1:4] + + if any(not (bool(re.match(r"\d+\.\d+", value)) + or value.isnumeric()) for value in values): + + return None + + return values + + +def python_input(message: str) -> str: + """Handles Python input. + + This class receives a message and validates the input. If the input + is not numeric, then a ValueError is raised. Otherwise, the input is + returned. + + Args: + message (str): The message to be displayed to the user. + + Raises: + ValueError: If the input is not numeric. + + Returns: + str: The validated input or None. + """ + + value = input(message) + + if not (bool(re.match(r"\d+\.\d+", value)) or value.isnumeric()): + raise ValueError("the input must be numeric") + + return value diff --git a/src/coordinate-canvas/line_builder.py b/src/coordinate-canvas/core/line_builder.py similarity index 72% rename from src/coordinate-canvas/line_builder.py rename to src/coordinate-canvas/core/line_builder.py index 9c1190d..5234941 100644 --- a/src/coordinate-canvas/line_builder.py +++ b/src/coordinate-canvas/core/line_builder.py @@ -12,7 +12,7 @@ from bidimensional import Coordinate from bidimensional.functions import Spline -from .config import CONFIG +from .config import INPUT class LineBuilder: @@ -30,8 +30,6 @@ class LineBuilder: color (str): The color of the line. """ - CONFIG = CONFIG - def __init__(self, line: matplotlib.lines.Line2D, ax: matplotlib.axes.Axes, width: float, height: float, color: str) -> None: @@ -42,10 +40,38 @@ def __init__(self, line: matplotlib.lines.Line2D, self.color = color self.x, self.y = list(line.get_xdata()), list(line.get_ydata()) - - self.cid = line.figure.canvas.mpl_connect("button_press_event", self) + self.cid = None self.line.figure.canvas.draw() + def is_connected(self) -> bool: + """Checks if the line builder is connected. + + This method checks if the line builder is connected to the matplotlib + plot. + + Returns: + bool: True if the line builder is connected, False otherwise. + """ + + return self.cid is not None + + def connect(self) -> None: + """Connects the line builder. + + This method connects the line builder to the matplotlib plot. + """ + + self.cid = self.line.figure.canvas.mpl_connect("button_press_event", + self) + + def disconnect(self) -> None: + """Disconnects the line builder. + + This method disconnects the line builder from the matplotlib plot. + """ + + self.line.figure.canvas.mpl_disconnect(self.cid) + def __call__(self, event: matplotlib.backend_bases.MouseEvent) -> None: """Click event handler. @@ -79,9 +105,9 @@ def __call__(self, event: matplotlib.backend_bases.MouseEvent) -> None: y = [y_ for _, y_ in sp.positions] sp.plot_input( - CONFIG.get("input").get("shape"), - ms=CONFIG.get("input").get("size"), - alpha=CONFIG.get("input").get("alpha"), + INPUT.get("shape"), + ms=INPUT.get("size"), + alpha=INPUT.get("alpha"), color=f"dark{self.color}", ) @@ -95,9 +121,9 @@ def __call__(self, event: matplotlib.backend_bases.MouseEvent) -> None: else: self.ax.plot( event.xdata, event.ydata, - CONFIG.get("input").get("shape"), - lw=CONFIG.get("input").get("size"), - alpha=CONFIG.get("input").get("alpha"), + INPUT.get("shape"), + lw=INPUT.get("size"), + alpha=INPUT.get("alpha"), color=f"dark{self.color}" )