From 925c750649f7ab8517db1b5024975fe46864bd0f Mon Sep 17 00:00:00 2001 From: Tnixc Date: Fri, 19 Sep 2025 19:40:15 -0400 Subject: [PATCH 01/12] feat: initial implementation for curve editor --- config-create/CurveWidget.py | 381 +++++++++++++++++++++++++++++++++++ config-create/main.py | 43 ++++ 2 files changed, 424 insertions(+) create mode 100644 config-create/CurveWidget.py create mode 100644 config-create/main.py diff --git a/config-create/CurveWidget.py b/config-create/CurveWidget.py new file mode 100644 index 0000000..cf2a7a9 --- /dev/null +++ b/config-create/CurveWidget.py @@ -0,0 +1,381 @@ +# from __future__ import print_function + +import PyQt6.QtGui as QtGui +import PyQt6.QtWidgets as QtWidgets + + +class Curve: + """Interface to linear interpolation between control points""" + + def __init__(self, x_max=1400, y_min=25, y_max=1200): + # Axis scale values + self.x_max = x_max + self.y_min = y_min + self.y_max = y_max + + self._cv_points = [ + [0.0, 25.0], + [200.0, 1100.0], + [1200.0, 1100.0], + [1400.0, 25.0], + ] + + # Sort points by x coordinate for linear interpolation + self.build_curve() + + def set_axis_scales(self, x_max, y_min, y_max): + """Update axis scale values""" + self.x_max = x_max + self.y_min = y_min + self.y_max = y_max + + def get_cv_points(self): + """Returns a list of all controll points""" + return self._cv_points + + def build_curve(self): + """Sort control points by x coordinate for linear interpolation""" + # Sort points by x coordinate for proper linear interpolation + self._cv_points = sorted(self._cv_points, key=lambda v: v[0]) + + def set_cv_value(self, index, x_value, y_value): + """Updates the cv point at the given index""" + self._cv_points[index] = [x_value, y_value] + + def add_cv_point(self, x_value, y_value): + """Adds a new control point and rebuilds the curve""" + self._cv_points.append([x_value, y_value]) + self.build_curve() + + def get_value(self, offset): + """Returns the value using linear interpolation between control points. + offset should be from 0 to 1, returns a value from 0 to 1.""" + if not self._cv_points: + return 0.0 + + # Ensure points are sorted by x coordinate + sorted_points = sorted(self._cv_points, key=lambda v: v[0]) + + # Handle edge cases + if offset <= sorted_points[0][0]: + return sorted_points[0][1] + if offset >= sorted_points[-1][0]: + return sorted_points[-1][1] + + # Find the two points to interpolate between + for i in range(len(sorted_points) - 1): + x1, y1 = sorted_points[i] + x2, y2 = sorted_points[i + 1] + + if x1 <= offset <= x2: + # Linear interpolation + if x2 == x1: # Avoid division by zero + return y1 + t = (offset - x1) / (x2 - x1) + return y1 + t * (y2 - y1) + + return 0.0 + + +class CurveWidget(QtWidgets.QWidget): + """This is a resizeable Widget which shows an editable curve which can + be modified.""" + + def __init__(self, parent): + """Constructs the CurveWidget, we start with an initial curve""" + super().__init__(parent) + + # Single curve + self.curve = Curve() + + # Widget render constants + self._cv_point_size = 6 + self._legend_border = 35 + self._control_bar_height = 40 + self._padding = 12 + + # Currently dragged control point, format is: + # (PointIndex, Drag-Offset (x,y)) + self._drag_point = None + + # Currently selected control point index + self._selected_point = None + + # Create control widgets + self._create_controls() + + def _create_controls(self): + """Create the control widgets for axis scaling""" + # X-axis max control + self.x_max_label = QtWidgets.QLabel("X Max:") + self.x_max_spinbox = QtWidgets.QSpinBox() + self.x_max_spinbox.setRange(100, 10000) + self.x_max_spinbox.setValue(1400) + self.x_max_spinbox.valueChanged.connect(self._update_scales) + + # Y-axis min control + self.y_min_label = QtWidgets.QLabel("Y Min:") + self.y_min_spinbox = QtWidgets.QSpinBox() + self.y_min_spinbox.setRange(0, 1000) + self.y_min_spinbox.setValue(25) + self.y_min_spinbox.valueChanged.connect(self._update_scales) + + # Y-axis max control + self.y_max_label = QtWidgets.QLabel("Y Max:") + self.y_max_spinbox = QtWidgets.QSpinBox() + self.y_max_spinbox.setRange(100, 10000) + self.y_max_spinbox.setValue(1200) + self.y_max_spinbox.valueChanged.connect(self._update_scales) + + def _update_scales(self): + """Update the curve axis scales when controls change""" + x_max = self.x_max_spinbox.value() + y_min = self.y_min_spinbox.value() + y_max = self.y_max_spinbox.value() + + # Ensure y_max > y_min + if y_max <= y_min: + y_max = y_min + 100 + self.y_max_spinbox.setValue(y_max) + + self.curve.set_axis_scales(x_max, y_min, y_max) + self.update() + + def paintEvent(self, e): + """Internal QT paint event, draws the entire widget""" + qp = QtGui.QPainter() + qp.begin(self) + self._draw(qp) + qp.end() + + def mousePressEvent(self, QMouseEvent): + """Internal mouse-press handler""" + self._drag_point = None + self._selected_point = None + mouse_pos = QMouseEvent.pos() + mouse_x = mouse_pos.x() + mouse_y = mouse_pos.y() + + for cv_index, (x, y) in enumerate(self.curve.get_cv_points()): + point_x = self._get_x_value_for(x) + point_y = self._get_y_value_for(y) + + # Check if mouse is close to this control point + if abs(point_x - mouse_x) < self._cv_point_size + 4: + if abs(point_y - mouse_y) < self._cv_point_size + 4: + # Store the offset from mouse to point center in screen coordinates + drag_x_offset = point_x - mouse_x + drag_y_offset = point_y - mouse_y + self._drag_point = (cv_index, (drag_x_offset, drag_y_offset)) + self._selected_point = cv_index + break + + self.update() + + def mouseReleaseEvent(self, QMouseEvent): + """Internal mouse-release handler""" + self._drag_point = None + + def mouseDoubleClickEvent(self, QMouseEvent): + """Internal mouse-double-click handler - adds new control point""" + mouse_pos = QMouseEvent.pos() + mouse_x = mouse_pos.x() - self._legend_border - self._padding + mouse_y = mouse_pos.y() - self._padding + + # Convert to coordinate range using current axis scales + local_x = max(0, min(self.curve.x_max, mouse_x / float(self.width() - self._legend_border - 2 * self._padding) * self.curve.x_max)) + local_y = max( + self.curve.y_min, + min( + self.curve.y_max, + (1 - mouse_y / float(self.height() - self._legend_border - self._control_bar_height - 2 * self._padding)) + * (self.curve.y_max - self.curve.y_min) + + self.curve.y_min, + ), + ) + + # Add new control point + self.curve.add_cv_point(local_x, local_y) + self.update() + + def mouseMoveEvent(self, QMouseEvent): + """Internal mouse-move handler""" + if self._drag_point is not None: + # Get current mouse position in screen coordinates + mouse_pos = QMouseEvent.pos() + + # Apply the drag offset to get the desired point position in screen coordinates + point_screen_x = mouse_pos.x() + self._drag_point[1][0] + point_screen_y = mouse_pos.y() + self._drag_point[1][1] + + # Convert from screen coordinates to graph coordinates + graph_x = point_screen_x - self._legend_border - self._padding + graph_y = point_screen_y - self._padding + + # Get graph dimensions + graph_width = self.width() - self._legend_border - 2 * self._padding + graph_height = self.height() - self._legend_border - self._control_bar_height - 2 * self._padding + + # Convert to data coordinates using current axis scales + # X coordinate: normalize by graph width, then scale to x_max + local_x = max(0, min(self.curve.x_max, graph_x / float(graph_width) * self.curve.x_max)) + + # Y coordinate: flip Y axis (screen Y increases downward, data Y increases upward) + # then normalize and scale to y range + normalized_y = 1.0 - (graph_y / float(graph_height)) + local_y = max(self.curve.y_min, min(self.curve.y_max, normalized_y * (self.curve.y_max - self.curve.y_min) + self.curve.y_min)) + + # Update the control point + self.curve.set_cv_value(self._drag_point[0], local_x, local_y) + + # Rebuild curve and update display + self.curve.build_curve() + self.update() + + def _get_y_value_for(self, local_value): + """Converts a value from y_min-y_max to canvas height""" + y_range = self.curve.y_max - self.curve.y_min + normalized_value = (local_value - self.curve.y_min) / y_range # Normalize to 0-1 + normalized_value = max(0, min(1.0, 1.0 - normalized_value)) # Flip Y and clamp + local_value = ( + normalized_value * (self.height() - self._legend_border - self._control_bar_height - 2 * self._padding) + self._padding + ) + return local_value + + def _get_x_value_for(self, local_value): + """Converts a value from 0-x_max to canvas width""" + normalized_value = local_value / self.curve.x_max # Normalize to 0-1 + normalized_value = max(0, min(1.0, normalized_value)) + local_value = normalized_value * (self.width() - self._legend_border - 2 * self._padding) + self._legend_border + self._padding + return local_value + + def _draw(self, painter): + """Internal method to draw the widget""" + + canvas_width = self.width() - self._legend_border - 2 * self._padding + canvas_height = self.height() - self._legend_border - self._control_bar_height - 2 * self._padding + + # Draw field background + palette = self.palette() + painter.setPen(palette.color(QtGui.QPalette.ColorRole.Mid)) + painter.setBrush(palette.color(QtGui.QPalette.ColorRole.Base)) + painter.drawRect(0, 0, self.width() - 1, self.height() - 1) + + # Draw legend + + # Compute amount of horizontal / vertical lines + num_vert_lines = 7 # Adjustable based on x_max + line_spacing_x = (self.width() - self._legend_border - 2 * self._padding) / 7.0 + line_spacing_y = (self.height() - self._legend_border - self._control_bar_height - 2 * self._padding) / 10.0 + num_horiz_lines = 11 # Adjustable based on y range + + # Draw vertical lines + painter.setPen(palette.color(QtGui.QPalette.ColorRole.Window)) + for i in range(num_vert_lines + 1): + line_pos = i * line_spacing_x + self._legend_border + self._padding + painter.drawLine(int(line_pos), self._padding, int(line_pos), canvas_height + self._padding) + + # Draw horizontal lines + painter.setPen(palette.color(QtGui.QPalette.ColorRole.Window)) + for i in range(num_horiz_lines): + line_pos = canvas_height - i * line_spacing_y + self._padding + painter.drawLine(self._legend_border, int(line_pos), self.width(), int(line_pos)) + + # Draw vertical legend labels (Y-axis values) + painter.setPen(palette.color(QtGui.QPalette.ColorRole.Text)) + y_range = self.curve.y_max - self.curve.y_min + for i in range(num_horiz_lines): + line_pos = canvas_height - i * line_spacing_y + self._padding + value = int(self.curve.y_min + (y_range * i / (num_horiz_lines - 1))) + painter.drawText(6, int(line_pos + 3), str(value)) + + # Draw horizontal legend labels (X-axis values) + for i in range(num_vert_lines + 1): + line_pos = i * line_spacing_x + self._legend_border + self._padding + offpos_x = -14 + if i == 0: + offpos_x = -2 + elif i == num_vert_lines: + offpos_x = -33 + value = int(self.curve.x_max * i / num_vert_lines) + painter.drawText(int(line_pos + offpos_x), canvas_height + self._padding + 18, str(value)) + + # Draw control bar background + control_bar_y = canvas_height + self._padding + 25 + painter.setPen(palette.color(QtGui.QPalette.ColorRole.Mid)) + painter.setBrush(palette.color(QtGui.QPalette.ColorRole.Window)) + painter.drawRect(0, control_bar_y, self.width(), self._control_bar_height) + + # Position control widgets + self._position_controls(control_bar_y) + + # Draw curve as straight lines between control points + painter.setPen(palette.color(QtGui.QPalette.ColorRole.Text)) + cv_points = self.curve.get_cv_points() + + # Sort points by x coordinate for drawing + sorted_points = sorted(cv_points, key=lambda v: v[0]) + + # Draw lines between consecutive control points + for i in range(len(sorted_points) - 1): + x1, y1 = sorted_points[i] + x2, y2 = sorted_points[i + 1] + + # Convert to screen coordinates + screen_x1 = self._get_x_value_for(x1) + screen_y1 = self._get_y_value_for(y1) + screen_x2 = self._get_x_value_for(x2) + screen_y2 = self._get_y_value_for(y2) + + painter.drawLine(int(screen_x1), int(screen_y1), int(screen_x2), int(screen_y2)) + + # Draw the CV points of the curve + painter.setBrush(palette.color(QtGui.QPalette.ColorRole.Base)) + + for cv_index, (x, y) in enumerate(self.curve.get_cv_points()): + offs_x = self._get_x_value_for(x) + offs_y = self._get_y_value_for(y) + + if self._selected_point == cv_index: + painter.setPen(palette.color(QtGui.QPalette.ColorRole.Highlight)) + else: + painter.setPen(palette.color(QtGui.QPalette.ColorRole.Dark)) + painter.drawRect( + int(offs_x - self._cv_point_size), int(offs_y - self._cv_point_size), 2 * self._cv_point_size, 2 * self._cv_point_size + ) + + def _position_controls(self, y_pos): + """Position the control widgets at the bottom""" + x_offset = 10 + + # X Max control + self.x_max_label.setParent(self) + self.x_max_label.move(x_offset, y_pos + 10) + self.x_max_label.show() + + self.x_max_spinbox.setParent(self) + self.x_max_spinbox.move(x_offset + 50, y_pos + 8) + self.x_max_spinbox.resize(80, 25) + self.x_max_spinbox.show() + + # Y Min control + x_offset += 150 + self.y_min_label.setParent(self) + self.y_min_label.move(x_offset, y_pos + 10) + self.y_min_label.show() + + self.y_min_spinbox.setParent(self) + self.y_min_spinbox.move(x_offset + 50, y_pos + 8) + self.y_min_spinbox.resize(80, 25) + self.y_min_spinbox.show() + + # Y Max control + x_offset += 150 + self.y_max_label.setParent(self) + self.y_max_label.move(x_offset, y_pos + 10) + self.y_max_label.show() + + self.y_max_spinbox.setParent(self) + self.y_max_spinbox.move(x_offset + 50, y_pos + 8) + self.y_max_spinbox.resize(80, 25) + self.y_max_spinbox.show() diff --git a/config-create/main.py b/config-create/main.py new file mode 100644 index 0000000..e482af0 --- /dev/null +++ b/config-create/main.py @@ -0,0 +1,43 @@ +import sys +import PyQt6.QtCore as QtCore +import PyQt6.QtGui as QtGui +import PyQt6.QtWidgets as QtWidgets + +from CurveWidget import CurveWidget + + +class Editor(QtWidgets.QMainWindow): + def __init__(self): + super().__init__() + self.resize(580, 480) + self.setWindowTitle("Qt6 Curve Editor") + + # Create central widget + central_widget = QtWidgets.QWidget() + self.setCentralWidget(central_widget) + + # Create layout + layout = QtWidgets.QVBoxLayout(central_widget) + + # Create curve widget + self.curve_widget = CurveWidget(self) + layout.addWidget(self.curve_widget) + + +def main(): + """Main function with proper error handling""" + try: + app = QtWidgets.QApplication(sys.argv) + app.setApplicationName("Qt6 Curve Editor") + + editor = Editor() + editor.show() + + return app.exec() + except Exception as e: + print(f"Error starting application: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From e7a6094209401650f7b0b3847789f39bc76c4db0 Mon Sep 17 00:00:00 2001 From: Tnixc Date: Fri, 19 Sep 2025 20:10:58 -0400 Subject: [PATCH 02/12] feat: undo and redo system --- config-create/CurveWidget.py | 427 +++++++++++++++++++++++------------ config-create/main.py | 4 + 2 files changed, 287 insertions(+), 144 deletions(-) diff --git a/config-create/CurveWidget.py b/config-create/CurveWidget.py index cf2a7a9..90d6428 100644 --- a/config-create/CurveWidget.py +++ b/config-create/CurveWidget.py @@ -1,8 +1,13 @@ # from __future__ import print_function +import copy +import PyQt6.QtCore as QtCore import PyQt6.QtGui as QtGui import PyQt6.QtWidgets as QtWidgets +# Set to True to enable debug logging for state saves +DEBUG_UNDO = False + class Curve: """Interface to linear interpolation between control points""" @@ -35,7 +40,6 @@ def get_cv_points(self): def build_curve(self): """Sort control points by x coordinate for linear interpolation""" - # Sort points by x coordinate for proper linear interpolation self._cv_points = sorted(self._cv_points, key=lambda v: v[0]) def set_cv_value(self, index, x_value, y_value): @@ -53,23 +57,18 @@ def get_value(self, offset): if not self._cv_points: return 0.0 - # Ensure points are sorted by x coordinate sorted_points = sorted(self._cv_points, key=lambda v: v[0]) - # Handle edge cases if offset <= sorted_points[0][0]: return sorted_points[0][1] if offset >= sorted_points[-1][0]: return sorted_points[-1][1] - # Find the two points to interpolate between for i in range(len(sorted_points) - 1): x1, y1 = sorted_points[i] x2, y2 = sorted_points[i + 1] - if x1 <= offset <= x2: - # Linear interpolation - if x2 == x1: # Avoid division by zero + if x2 == x1: return y1 t = (offset - x1) / (x2 - x1) return y1 + t * (y2 - y1) @@ -78,14 +77,11 @@ def get_value(self, offset): class CurveWidget(QtWidgets.QWidget): - """This is a resizeable Widget which shows an editable curve which can - be modified.""" + """Resizeable widget displaying an editable curve with undo/redo support.""" def __init__(self, parent): - """Constructs the CurveWidget, we start with an initial curve""" super().__init__(parent) - # Single curve self.curve = Curve() # Widget render constants @@ -94,33 +90,178 @@ def __init__(self, parent): self._control_bar_height = 40 self._padding = 12 - # Currently dragged control point, format is: - # (PointIndex, Drag-Offset (x,y)) - self._drag_point = None - - # Currently selected control point index + # Drag / selection tracking + self._drag_point = None # (index, (offset_x, offset_y)) self._selected_point = None + self._pre_drag_points = None # deep copy of points at drag start + + # Undo/Redo state history + self._history = [] + self._history_index = -1 + self._restoring_state = False - # Create control widgets + # Build UI + self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._show_context_menu) self._create_controls() + self._setup_shortcuts() + + # Save initial state AFTER widget is ready + self._push_state("Initial") + self._update_window_title() + + # ------------------------------------------------------------------ + # Undo/Redo infrastructure + # ------------------------------------------------------------------ + def _capture_state(self): + """Return a deep copy snapshot of current logical state.""" + return { + "cv_points": copy.deepcopy(self.curve._cv_points), + "x_max": self.curve.x_max, + "y_min": self.curve.y_min, + "y_max": self.curve.y_max, + } + + def _states_equal(self, a, b): + return a["cv_points"] == b["cv_points"] and a["x_max"] == b["x_max"] and a["y_min"] == b["y_min"] and a["y_max"] == b["y_max"] + + def _push_state(self, action_name="Unknown"): + """Record current state AFTER a modification.""" + current = self._capture_state() + # Truncate redo branch if we are not at the end + if self._history_index < len(self._history) - 1: + self._history = self._history[: self._history_index + 1] + # Avoid duplicates + if self._history and self._states_equal(current, self._history[-1]): + if DEBUG_UNDO: + print(f"UNDO DEBUG: skip duplicate after '{action_name}'") + return + self._history.append(current) + self._history_index = len(self._history) - 1 + if DEBUG_UNDO: + print(f"UNDO DEBUG: push '{action_name}' -> index {self._history_index} / {len(self._history)}") + + # Backwards compatibility (legacy code/tests may call _save_state before modifying) + def _save_state(self, action_name="Unknown"): + """Deprecated: kept for backward compatibility. + Unlike legacy behavior we now snapshot current state (post-mod).""" + self._push_state(action_name) + + def _undo(self): + if self._history_index > 0: + self._history_index -= 1 + self._restore_state(self._history[self._history_index]) + self._update_window_title() + if DEBUG_UNDO: + print(f"UNDO DEBUG: undo -> index {self._history_index} / {len(self._history)}") + + def _redo(self): + if self._history_index < len(self._history) - 1: + self._history_index += 1 + self._restore_state(self._history[self._history_index]) + self._update_window_title() + if DEBUG_UNDO: + print(f"UNDO DEBUG: redo -> index {self._history_index} / {len(self._history)}") + + def _restore_state(self, state): + self._restoring_state = True + try: + self.curve._cv_points = copy.deepcopy(state["cv_points"]) + self.curve.x_max = state["x_max"] + self.curve.y_min = state["y_min"] + self.curve.y_max = state["y_max"] + + # Update spin boxes (will not push state due to flag) + self.x_max_spinbox.setValue(state["x_max"]) + self.y_min_spinbox.setValue(state["y_min"]) + self.y_max_spinbox.setValue(state["y_max"]) + self.curve.build_curve() + self.update() + finally: + self._restoring_state = False + + # ------------------------------------------------------------------ + # UI / shortcuts + # ------------------------------------------------------------------ + def _setup_shortcuts(self): + self.undo_shortcut = QtGui.QShortcut(QtGui.QKeySequence.StandardKey.Undo, self) + self.undo_shortcut.activated.connect(self._undo) + self.redo_shortcut = QtGui.QShortcut(QtGui.QKeySequence.StandardKey.Redo, self) + self.redo_shortcut.activated.connect(self._redo) + + def _update_window_title(self): + parent_window = self.window() + if not parent_window: + return + base_title = "Qt6 Curve Editor" + undo_available = self._history_index > 0 + redo_available = self._history_index < len(self._history) - 1 + status_parts = [] + if undo_available: + status_parts.append("Ctrl+Z: Undo") + if redo_available: + status_parts.append("Ctrl+Shift+Z: Redo") + if status_parts: + title = f"{base_title} - {' | '.join(status_parts)} | Right-click to delete points" + else: + title = f"{base_title} - Right-click to delete points" + parent_window.setWindowTitle(title) + + # ------------------------------------------------------------------ + # Context menu / point deletion + # ------------------------------------------------------------------ + def _show_context_menu(self, position): + point_index = self._get_point_at_position(position) + if point_index is None: + return + menu = QtWidgets.QMenu(self) + delete_action = menu.addAction("Delete Point") + if delete_action: + delete_action.triggered.connect(lambda: self._delete_point(point_index)) + menu.exec(self.mapToGlobal(position)) + + def _get_point_at_position(self, position): + mouse_x = position.x() + mouse_y = position.y() + for idx, (x, y) in enumerate(self.curve.get_cv_points()): + px = self._get_x_value_for(x) + py = self._get_y_value_for(y) + if abs(px - mouse_x) < self._cv_point_size + 4 and abs(py - mouse_y) < self._cv_point_size + 4: + return idx + return None + + def _delete_point(self, point_index): + if len(self.curve._cv_points) <= 1: + return + # Perform mutation first + del self.curve._cv_points[point_index] + if self._selected_point == point_index: + self._selected_point = None + elif self._selected_point is not None and self._selected_point > point_index: + self._selected_point -= 1 + self.curve.build_curve() + self.update() + # Save state AFTER deletion + self._push_state(f"Delete point {point_index}") + self._update_window_title() + + # ------------------------------------------------------------------ + # Axis scale controls + # ------------------------------------------------------------------ def _create_controls(self): - """Create the control widgets for axis scaling""" - # X-axis max control self.x_max_label = QtWidgets.QLabel("X Max:") self.x_max_spinbox = QtWidgets.QSpinBox() self.x_max_spinbox.setRange(100, 10000) self.x_max_spinbox.setValue(1400) self.x_max_spinbox.valueChanged.connect(self._update_scales) - # Y-axis min control self.y_min_label = QtWidgets.QLabel("Y Min:") self.y_min_spinbox = QtWidgets.QSpinBox() self.y_min_spinbox.setRange(0, 1000) self.y_min_spinbox.setValue(25) self.y_min_spinbox.valueChanged.connect(self._update_scales) - # Y-axis max control self.y_max_label = QtWidgets.QLabel("Y Max:") self.y_max_spinbox = QtWidgets.QSpinBox() self.y_max_spinbox.setRange(100, 10000) @@ -128,160 +269,163 @@ def _create_controls(self): self.y_max_spinbox.valueChanged.connect(self._update_scales) def _update_scales(self): - """Update the curve axis scales when controls change""" - x_max = self.x_max_spinbox.value() - y_min = self.y_min_spinbox.value() - y_max = self.y_max_spinbox.value() - - # Ensure y_max > y_min - if y_max <= y_min: - y_max = y_min + 100 - self.y_max_spinbox.setValue(y_max) - - self.curve.set_axis_scales(x_max, y_min, y_max) + if self._restoring_state: + return + new_x = self.x_max_spinbox.value() + new_ymin = self.y_min_spinbox.value() + new_ymax = self.y_max_spinbox.value() + # Enforce invariant + if new_ymax <= new_ymin: + new_ymax = new_ymin + 1 + self.y_max_spinbox.setValue(new_ymax) + changed = new_x != self.curve.x_max or new_ymin != self.curve.y_min or new_ymax != self.curve.y_max + self.curve.set_axis_scales(new_x, new_ymin, new_ymax) + if changed: + self._push_state("Axis scale change") self.update() + self._update_window_title() - def paintEvent(self, e): - """Internal QT paint event, draws the entire widget""" + # ------------------------------------------------------------------ + # Events + # ------------------------------------------------------------------ + def paintEvent(self, a0): qp = QtGui.QPainter() qp.begin(self) self._draw(qp) qp.end() - def mousePressEvent(self, QMouseEvent): - """Internal mouse-press handler""" + def mousePressEvent(self, a0): self._drag_point = None + self._pre_drag_points = None self._selected_point = None - mouse_pos = QMouseEvent.pos() - mouse_x = mouse_pos.x() - mouse_y = mouse_pos.y() - - for cv_index, (x, y) in enumerate(self.curve.get_cv_points()): - point_x = self._get_x_value_for(x) - point_y = self._get_y_value_for(y) - - # Check if mouse is close to this control point - if abs(point_x - mouse_x) < self._cv_point_size + 4: - if abs(point_y - mouse_y) < self._cv_point_size + 4: - # Store the offset from mouse to point center in screen coordinates - drag_x_offset = point_x - mouse_x - drag_y_offset = point_y - mouse_y - self._drag_point = (cv_index, (drag_x_offset, drag_y_offset)) - self._selected_point = cv_index - break + mouse_pos = a0.pos() + mx = mouse_pos.x() + my = mouse_pos.y() + + for idx, (x, y) in enumerate(self.curve.get_cv_points()): + px = self._get_x_value_for(x) + py = self._get_y_value_for(y) + if abs(px - mx) < self._cv_point_size + 4 and abs(py - my) < self._cv_point_size + 4: + self._drag_point = (idx, (px - mx, py - my)) + self._selected_point = idx + # record original points for later comparison (drag end) + self._pre_drag_points = copy.deepcopy(self.curve._cv_points) + break self.update() - def mouseReleaseEvent(self, QMouseEvent): - """Internal mouse-release handler""" + def mouseReleaseEvent(self, a0): + # Save state after drag if something actually moved + if self._drag_point is not None and self._pre_drag_points is not None: + if self.curve._cv_points != self._pre_drag_points: + self._push_state("Move point") + self._update_window_title() self._drag_point = None + self._pre_drag_points = None - def mouseDoubleClickEvent(self, QMouseEvent): - """Internal mouse-double-click handler - adds new control point""" - mouse_pos = QMouseEvent.pos() + def mouseDoubleClickEvent(self, a0): + mouse_pos = a0.pos() mouse_x = mouse_pos.x() - self._legend_border - self._padding mouse_y = mouse_pos.y() - self._padding - # Convert to coordinate range using current axis scales - local_x = max(0, min(self.curve.x_max, mouse_x / float(self.width() - self._legend_border - 2 * self._padding) * self.curve.x_max)) + graph_width = self.width() - self._legend_border - 2 * self._padding + graph_height = self.height() - self._legend_border - self._control_bar_height - 2 * self._padding + + local_x = max( + 0, + min( + self.curve.x_max, + mouse_x / float(graph_width) * self.curve.x_max, + ), + ) local_y = max( self.curve.y_min, min( self.curve.y_max, - (1 - mouse_y / float(self.height() - self._legend_border - self._control_bar_height - 2 * self._padding)) - * (self.curve.y_max - self.curve.y_min) - + self.curve.y_min, + (1 - mouse_y / float(graph_height)) * (self.curve.y_max - self.curve.y_min) + self.curve.y_min, ), ) - # Add new control point + # Perform modification first self.curve.add_cv_point(local_x, local_y) self.update() + # Save state AFTER adding + self._push_state("Add point") + self._update_window_title() - def mouseMoveEvent(self, QMouseEvent): - """Internal mouse-move handler""" - if self._drag_point is not None: - # Get current mouse position in screen coordinates - mouse_pos = QMouseEvent.pos() + def mouseMoveEvent(self, a0): + if self._drag_point is None: + return + idx, (ox, oy) = self._drag_point + mouse_pos = a0.pos() - # Apply the drag offset to get the desired point position in screen coordinates - point_screen_x = mouse_pos.x() + self._drag_point[1][0] - point_screen_y = mouse_pos.y() + self._drag_point[1][1] + # Screen position for point (apply stored offset) + point_screen_x = mouse_pos.x() + ox + point_screen_y = mouse_pos.y() + oy - # Convert from screen coordinates to graph coordinates - graph_x = point_screen_x - self._legend_border - self._padding - graph_y = point_screen_y - self._padding + graph_x = point_screen_x - self._legend_border - self._padding + graph_y = point_screen_y - self._padding - # Get graph dimensions - graph_width = self.width() - self._legend_border - 2 * self._padding - graph_height = self.height() - self._legend_border - self._control_bar_height - 2 * self._padding + graph_width = self.width() - self._legend_border - 2 * self._padding + graph_height = self.height() - self._legend_border - self._control_bar_height - 2 * self._padding - # Convert to data coordinates using current axis scales - # X coordinate: normalize by graph width, then scale to x_max - local_x = max(0, min(self.curve.x_max, graph_x / float(graph_width) * self.curve.x_max)) - - # Y coordinate: flip Y axis (screen Y increases downward, data Y increases upward) - # then normalize and scale to y range - normalized_y = 1.0 - (graph_y / float(graph_height)) - local_y = max(self.curve.y_min, min(self.curve.y_max, normalized_y * (self.curve.y_max - self.curve.y_min) + self.curve.y_min)) - - # Update the control point - self.curve.set_cv_value(self._drag_point[0], local_x, local_y) + local_x = max(0, min(self.curve.x_max, graph_x / float(graph_width) * self.curve.x_max)) + normalized_y = 1.0 - (graph_y / float(graph_height)) + local_y = max( + self.curve.y_min, + min( + self.curve.y_max, + normalized_y * (self.curve.y_max - self.curve.y_min) + self.curve.y_min, + ), + ) - # Rebuild curve and update display - self.curve.build_curve() - self.update() + self.curve.set_cv_value(idx, local_x, local_y) + self.curve.build_curve() + self.update() + # ------------------------------------------------------------------ + # Coordinate helpers + # ------------------------------------------------------------------ def _get_y_value_for(self, local_value): - """Converts a value from y_min-y_max to canvas height""" y_range = self.curve.y_max - self.curve.y_min - normalized_value = (local_value - self.curve.y_min) / y_range # Normalize to 0-1 - normalized_value = max(0, min(1.0, 1.0 - normalized_value)) # Flip Y and clamp - local_value = ( - normalized_value * (self.height() - self._legend_border - self._control_bar_height - 2 * self._padding) + self._padding - ) - return local_value + normalized_value = (local_value - self.curve.y_min) / y_range + normalized_value = max(0, min(1.0, 1.0 - normalized_value)) + return normalized_value * (self.height() - self._legend_border - self._control_bar_height - 2 * self._padding) + self._padding def _get_x_value_for(self, local_value): - """Converts a value from 0-x_max to canvas width""" - normalized_value = local_value / self.curve.x_max # Normalize to 0-1 + normalized_value = local_value / self.curve.x_max normalized_value = max(0, min(1.0, normalized_value)) - local_value = normalized_value * (self.width() - self._legend_border - 2 * self._padding) + self._legend_border + self._padding - return local_value + return normalized_value * (self.width() - self._legend_border - 2 * self._padding) + self._legend_border + self._padding + # ------------------------------------------------------------------ + # Drawing + # ------------------------------------------------------------------ def _draw(self, painter): - """Internal method to draw the widget""" - canvas_width = self.width() - self._legend_border - 2 * self._padding canvas_height = self.height() - self._legend_border - self._control_bar_height - 2 * self._padding - # Draw field background palette = self.palette() painter.setPen(palette.color(QtGui.QPalette.ColorRole.Mid)) painter.setBrush(palette.color(QtGui.QPalette.ColorRole.Base)) painter.drawRect(0, 0, self.width() - 1, self.height() - 1) - # Draw legend - - # Compute amount of horizontal / vertical lines - num_vert_lines = 7 # Adjustable based on x_max + # Grid lines + num_vert_lines = 7 line_spacing_x = (self.width() - self._legend_border - 2 * self._padding) / 7.0 line_spacing_y = (self.height() - self._legend_border - self._control_bar_height - 2 * self._padding) / 10.0 - num_horiz_lines = 11 # Adjustable based on y range + num_horiz_lines = 11 - # Draw vertical lines painter.setPen(palette.color(QtGui.QPalette.ColorRole.Window)) for i in range(num_vert_lines + 1): line_pos = i * line_spacing_x + self._legend_border + self._padding painter.drawLine(int(line_pos), self._padding, int(line_pos), canvas_height + self._padding) - # Draw horizontal lines painter.setPen(palette.color(QtGui.QPalette.ColorRole.Window)) for i in range(num_horiz_lines): line_pos = canvas_height - i * line_spacing_y + self._padding painter.drawLine(self._legend_border, int(line_pos), self.width(), int(line_pos)) - # Draw vertical legend labels (Y-axis values) + # Y axis labels painter.setPen(palette.color(QtGui.QPalette.ColorRole.Text)) y_range = self.curve.y_max - self.curve.y_min for i in range(num_horiz_lines): @@ -289,7 +433,7 @@ def _draw(self, painter): value = int(self.curve.y_min + (y_range * i / (num_horiz_lines - 1))) painter.drawText(6, int(line_pos + 3), str(value)) - # Draw horizontal legend labels (X-axis values) + # X axis labels for i in range(num_vert_lines + 1): line_pos = i * line_spacing_x + self._legend_border + self._padding offpos_x = -14 @@ -298,57 +442,54 @@ def _draw(self, painter): elif i == num_vert_lines: offpos_x = -33 value = int(self.curve.x_max * i / num_vert_lines) - painter.drawText(int(line_pos + offpos_x), canvas_height + self._padding + 18, str(value)) + painter.drawText( + int(line_pos + offpos_x), + canvas_height + self._padding + 18, + str(value), + ) - # Draw control bar background + # Control bar background control_bar_y = canvas_height + self._padding + 25 painter.setPen(palette.color(QtGui.QPalette.ColorRole.Mid)) painter.setBrush(palette.color(QtGui.QPalette.ColorRole.Window)) painter.drawRect(0, control_bar_y, self.width(), self._control_bar_height) - # Position control widgets self._position_controls(control_bar_y) - # Draw curve as straight lines between control points + # Curve lines painter.setPen(palette.color(QtGui.QPalette.ColorRole.Text)) - cv_points = self.curve.get_cv_points() - - # Sort points by x coordinate for drawing - sorted_points = sorted(cv_points, key=lambda v: v[0]) - - # Draw lines between consecutive control points + sorted_points = sorted(self.curve.get_cv_points(), key=lambda v: v[0]) for i in range(len(sorted_points) - 1): x1, y1 = sorted_points[i] x2, y2 = sorted_points[i + 1] + painter.drawLine( + int(self._get_x_value_for(x1)), + int(self._get_y_value_for(y1)), + int(self._get_x_value_for(x2)), + int(self._get_y_value_for(y2)), + ) - # Convert to screen coordinates - screen_x1 = self._get_x_value_for(x1) - screen_y1 = self._get_y_value_for(y1) - screen_x2 = self._get_x_value_for(x2) - screen_y2 = self._get_y_value_for(y2) - - painter.drawLine(int(screen_x1), int(screen_y1), int(screen_x2), int(screen_y2)) - - # Draw the CV points of the curve + # Points painter.setBrush(palette.color(QtGui.QPalette.ColorRole.Base)) - - for cv_index, (x, y) in enumerate(self.curve.get_cv_points()): + for idx, (x, y) in enumerate(self.curve.get_cv_points()): offs_x = self._get_x_value_for(x) offs_y = self._get_y_value_for(y) - - if self._selected_point == cv_index: + if self._selected_point == idx: painter.setPen(palette.color(QtGui.QPalette.ColorRole.Highlight)) else: painter.setPen(palette.color(QtGui.QPalette.ColorRole.Dark)) painter.drawRect( - int(offs_x - self._cv_point_size), int(offs_y - self._cv_point_size), 2 * self._cv_point_size, 2 * self._cv_point_size + int(offs_x - self._cv_point_size), + int(offs_y - self._cv_point_size), + 2 * self._cv_point_size, + 2 * self._cv_point_size, ) + # ------------------------------------------------------------------ + # Control positioning + # ------------------------------------------------------------------ def _position_controls(self, y_pos): - """Position the control widgets at the bottom""" x_offset = 10 - - # X Max control self.x_max_label.setParent(self) self.x_max_label.move(x_offset, y_pos + 10) self.x_max_label.show() @@ -358,7 +499,6 @@ def _position_controls(self, y_pos): self.x_max_spinbox.resize(80, 25) self.x_max_spinbox.show() - # Y Min control x_offset += 150 self.y_min_label.setParent(self) self.y_min_label.move(x_offset, y_pos + 10) @@ -369,7 +509,6 @@ def _position_controls(self, y_pos): self.y_min_spinbox.resize(80, 25) self.y_min_spinbox.show() - # Y Max control x_offset += 150 self.y_max_label.setParent(self) self.y_max_label.move(x_offset, y_pos + 10) diff --git a/config-create/main.py b/config-create/main.py index e482af0..e08b771 100644 --- a/config-create/main.py +++ b/config-create/main.py @@ -23,6 +23,10 @@ def __init__(self): self.curve_widget = CurveWidget(self) layout.addWidget(self.curve_widget) + # Ensure the curve widget can receive focus for keyboard shortcuts + self.curve_widget.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus) + self.curve_widget.setFocus() + def main(): """Main function with proper error handling""" From 446240e4010ee13beb4b27121cc67b6fff41047a Mon Sep 17 00:00:00 2001 From: Tnixc Date: Fri, 19 Sep 2025 20:16:31 -0400 Subject: [PATCH 03/12] delete keybind --- config-create/CurveWidget.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/config-create/CurveWidget.py b/config-create/CurveWidget.py index 90d6428..17df4a0 100644 --- a/config-create/CurveWidget.py +++ b/config-create/CurveWidget.py @@ -83,6 +83,7 @@ def __init__(self, parent): super().__init__(parent) self.curve = Curve() + self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus) # Widget render constants self._cv_point_size = 6 @@ -203,9 +204,9 @@ def _update_window_title(self): if redo_available: status_parts.append("Ctrl+Shift+Z: Redo") if status_parts: - title = f"{base_title} - {' | '.join(status_parts)} | Right-click to delete points" + title = f"{base_title} - {' | '.join(status_parts)} | Right-click to delete points | Delete: Remove selected point" else: - title = f"{base_title} - Right-click to delete points" + title = f"{base_title} - Right-click to delete points | Delete: Remove selected point" parent_window.setWindowTitle(title) # ------------------------------------------------------------------ @@ -295,6 +296,7 @@ def paintEvent(self, a0): qp.end() def mousePressEvent(self, a0): + self.setFocus() self._drag_point = None self._pre_drag_points = None self._selected_point = None @@ -345,6 +347,11 @@ def mouseDoubleClickEvent(self, a0): (1 - mouse_y / float(graph_height)) * (self.curve.y_max - self.curve.y_min) + self.curve.y_min, ), ) + # Snap new point's Y to existing point Y values if close (screen-space) + for _, existing_y in self.curve.get_cv_points(): + if abs(self._get_y_value_for(existing_y) - self._get_y_value_for(local_y)) <= self._cv_point_size * 2: + local_y = existing_y + break # Perform modification first self.curve.add_cv_point(local_x, local_y) @@ -378,6 +385,13 @@ def mouseMoveEvent(self, a0): normalized_y * (self.curve.y_max - self.curve.y_min) + self.curve.y_min, ), ) + # Snap dragged point's Y to other points if close (exclude itself) + for other_idx, (_, existing_y) in enumerate(self.curve.get_cv_points()): + if other_idx == idx: + continue + if abs(self._get_y_value_for(existing_y) - self._get_y_value_for(local_y)) <= self._cv_point_size * 2: + local_y = existing_y + break self.curve.set_cv_value(idx, local_x, local_y) self.curve.build_curve() @@ -518,3 +532,13 @@ def _position_controls(self, y_pos): self.y_max_spinbox.move(x_offset + 50, y_pos + 8) self.y_max_spinbox.resize(80, 25) self.y_max_spinbox.show() + + def keyPressEvent(self, a0): + # Allow deleting the currently selected point with Delete or Backspace + if a0 is None: + return + if a0.key() in (QtCore.Qt.Key.Key_Delete, QtCore.Qt.Key.Key_Backspace): + if self._selected_point is not None: + self._delete_point(self._selected_point) + return + super().keyPressEvent(a0) From 036c1aa6cd895d5ec206d4cfd1078de577a21099 Mon Sep 17 00:00:00 2001 From: Tnixc Date: Fri, 19 Sep 2025 20:26:12 -0400 Subject: [PATCH 04/12] type hints and cleanup --- config-create/main.py | 11 +- config-create/models/curve.py | 229 ++++++++++++++++++ .../curve_widget.py} | 227 ++++++----------- 3 files changed, 312 insertions(+), 155 deletions(-) create mode 100644 config-create/models/curve.py rename config-create/{CurveWidget.py => widgets/curve_widget.py} (74%) diff --git a/config-create/main.py b/config-create/main.py index e08b771..c4cdeb5 100644 --- a/config-create/main.py +++ b/config-create/main.py @@ -1,13 +1,14 @@ import sys import PyQt6.QtCore as QtCore -import PyQt6.QtGui as QtGui + +# Removed unused PyQt6.QtGui import import PyQt6.QtWidgets as QtWidgets -from CurveWidget import CurveWidget +from widgets.curve_widget import CurveWidget class Editor(QtWidgets.QMainWindow): - def __init__(self): + def __init__(self) -> None: super().__init__() self.resize(580, 480) self.setWindowTitle("Qt6 Curve Editor") @@ -20,7 +21,7 @@ def __init__(self): layout = QtWidgets.QVBoxLayout(central_widget) # Create curve widget - self.curve_widget = CurveWidget(self) + self.curve_widget: CurveWidget = CurveWidget(self) layout.addWidget(self.curve_widget) # Ensure the curve widget can receive focus for keyboard shortcuts @@ -28,7 +29,7 @@ def __init__(self): self.curve_widget.setFocus() -def main(): +def main() -> int: """Main function with proper error handling""" try: app = QtWidgets.QApplication(sys.argv) diff --git a/config-create/models/curve.py b/config-create/models/curve.py new file mode 100644 index 0000000..28cd582 --- /dev/null +++ b/config-create/models/curve.py @@ -0,0 +1,229 @@ +""" +Curve model providing linear interpolation between control points. + +This module separates the data/model responsibilities from the Qt widget +implementation (see widgets/curve_widget.py). The Curve class is intentionally +light‑weight and framework agnostic so it can be re-used or unit tested +independently. + +Design notes: +- Control points are stored as a list of 2-item float lists: [[x, y], ...] + (list of lists instead of tuples) to remain compatible with existing code + that mutates the inner lists by re-assignment (e.g. replacing an element). +- Public API mirrors the previous in-widget implementation for drop‑in use. +- All methods include type hints and docstrings. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Iterable, List + + +ControlPoint = list[float] # [x, y] +ControlPoints = list[ControlPoint] # collection alias + + +@dataclass +class Curve: + """ + Represents a 2D curve defined by ordered control points and provides + linear interpolation between those points. + + Attributes + ---------- + x_max : float + Maximum X axis value (domain upper bound). + y_min : float + Minimum Y axis value (range lower bound). + y_max : float + Maximum Y axis value (range upper bound). + _cv_points : list[list[float]] + Internal mutable list of control points. Each point is [x, y]. + Points are expected (but not strictly required) to be within the + configured axis scales. They are kept sorted by X via build_curve(). + """ + + x_max: float = 1400.0 + y_min: float = 25.0 + y_max: float = 1200.0 + _cv_points: ControlPoints = field( + default_factory=lambda: [ + [0.0, 25.0], + [200.0, 1100.0], + [1200.0, 1100.0], + [1400.0, 25.0], + ] + ) + + # --------------------------------------------------------------------- # + # Initialization / configuration + # --------------------------------------------------------------------- # + def __post_init__(self) -> None: + """ + Normalize numeric types and ensure control points are sorted. + """ + # Coerce to float explicitly (in case ints are passed) + self.x_max = float(self.x_max) + self.y_min = float(self.y_min) + self.y_max = float(self.y_max) + self.build_curve() + + def set_axis_scales(self, x_max: float, y_min: float, y_max: float) -> None: + """ + Update axis scale values. + + Parameters + ---------- + x_max : float + New maximum X value. + y_min : float + New minimum Y value. + y_max : float + New maximum Y value (must be > y_min for meaningful interpolation). + """ + self.x_max = float(x_max) + self.y_min = float(y_min) + self.y_max = float(y_max) + + # --------------------------------------------------------------------- # + # Control point management + # --------------------------------------------------------------------- # + def get_cv_points(self) -> ControlPoints: + """ + Return the current list of control points. + + Returns + ------- + list[list[float]] + The internal list (NOT a copy). Caller should treat it as read-only + unless deliberately mutating. Widget code historically mutates it. + """ + return self._cv_points + + def build_curve(self) -> None: + """ + Sort control points by X coordinate (stable) to maintain correct + linear interpolation behavior. + """ + self._cv_points.sort(key=lambda v: v[0]) + + def set_cv_value(self, index: int, x_value: float, y_value: float) -> None: + """ + Update the control point at the specified index. + + Parameters + ---------- + index : int + Index of the control point to update. + x_value : float + New X coordinate. + y_value : float + New Y coordinate. + """ + self._cv_points[index] = [float(x_value), float(y_value)] + + def add_cv_point(self, x_value: float, y_value: float) -> None: + """ + Add a new control point and re-sort the curve. + + Parameters + ---------- + x_value : float + X coordinate of the new point. + y_value : float + Y coordinate of the new point. + """ + self._cv_points.append([float(x_value), float(y_value)]) + self.build_curve() + + def extend_cv_points(self, points: Iterable[Iterable[float]]) -> None: + """ + Bulk append multiple control points (optional helper) and re-sort. + + Parameters + ---------- + points : Iterable[Iterable[float]] + An iterable of 2-length iterables representing [x, y] pairs. + """ + for p in points: + x, y = p + self._cv_points.append([float(x), float(y)]) + self.build_curve() + + # --------------------------------------------------------------------- # + # Interpolation + # --------------------------------------------------------------------- # + def get_value(self, offset: float) -> float: + """ + Perform linear interpolation for a given X offset. + + Parameters + ---------- + offset : float + X coordinate at which to evaluate the curve. This value is expected + to be within the domain [min_x, max_x] covered by control points, + but clamping behavior is provided for values outside the range. + + Returns + ------- + float + Interpolated Y value. + + Notes + ----- + Unlike the previous docstring that mentioned normalized 0..1 offsets, + this implementation operates in the same unit space as the control + point X values (so typically 0..x_max). Callers that want normalized + sampling should scale accordingly (offset * x_max). + """ + if not self._cv_points: + return 0.0 + + # Ensure ordering (cheap if already sorted). + sorted_points: List[ControlPoint] = sorted(self._cv_points, key=lambda v: v[0]) + + # Clamp to range + if offset <= sorted_points[0][0]: + return sorted_points[0][1] + if offset >= sorted_points[-1][0]: + return sorted_points[-1][1] + + # Linear segment search (O(n)); acceptable for small point counts. + for i in range(len(sorted_points) - 1): + x1, y1 = sorted_points[i] + x2, y2 = sorted_points[i + 1] + if x1 <= offset <= x2: + if x2 == x1: + # Degenerate segment; return start value. + return y1 + t = (offset - x1) / (x2 - x1) + return y1 + t * (y2 - y1) + + # Fallback (should not reach here given earlier clamps) + return 0.0 + + # --------------------------------------------------------------------- # + # Utility / representation + # --------------------------------------------------------------------- # + def copy(self) -> "Curve": + """ + Create a deep (logical) copy of the curve. + + Returns + ------- + Curve + New curve instance with duplicated control points and axis scales. + """ + return Curve( + x_max=self.x_max, + y_min=self.y_min, + y_max=self.y_max, + _cv_points=[p.copy() for p in self._cv_points], + ) + + def __repr__(self) -> str: # pragma: no cover - convenience + return f"Curve(x_max={self.x_max}, y_min={self.y_min}, y_max={self.y_max}, points={self._cv_points})" + + +__all__ = ["Curve", "ControlPoint", "ControlPoints"] diff --git a/config-create/CurveWidget.py b/config-create/widgets/curve_widget.py similarity index 74% rename from config-create/CurveWidget.py rename to config-create/widgets/curve_widget.py index 17df4a0..a99bd80 100644 --- a/config-create/CurveWidget.py +++ b/config-create/widgets/curve_widget.py @@ -1,105 +1,48 @@ -# from __future__ import print_function +from __future__ import annotations import copy import PyQt6.QtCore as QtCore import PyQt6.QtGui as QtGui import PyQt6.QtWidgets as QtWidgets +from typing import TypedDict + +from models.curve import Curve # Set to True to enable debug logging for state saves DEBUG_UNDO = False -class Curve: - """Interface to linear interpolation between control points""" - - def __init__(self, x_max=1400, y_min=25, y_max=1200): - # Axis scale values - self.x_max = x_max - self.y_min = y_min - self.y_max = y_max - - self._cv_points = [ - [0.0, 25.0], - [200.0, 1100.0], - [1200.0, 1100.0], - [1400.0, 25.0], - ] - - # Sort points by x coordinate for linear interpolation - self.build_curve() - - def set_axis_scales(self, x_max, y_min, y_max): - """Update axis scale values""" - self.x_max = x_max - self.y_min = y_min - self.y_max = y_max - - def get_cv_points(self): - """Returns a list of all controll points""" - return self._cv_points - - def build_curve(self): - """Sort control points by x coordinate for linear interpolation""" - self._cv_points = sorted(self._cv_points, key=lambda v: v[0]) - - def set_cv_value(self, index, x_value, y_value): - """Updates the cv point at the given index""" - self._cv_points[index] = [x_value, y_value] - - def add_cv_point(self, x_value, y_value): - """Adds a new control point and rebuilds the curve""" - self._cv_points.append([x_value, y_value]) - self.build_curve() - - def get_value(self, offset): - """Returns the value using linear interpolation between control points. - offset should be from 0 to 1, returns a value from 0 to 1.""" - if not self._cv_points: - return 0.0 - - sorted_points = sorted(self._cv_points, key=lambda v: v[0]) - - if offset <= sorted_points[0][0]: - return sorted_points[0][1] - if offset >= sorted_points[-1][0]: - return sorted_points[-1][1] - - for i in range(len(sorted_points) - 1): - x1, y1 = sorted_points[i] - x2, y2 = sorted_points[i + 1] - if x1 <= offset <= x2: - if x2 == x1: - return y1 - t = (offset - x1) / (x2 - x1) - return y1 + t * (y2 - y1) - - return 0.0 +class Snapshot(TypedDict): + cv_points: list[list[float]] + x_max: float + y_min: float + y_max: float class CurveWidget(QtWidgets.QWidget): - """Resizeable widget displaying an editable curve with undo/redo support.""" + """Resizable widget displaying an editable curve with undo/redo support.""" - def __init__(self, parent): + def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: super().__init__(parent) - self.curve = Curve() + self.curve: Curve = Curve() self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus) # Widget render constants - self._cv_point_size = 6 - self._legend_border = 35 - self._control_bar_height = 40 - self._padding = 12 + self._cv_point_size: int = 6 + self._legend_border: int = 35 + self._control_bar_height: int = 40 + self._padding: int = 12 # Drag / selection tracking - self._drag_point = None # (index, (offset_x, offset_y)) - self._selected_point = None - self._pre_drag_points = None # deep copy of points at drag start + self._drag_point: tuple[int, tuple[float, float]] | None = None # (index, (offset_x, offset_y)) + self._selected_point: int | None = None + self._pre_drag_points: list[list[float]] | None = None # deep copy of points at drag start # Undo/Redo state history - self._history = [] - self._history_index = -1 - self._restoring_state = False + self._history: list[Snapshot] = [] + self._history_index: int = -1 + self._restoring_state: bool = False # Build UI self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) @@ -114,19 +57,19 @@ def __init__(self, parent): # ------------------------------------------------------------------ # Undo/Redo infrastructure # ------------------------------------------------------------------ - def _capture_state(self): + def _capture_state(self) -> Snapshot: """Return a deep copy snapshot of current logical state.""" - return { - "cv_points": copy.deepcopy(self.curve._cv_points), - "x_max": self.curve.x_max, - "y_min": self.curve.y_min, - "y_max": self.curve.y_max, - } - - def _states_equal(self, a, b): + return Snapshot( + cv_points=copy.deepcopy(self.curve._cv_points), + x_max=self.curve.x_max, + y_min=self.curve.y_min, + y_max=self.curve.y_max, + ) + + def _states_equal(self, a: Snapshot, b: Snapshot) -> bool: return a["cv_points"] == b["cv_points"] and a["x_max"] == b["x_max"] and a["y_min"] == b["y_min"] and a["y_max"] == b["y_max"] - def _push_state(self, action_name="Unknown"): + def _push_state(self, action_name: str = "Unknown") -> None: """Record current state AFTER a modification.""" current = self._capture_state() # Truncate redo branch if we are not at the end @@ -143,12 +86,12 @@ def _push_state(self, action_name="Unknown"): print(f"UNDO DEBUG: push '{action_name}' -> index {self._history_index} / {len(self._history)}") # Backwards compatibility (legacy code/tests may call _save_state before modifying) - def _save_state(self, action_name="Unknown"): + def _save_state(self, action_name: str = "Unknown") -> None: """Deprecated: kept for backward compatibility. Unlike legacy behavior we now snapshot current state (post-mod).""" self._push_state(action_name) - def _undo(self): + def _undo(self) -> None: if self._history_index > 0: self._history_index -= 1 self._restore_state(self._history[self._history_index]) @@ -156,7 +99,7 @@ def _undo(self): if DEBUG_UNDO: print(f"UNDO DEBUG: undo -> index {self._history_index} / {len(self._history)}") - def _redo(self): + def _redo(self) -> None: if self._history_index < len(self._history) - 1: self._history_index += 1 self._restore_state(self._history[self._history_index]) @@ -164,7 +107,7 @@ def _redo(self): if DEBUG_UNDO: print(f"UNDO DEBUG: redo -> index {self._history_index} / {len(self._history)}") - def _restore_state(self, state): + def _restore_state(self, state: Snapshot) -> None: self._restoring_state = True try: self.curve._cv_points = copy.deepcopy(state["cv_points"]) @@ -173,9 +116,9 @@ def _restore_state(self, state): self.curve.y_max = state["y_max"] # Update spin boxes (will not push state due to flag) - self.x_max_spinbox.setValue(state["x_max"]) - self.y_min_spinbox.setValue(state["y_min"]) - self.y_max_spinbox.setValue(state["y_max"]) + self.x_max_spinbox.setValue(int(state["x_max"])) + self.y_min_spinbox.setValue(int(state["y_min"])) + self.y_max_spinbox.setValue(int(state["y_max"])) self.curve.build_curve() self.update() @@ -185,20 +128,20 @@ def _restore_state(self, state): # ------------------------------------------------------------------ # UI / shortcuts # ------------------------------------------------------------------ - def _setup_shortcuts(self): - self.undo_shortcut = QtGui.QShortcut(QtGui.QKeySequence.StandardKey.Undo, self) + def _setup_shortcuts(self) -> None: + self.undo_shortcut: QtGui.QShortcut = QtGui.QShortcut(QtGui.QKeySequence.StandardKey.Undo, self) self.undo_shortcut.activated.connect(self._undo) - self.redo_shortcut = QtGui.QShortcut(QtGui.QKeySequence.StandardKey.Redo, self) + self.redo_shortcut: QtGui.QShortcut = QtGui.QShortcut(QtGui.QKeySequence.StandardKey.Redo, self) self.redo_shortcut.activated.connect(self._redo) - def _update_window_title(self): + def _update_window_title(self) -> None: parent_window = self.window() if not parent_window: return base_title = "Qt6 Curve Editor" undo_available = self._history_index > 0 redo_available = self._history_index < len(self._history) - 1 - status_parts = [] + status_parts: list[str] = [] if undo_available: status_parts.append("Ctrl+Z: Undo") if redo_available: @@ -212,7 +155,7 @@ def _update_window_title(self): # ------------------------------------------------------------------ # Context menu / point deletion # ------------------------------------------------------------------ - def _show_context_menu(self, position): + def _show_context_menu(self, position: QtCore.QPoint) -> None: point_index = self._get_point_at_position(position) if point_index is None: return @@ -222,7 +165,7 @@ def _show_context_menu(self, position): delete_action.triggered.connect(lambda: self._delete_point(point_index)) menu.exec(self.mapToGlobal(position)) - def _get_point_at_position(self, position): + def _get_point_at_position(self, position: QtCore.QPoint) -> int | None: mouse_x = position.x() mouse_y = position.y() for idx, (x, y) in enumerate(self.curve.get_cv_points()): @@ -232,10 +175,9 @@ def _get_point_at_position(self, position): return idx return None - def _delete_point(self, point_index): + def _delete_point(self, point_index: int) -> None: if len(self.curve._cv_points) <= 1: return - # Perform mutation first del self.curve._cv_points[point_index] if self._selected_point == point_index: self._selected_point = None @@ -243,39 +185,37 @@ def _delete_point(self, point_index): self._selected_point -= 1 self.curve.build_curve() self.update() - # Save state AFTER deletion self._push_state(f"Delete point {point_index}") self._update_window_title() # ------------------------------------------------------------------ # Axis scale controls # ------------------------------------------------------------------ - def _create_controls(self): - self.x_max_label = QtWidgets.QLabel("X Max:") - self.x_max_spinbox = QtWidgets.QSpinBox() + def _create_controls(self) -> None: + self.x_max_label: QtWidgets.QLabel = QtWidgets.QLabel("X Max:") + self.x_max_spinbox: QtWidgets.QSpinBox = QtWidgets.QSpinBox() self.x_max_spinbox.setRange(100, 10000) self.x_max_spinbox.setValue(1400) self.x_max_spinbox.valueChanged.connect(self._update_scales) - self.y_min_label = QtWidgets.QLabel("Y Min:") - self.y_min_spinbox = QtWidgets.QSpinBox() + self.y_min_label: QtWidgets.QLabel = QtWidgets.QLabel("Y Min:") + self.y_min_spinbox: QtWidgets.QSpinBox = QtWidgets.QSpinBox() self.y_min_spinbox.setRange(0, 1000) self.y_min_spinbox.setValue(25) self.y_min_spinbox.valueChanged.connect(self._update_scales) - self.y_max_label = QtWidgets.QLabel("Y Max:") - self.y_max_spinbox = QtWidgets.QSpinBox() + self.y_max_label: QtWidgets.QLabel = QtWidgets.QLabel("Y Max:") + self.y_max_spinbox: QtWidgets.QSpinBox = QtWidgets.QSpinBox() self.y_max_spinbox.setRange(100, 10000) self.y_max_spinbox.setValue(1200) self.y_max_spinbox.valueChanged.connect(self._update_scales) - def _update_scales(self): + def _update_scales(self) -> None: if self._restoring_state: return new_x = self.x_max_spinbox.value() new_ymin = self.y_min_spinbox.value() new_ymax = self.y_max_spinbox.value() - # Enforce invariant if new_ymax <= new_ymin: new_ymax = new_ymin + 1 self.y_max_spinbox.setValue(new_ymax) @@ -289,13 +229,13 @@ def _update_scales(self): # ------------------------------------------------------------------ # Events # ------------------------------------------------------------------ - def paintEvent(self, a0): - qp = QtGui.QPainter() - qp.begin(self) + def paintEvent(self, a0: QtGui.QPaintEvent | None) -> None: + qp = QtGui.QPainter(self) self._draw(qp) - qp.end() - def mousePressEvent(self, a0): + def mousePressEvent(self, a0: QtGui.QMouseEvent | None) -> None: + if a0 is None: + return self.setFocus() self._drag_point = None self._pre_drag_points = None @@ -311,13 +251,13 @@ def mousePressEvent(self, a0): if abs(px - mx) < self._cv_point_size + 4 and abs(py - my) < self._cv_point_size + 4: self._drag_point = (idx, (px - mx, py - my)) self._selected_point = idx - # record original points for later comparison (drag end) self._pre_drag_points = copy.deepcopy(self.curve._cv_points) break self.update() - def mouseReleaseEvent(self, a0): - # Save state after drag if something actually moved + def mouseReleaseEvent(self, a0: QtGui.QMouseEvent | None) -> None: + if a0 is None: + return if self._drag_point is not None and self._pre_drag_points is not None: if self.curve._cv_points != self._pre_drag_points: self._push_state("Move point") @@ -325,7 +265,9 @@ def mouseReleaseEvent(self, a0): self._drag_point = None self._pre_drag_points = None - def mouseDoubleClickEvent(self, a0): + def mouseDoubleClickEvent(self, a0: QtGui.QMouseEvent | None) -> None: + if a0 is None: + return mouse_pos = a0.pos() mouse_x = mouse_pos.x() - self._legend_border - self._padding mouse_y = mouse_pos.y() - self._padding @@ -347,26 +289,24 @@ def mouseDoubleClickEvent(self, a0): (1 - mouse_y / float(graph_height)) * (self.curve.y_max - self.curve.y_min) + self.curve.y_min, ), ) - # Snap new point's Y to existing point Y values if close (screen-space) for _, existing_y in self.curve.get_cv_points(): if abs(self._get_y_value_for(existing_y) - self._get_y_value_for(local_y)) <= self._cv_point_size * 2: local_y = existing_y break - # Perform modification first self.curve.add_cv_point(local_x, local_y) self.update() - # Save state AFTER adding self._push_state("Add point") self._update_window_title() - def mouseMoveEvent(self, a0): + def mouseMoveEvent(self, a0: QtGui.QMouseEvent | None) -> None: + if a0 is None: + return if self._drag_point is None: return idx, (ox, oy) = self._drag_point mouse_pos = a0.pos() - # Screen position for point (apply stored offset) point_screen_x = mouse_pos.x() + ox point_screen_y = mouse_pos.y() + oy @@ -385,7 +325,6 @@ def mouseMoveEvent(self, a0): normalized_y * (self.curve.y_max - self.curve.y_min) + self.curve.y_min, ), ) - # Snap dragged point's Y to other points if close (exclude itself) for other_idx, (_, existing_y) in enumerate(self.curve.get_cv_points()): if other_idx == idx: continue @@ -400,22 +339,21 @@ def mouseMoveEvent(self, a0): # ------------------------------------------------------------------ # Coordinate helpers # ------------------------------------------------------------------ - def _get_y_value_for(self, local_value): + def _get_y_value_for(self, local_value: float) -> float: y_range = self.curve.y_max - self.curve.y_min normalized_value = (local_value - self.curve.y_min) / y_range - normalized_value = max(0, min(1.0, 1.0 - normalized_value)) + normalized_value = max(0.0, min(1.0, 1.0 - normalized_value)) return normalized_value * (self.height() - self._legend_border - self._control_bar_height - 2 * self._padding) + self._padding - def _get_x_value_for(self, local_value): + def _get_x_value_for(self, local_value: float) -> float: normalized_value = local_value / self.curve.x_max - normalized_value = max(0, min(1.0, normalized_value)) + normalized_value = max(0.0, min(1.0, normalized_value)) return normalized_value * (self.width() - self._legend_border - 2 * self._padding) + self._legend_border + self._padding # ------------------------------------------------------------------ # Drawing # ------------------------------------------------------------------ - def _draw(self, painter): - canvas_width = self.width() - self._legend_border - 2 * self._padding + def _draw(self, painter: QtGui.QPainter) -> None: canvas_height = self.height() - self._legend_border - self._control_bar_height - 2 * self._padding palette = self.palette() @@ -423,7 +361,6 @@ def _draw(self, painter): painter.setBrush(palette.color(QtGui.QPalette.ColorRole.Base)) painter.drawRect(0, 0, self.width() - 1, self.height() - 1) - # Grid lines num_vert_lines = 7 line_spacing_x = (self.width() - self._legend_border - 2 * self._padding) / 7.0 line_spacing_y = (self.height() - self._legend_border - self._control_bar_height - 2 * self._padding) / 10.0 @@ -439,7 +376,6 @@ def _draw(self, painter): line_pos = canvas_height - i * line_spacing_y + self._padding painter.drawLine(self._legend_border, int(line_pos), self.width(), int(line_pos)) - # Y axis labels painter.setPen(palette.color(QtGui.QPalette.ColorRole.Text)) y_range = self.curve.y_max - self.curve.y_min for i in range(num_horiz_lines): @@ -447,7 +383,6 @@ def _draw(self, painter): value = int(self.curve.y_min + (y_range * i / (num_horiz_lines - 1))) painter.drawText(6, int(line_pos + 3), str(value)) - # X axis labels for i in range(num_vert_lines + 1): line_pos = i * line_spacing_x + self._legend_border + self._padding offpos_x = -14 @@ -456,13 +391,8 @@ def _draw(self, painter): elif i == num_vert_lines: offpos_x = -33 value = int(self.curve.x_max * i / num_vert_lines) - painter.drawText( - int(line_pos + offpos_x), - canvas_height + self._padding + 18, - str(value), - ) + painter.drawText(int(line_pos + offpos_x), canvas_height + self._padding + 18, str(value)) - # Control bar background control_bar_y = canvas_height + self._padding + 25 painter.setPen(palette.color(QtGui.QPalette.ColorRole.Mid)) painter.setBrush(palette.color(QtGui.QPalette.ColorRole.Window)) @@ -470,7 +400,6 @@ def _draw(self, painter): self._position_controls(control_bar_y) - # Curve lines painter.setPen(palette.color(QtGui.QPalette.ColorRole.Text)) sorted_points = sorted(self.curve.get_cv_points(), key=lambda v: v[0]) for i in range(len(sorted_points) - 1): @@ -483,7 +412,6 @@ def _draw(self, painter): int(self._get_y_value_for(y2)), ) - # Points painter.setBrush(palette.color(QtGui.QPalette.ColorRole.Base)) for idx, (x, y) in enumerate(self.curve.get_cv_points()): offs_x = self._get_x_value_for(x) @@ -502,7 +430,7 @@ def _draw(self, painter): # ------------------------------------------------------------------ # Control positioning # ------------------------------------------------------------------ - def _position_controls(self, y_pos): + def _position_controls(self, y_pos: int) -> None: x_offset = 10 self.x_max_label.setParent(self) self.x_max_label.move(x_offset, y_pos + 10) @@ -533,8 +461,7 @@ def _position_controls(self, y_pos): self.y_max_spinbox.resize(80, 25) self.y_max_spinbox.show() - def keyPressEvent(self, a0): - # Allow deleting the currently selected point with Delete or Backspace + def keyPressEvent(self, a0: QtGui.QKeyEvent | None) -> None: if a0 is None: return if a0.key() in (QtCore.Qt.Key.Key_Delete, QtCore.Qt.Key.Key_Backspace): From f88dcfe2f44084c650c9a5c4767a240c1d7a9003 Mon Sep 17 00:00:00 2001 From: Tnixc Date: Fri, 19 Sep 2025 20:29:17 -0400 Subject: [PATCH 05/12] fix dragging points past each other --- config-create/widgets/curve_widget.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/config-create/widgets/curve_widget.py b/config-create/widgets/curve_widget.py index a99bd80..704918f 100644 --- a/config-create/widgets/curve_widget.py +++ b/config-create/widgets/curve_widget.py @@ -260,6 +260,10 @@ def mouseReleaseEvent(self, a0: QtGui.QMouseEvent | None) -> None: return if self._drag_point is not None and self._pre_drag_points is not None: if self.curve._cv_points != self._pre_drag_points: + # Defer sorting until drag end to avoid index changes during drag + # that previously caused the actively moved point to switch and + # appear as though points were deleted when crossing neighbors. + self.curve.build_curve() self._push_state("Move point") self._update_window_title() self._drag_point = None @@ -333,7 +337,9 @@ def mouseMoveEvent(self, a0: QtGui.QMouseEvent | None) -> None: break self.curve.set_cv_value(idx, local_x, local_y) - self.curve.build_curve() + # Do NOT sort here; sorting during drag changes indices and can + # cause the dragged point reference to shift to another point. + # We sort once on mouse release instead. self.update() # ------------------------------------------------------------------ From 77765de4ad4023cecea4949bf5c8fca5349ccc13 Mon Sep 17 00:00:00 2001 From: Tnixc Date: Fri, 19 Sep 2025 20:36:27 -0400 Subject: [PATCH 06/12] add help bar on bottom --- config-create/widgets/curve_widget.py | 63 ++++++++++++++++++--------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/config-create/widgets/curve_widget.py b/config-create/widgets/curve_widget.py index 704918f..95ec2fa 100644 --- a/config-create/widgets/curve_widget.py +++ b/config-create/widgets/curve_widget.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +import sys import PyQt6.QtCore as QtCore import PyQt6.QtGui as QtGui import PyQt6.QtWidgets as QtWidgets @@ -32,6 +33,7 @@ def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: self._cv_point_size: int = 6 self._legend_border: int = 35 self._control_bar_height: int = 40 + self._help_bar_height: int = 22 self._padding: int = 12 # Drag / selection tracking @@ -135,22 +137,21 @@ def _setup_shortcuts(self) -> None: self.redo_shortcut.activated.connect(self._redo) def _update_window_title(self) -> None: + # Keep window title simple; detailed instructions now live in bottom help bar. parent_window = self.window() - if not parent_window: - return - base_title = "Qt6 Curve Editor" - undo_available = self._history_index > 0 - redo_available = self._history_index < len(self._history) - 1 - status_parts: list[str] = [] - if undo_available: - status_parts.append("Ctrl+Z: Undo") - if redo_available: - status_parts.append("Ctrl+Shift+Z: Redo") - if status_parts: - title = f"{base_title} - {' | '.join(status_parts)} | Right-click to delete points | Delete: Remove selected point" - else: - title = f"{base_title} - Right-click to delete points | Delete: Remove selected point" - parent_window.setWindowTitle(title) + if parent_window: + parent_window.setWindowTitle("Qt6 Curve Editor") + # Refresh help label text (in case platform-specific modifier differs). + if hasattr(self, "help_label"): + self.help_label.setText(self._build_help_text()) + + def _build_help_text(self) -> str: + mod = "Cmd" if sys.platform == "darwin" else "Ctrl" + # We could reflect undo/redo availability dynamically, but for simplicity we always show them. + return ( + f"Double-click: Add point | Right-click: Delete point | Delete: Remove selected | " + f"{mod}+Z: Undo | {mod}+Shift+Z: Redo | Drag: Move point" + ) # ------------------------------------------------------------------ # Context menu / point deletion @@ -210,6 +211,14 @@ def _create_controls(self) -> None: self.y_max_spinbox.setValue(1200) self.y_max_spinbox.valueChanged.connect(self._update_scales) + # Help / instruction label (populated in _update_window_title) + self.help_label: QtWidgets.QLabel = QtWidgets.QLabel() + self.help_label.setText(self._build_help_text()) + font = self.help_label.font() + font.setPointSize(max(8, font.pointSize() - 1)) + self.help_label.setFont(font) + self.help_label.setStyleSheet("color: gray;") + def _update_scales(self) -> None: if self._restoring_state: return @@ -277,7 +286,7 @@ def mouseDoubleClickEvent(self, a0: QtGui.QMouseEvent | None) -> None: mouse_y = mouse_pos.y() - self._padding graph_width = self.width() - self._legend_border - 2 * self._padding - graph_height = self.height() - self._legend_border - self._control_bar_height - 2 * self._padding + graph_height = self.height() - self._legend_border - self._control_bar_height - self._help_bar_height - 2 * self._padding local_x = max( 0, @@ -318,7 +327,7 @@ def mouseMoveEvent(self, a0: QtGui.QMouseEvent | None) -> None: graph_y = point_screen_y - self._padding graph_width = self.width() - self._legend_border - 2 * self._padding - graph_height = self.height() - self._legend_border - self._control_bar_height - 2 * self._padding + graph_height = self.height() - self._legend_border - self._control_bar_height - self._help_bar_height - 2 * self._padding local_x = max(0, min(self.curve.x_max, graph_x / float(graph_width) * self.curve.x_max)) normalized_y = 1.0 - (graph_y / float(graph_height)) @@ -349,7 +358,10 @@ def _get_y_value_for(self, local_value: float) -> float: y_range = self.curve.y_max - self.curve.y_min normalized_value = (local_value - self.curve.y_min) / y_range normalized_value = max(0.0, min(1.0, 1.0 - normalized_value)) - return normalized_value * (self.height() - self._legend_border - self._control_bar_height - 2 * self._padding) + self._padding + return ( + normalized_value * (self.height() - self._legend_border - self._control_bar_height - self._help_bar_height - 2 * self._padding) + + self._padding + ) def _get_x_value_for(self, local_value: float) -> float: normalized_value = local_value / self.curve.x_max @@ -360,7 +372,7 @@ def _get_x_value_for(self, local_value: float) -> float: # Drawing # ------------------------------------------------------------------ def _draw(self, painter: QtGui.QPainter) -> None: - canvas_height = self.height() - self._legend_border - self._control_bar_height - 2 * self._padding + canvas_height = self.height() - self._legend_border - self._control_bar_height - self._help_bar_height - 2 * self._padding palette = self.palette() painter.setPen(palette.color(QtGui.QPalette.ColorRole.Mid)) @@ -369,7 +381,7 @@ def _draw(self, painter: QtGui.QPainter) -> None: num_vert_lines = 7 line_spacing_x = (self.width() - self._legend_border - 2 * self._padding) / 7.0 - line_spacing_y = (self.height() - self._legend_border - self._control_bar_height - 2 * self._padding) / 10.0 + line_spacing_y = (self.height() - self._legend_border - self._control_bar_height - self._help_bar_height - 2 * self._padding) / 10.0 num_horiz_lines = 11 painter.setPen(palette.color(QtGui.QPalette.ColorRole.Window)) @@ -403,6 +415,8 @@ def _draw(self, painter: QtGui.QPainter) -> None: painter.setPen(palette.color(QtGui.QPalette.ColorRole.Mid)) painter.setBrush(palette.color(QtGui.QPalette.ColorRole.Window)) painter.drawRect(0, control_bar_y, self.width(), self._control_bar_height) + # Help bar rectangle just below control bar + painter.drawRect(0, control_bar_y + self._control_bar_height, self.width(), self._help_bar_height) self._position_controls(control_bar_y) @@ -467,6 +481,15 @@ def _position_controls(self, y_pos: int) -> None: self.y_max_spinbox.resize(80, 25) self.y_max_spinbox.show() + # Position help label in a separate bar below the control bar + self.help_label.setParent(self) + help_bar_y = y_pos + self._control_bar_height + # Dynamically size and vertically center help label within the help bar + label_height = max(12, self._help_bar_height - 6) + self.help_label.resize(self.width() - 20, label_height) + self.help_label.move(10, help_bar_y + (self._help_bar_height - label_height) // 2) + self.help_label.show() + def keyPressEvent(self, a0: QtGui.QKeyEvent | None) -> None: if a0 is None: return From 503b98bdcbf925651df77f9edc4646bbf8857370 Mon Sep 17 00:00:00 2001 From: Tnixc Date: Fri, 19 Sep 2025 20:44:15 -0400 Subject: [PATCH 07/12] cleanups --- config-create/widgets/curve_widget.py | 66 ++++++++++++++++----------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/config-create/widgets/curve_widget.py b/config-create/widgets/curve_widget.py index 95ec2fa..18c7b37 100644 --- a/config-create/widgets/curve_widget.py +++ b/config-create/widgets/curve_widget.py @@ -375,48 +375,62 @@ def _draw(self, painter: QtGui.QPainter) -> None: canvas_height = self.height() - self._legend_border - self._control_bar_height - self._help_bar_height - 2 * self._padding palette = self.palette() + # Draw only the graph (plot) area background; leave rest transparent painter.setPen(palette.color(QtGui.QPalette.ColorRole.Mid)) painter.setBrush(palette.color(QtGui.QPalette.ColorRole.Base)) - painter.drawRect(0, 0, self.width() - 1, self.height() - 1) - - num_vert_lines = 7 - line_spacing_x = (self.width() - self._legend_border - 2 * self._padding) / 7.0 - line_spacing_y = (self.height() - self._legend_border - self._control_bar_height - self._help_bar_height - 2 * self._padding) / 10.0 - num_horiz_lines = 11 + graph_width = self.width() - self._legend_border - 2 * self._padding + painter.drawRect(self._legend_border, self._padding, graph_width, canvas_height) + + # Build grid positions at each 100 units (nearest hundred marks) plus end caps + x_values: list[float] = [] + step = 100.0 + xv = 0.0 + while xv < self.curve.x_max: + x_values.append(xv) + xv += step + if not x_values or x_values[-1] != self.curve.x_max: + x_values.append(self.curve.x_max) + # Y values from y_min upward every 100, include y_max + y_values: list[float] = [] + yv = self.curve.y_min + while yv < self.curve.y_max: + y_values.append(yv) + yv += step + if not y_values or y_values[-1] != self.curve.y_max: + y_values.append(self.curve.y_max) painter.setPen(palette.color(QtGui.QPalette.ColorRole.Window)) - for i in range(num_vert_lines + 1): - line_pos = i * line_spacing_x + self._legend_border + self._padding + for xv in x_values: + line_pos = ( + (xv / self.curve.x_max) * (self.width() - self._legend_border - 2 * self._padding) + self._legend_border + self._padding + ) painter.drawLine(int(line_pos), self._padding, int(line_pos), canvas_height + self._padding) painter.setPen(palette.color(QtGui.QPalette.ColorRole.Window)) - for i in range(num_horiz_lines): - line_pos = canvas_height - i * line_spacing_y + self._padding - painter.drawLine(self._legend_border, int(line_pos), self.width(), int(line_pos)) + for yv in y_values: + py = self._get_y_value_for(yv) + painter.drawLine(self._legend_border, int(py), self.width(), int(py)) painter.setPen(palette.color(QtGui.QPalette.ColorRole.Text)) - y_range = self.curve.y_max - self.curve.y_min - for i in range(num_horiz_lines): - line_pos = canvas_height - i * line_spacing_y + self._padding - value = int(self.curve.y_min + (y_range * i / (num_horiz_lines - 1))) - painter.drawText(6, int(line_pos + 3), str(value)) + for yv in y_values: + py = self._get_y_value_for(yv) + painter.drawText(6, int(py + 3), str(int(yv))) - for i in range(num_vert_lines + 1): - line_pos = i * line_spacing_x + self._legend_border + self._padding + for idx, xv in enumerate(x_values): + line_pos = ( + (xv / self.curve.x_max) * (self.width() - self._legend_border - 2 * self._padding) + self._legend_border + self._padding + ) offpos_x = -14 - if i == 0: + if idx == 0: offpos_x = -2 - elif i == num_vert_lines: + elif idx == len(x_values) - 1: offpos_x = -33 - value = int(self.curve.x_max * i / num_vert_lines) - painter.drawText(int(line_pos + offpos_x), canvas_height + self._padding + 18, str(value)) + painter.drawText(int(line_pos + offpos_x), canvas_height + self._padding + 18, str(int(xv))) control_bar_y = canvas_height + self._padding + 25 + # Omit control bar background for a cleaner look painter.setPen(palette.color(QtGui.QPalette.ColorRole.Mid)) - painter.setBrush(palette.color(QtGui.QPalette.ColorRole.Window)) - painter.drawRect(0, control_bar_y, self.width(), self._control_bar_height) - # Help bar rectangle just below control bar - painter.drawRect(0, control_bar_y + self._control_bar_height, self.width(), self._help_bar_height) + # (Intentionally not drawing a rect for control bar background) self._position_controls(control_bar_y) From 8d5ec0fcf43d00a9a6ff64b13278127df2eadecd Mon Sep 17 00:00:00 2001 From: Tnixc Date: Fri, 19 Sep 2025 21:18:00 -0400 Subject: [PATCH 08/12] more progess, styling, added points table --- config-create/main.py | 342 +++++++++++++++++++++++++++++++--- config-create/models/curve.py | 4 +- 2 files changed, 318 insertions(+), 28 deletions(-) diff --git a/config-create/main.py b/config-create/main.py index c4cdeb5..0699bd0 100644 --- a/config-create/main.py +++ b/config-create/main.py @@ -1,47 +1,337 @@ +from __future__ import annotations + import sys -import PyQt6.QtCore as QtCore -# Removed unused PyQt6.QtGui import +import PyQt6.QtCore as QtCore +import PyQt6.QtGui as QtGui import PyQt6.QtWidgets as QtWidgets -from widgets.curve_widget import CurveWidget +# Import the existing curve widget so we can subclass it to add signals +from widgets.curve_widget import CurveWidget as BaseCurveWidget + + +class CurveWidget(BaseCurveWidget): + """ + Thin subclass of the existing CurveWidget that emits signals whenever the + set of control points changes or the selected point changes. + + We hook into _push_state (called after each logical modification) and + _restore_state (used by undo/redo) to broadcast changes to outside + components like the sidebar table. + + NOTE: We deliberately keep the parent widget logic untouched; only signal + emission is added here to avoid editing the original file. + """ + + pointsChanged = QtCore.pyqtSignal() # Emitted after points list changes + selectionChanged = QtCore.pyqtSignal(int) # Emitted with selected point index or -1 + + def _push_state(self, action_name: str = "Unknown") -> None: # type: ignore[override] + super()._push_state(action_name) + self.pointsChanged.emit() + + def _restore_state(self, state): # type: ignore[override] + super()._restore_state(state) + self.pointsChanged.emit() + + def mousePressEvent(self, a0: QtGui.QMouseEvent | None) -> None: # type: ignore[override] + super().mousePressEvent(a0) + # After base handling, emit current selection + selected_index = getattr(self, "_selected_point", None) + if selected_index is None: + self.selectionChanged.emit(-1) + else: + self.selectionChanged.emit(selected_index) + + def mouseDoubleClickEvent(self, a0: QtGui.QMouseEvent | None) -> None: # type: ignore[override] + super().mouseDoubleClickEvent(a0) + self.pointsChanged.emit() + + def mouseReleaseEvent(self, a0: QtGui.QMouseEvent | None) -> None: # type: ignore[override] + pre_points = [p[:] for p in self.curve.get_cv_points()] + super().mouseReleaseEvent(a0) + post_points = self.curve.get_cv_points() + if pre_points != post_points: + self.pointsChanged.emit() + + +class PointsTable(QtWidgets.QTableWidget): + """ + Table displaying control points: + + Columns: + 0 - Index (sorted order) + 1 - X position (editable) + 2 - Y position (editable) + 3 - ΔY from previous point (read only) + """ + + COL_INDEX = 0 + COL_X = 1 + COL_Y = 2 + COL_DY = 3 + + headers = ["Idx", "X", "Y", "ΔY"] + + pointEdited = QtCore.pyqtSignal(int, float, float) # row index (sorted), new x, new y + + def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: + super().__init__(0, len(self.headers), parent) + self.setHorizontalHeaderLabels(self.headers) + self.verticalHeader().setVisible(False) + self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) + self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection) + self.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.DoubleClicked | QtWidgets.QAbstractItemView.EditTrigger.EditKeyPressed) + self.horizontalHeader().setStretchLastSection(True) + self.setAlternatingRowColors(True) + self._suppress_item_handler = False + + # Narrow index / delta columns + self.setColumnWidth(self.COL_INDEX, 40) + self.setColumnWidth(self.COL_DY, 60) + + self.itemChanged.connect(self._handle_item_changed) + + def populate(self, points: list[list[float]]) -> None: + """ + Populate table with sorted points. + """ + self._suppress_item_handler = True + try: + self.setRowCount(len(points)) + for row, (x, y) in enumerate(points): + index_item = QtWidgets.QTableWidgetItem(str(row)) + index_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled) + self.setItem(row, self.COL_INDEX, index_item) + + x_item = QtWidgets.QTableWidgetItem(self._format_float(x)) + x_item.setFlags(self._editable_flags()) + self.setItem(row, self.COL_X, x_item) + + y_item = QtWidgets.QTableWidgetItem(self._format_float(y)) + y_item.setFlags(self._editable_flags()) + self.setItem(row, self.COL_Y, y_item) + + dy_value = 0.0 if row == 0 else (y - points[row - 1][1]) + dy_item = QtWidgets.QTableWidgetItem(self._format_float(dy_value)) + dy_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled) + self.setItem(row, self.COL_DY, dy_item) + finally: + self._suppress_item_handler = False + + def select_point(self, sorted_index: int) -> None: + """ + Select the table row corresponding to the given sorted index. + """ + if 0 <= sorted_index < self.rowCount(): + self.setCurrentCell(sorted_index, self.COL_INDEX) + + def _editable_flags(self) -> QtCore.Qt.ItemFlag: + return QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsEditable + + def _handle_item_changed(self, item: QtWidgets.QTableWidgetItem) -> None: + if self._suppress_item_handler: + return + row = item.row() + col = item.column() + if col not in (self.COL_X, self.COL_Y): + return + + # Parse current X / Y row data safely + def parse_float(cell: QtWidgets.QTableWidgetItem) -> float | None: + try: + return float(cell.text()) + except Exception: + return None + + x_item = self.item(row, self.COL_X) + y_item = self.item(row, self.COL_Y) + if x_item is None or y_item is None: + return + x_val = parse_float(x_item) + y_val = parse_float(y_item) + if x_val is None or y_val is None: + return + + self.pointEdited.emit(row, x_val, y_val) + + @staticmethod + def _format_float(v: float) -> str: + if abs(v) >= 1000 or v.is_integer(): + return f"{v:.0f}" + return f"{v:.2f}" class Editor(QtWidgets.QMainWindow): + """ + Main window hosting the curve editor and the sidebar table. + """ + def __init__(self) -> None: super().__init__() - self.resize(580, 480) + self.resize(900, 520) self.setWindowTitle("Qt6 Curve Editor") - # Create central widget - central_widget = QtWidgets.QWidget() - self.setCentralWidget(central_widget) + # Central splitter + splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal) + self.setCentralWidget(splitter) - # Create layout - layout = QtWidgets.QVBoxLayout(central_widget) + # Sidebar (table) + sidebar_container = QtWidgets.QWidget() + sidebar_layout = QtWidgets.QVBoxLayout(sidebar_container) + sidebar_layout.setContentsMargins(6, 6, 6, 6) + sidebar_layout.setSpacing(4) - # Create curve widget + header_label = QtWidgets.QLabel("Control Points") + font = header_label.font() + font.setBold(True) + header_label.setFont(font) + sidebar_layout.addWidget(header_label) + + self.table = PointsTable() + sidebar_layout.addWidget(self.table, 1) + + # Buttons (optional) + button_row = QtWidgets.QHBoxLayout() + self.btn_add = QtWidgets.QPushButton("Add Point") + self.btn_remove = QtWidgets.QPushButton("Remove Selected") + button_row.addWidget(self.btn_add) + button_row.addWidget(self.btn_remove) + sidebar_layout.addLayout(button_row) + + splitter.addWidget(sidebar_container) + + # Curve widget (right side) self.curve_widget: CurveWidget = CurveWidget(self) - layout.addWidget(self.curve_widget) + splitter.addWidget(self.curve_widget) + + splitter.setStretchFactor(0, 0) + splitter.setStretchFactor(1, 1) + splitter.setSizes([260, 640]) + + # Connections + self.curve_widget.pointsChanged.connect(self._refresh_table_from_curve) + self.curve_widget.selectionChanged.connect(self._handle_curve_selection) + self.table.pointEdited.connect(self._handle_table_edit) + self.btn_add.clicked.connect(self._handle_add_point) + self.btn_remove.clicked.connect(self._handle_remove_selected) + + # Initial population + self._refresh_table_from_curve() + + # ------------------------------------------------------------------ # + # Synchronization helpers + # ------------------------------------------------------------------ # + def _sorted_points(self) -> list[list[float]]: + return sorted(self.curve_widget.curve.get_cv_points(), key=lambda p: p[0]) + + def _refresh_table_from_curve(self) -> None: + points = self._sorted_points() + self.table.populate(points) + + def _handle_curve_selection(self, index: int) -> None: + if index >= 0: + # We need to locate selected point's position in sorted order + sorted_pts = self._sorted_points() + raw_point = self.curve_widget.curve.get_cv_points()[index] + # Identify by (x,y) match (duplicate safety minimal point counts) + for sorted_index, p in enumerate(sorted_pts): + if p is raw_point or (p[0] == raw_point[0] and p[1] == raw_point[1]): + self.table.select_point(sorted_index) + break + + def _handle_table_edit(self, sorted_row: int, new_x: float, new_y: float) -> None: + """ + Apply edited X/Y back to underlying curve. + + sorted_row: row index in sorted order (0..n-1) + """ + points = self._sorted_points() + if not (0 <= sorted_row < len(points)): + return + + # Find the real underlying point reference. Since build_curve sorts in-place, + # sorted order now matches internal order after modifications that push state. + # To be safe, we re-sort internal list and then apply update by mapping index. + self.curve_widget.curve.build_curve() + internal_points = self.curve_widget.curve.get_cv_points() + + # Clamp within axis scales + new_x_clamped = max(0.0, min(self.curve_widget.curve.x_max, new_x)) + new_y_clamped = max(self.curve_widget.curve.y_min, min(self.curve_widget.curve.y_max, new_y)) + + self.curve_widget.curve.set_cv_value(sorted_row, new_x_clamped, new_y_clamped) + self.curve_widget.curve.build_curve() + self.curve_widget.update() + # Use protected push_state to keep undo chain + self.curve_widget._push_state("Edit point (table)") + self.curve_widget.pointsChanged.emit() + + def _handle_add_point(self) -> None: + """ + Add a point halfway between currently selected and next (or end). + """ + sorted_pts = self._sorted_points() + if not sorted_pts: + self.curve_widget.curve.add_cv_point(0.0, self.curve_widget.curve.y_min) + self.curve_widget._push_state("Add point (button)") + self.curve_widget.pointsChanged.emit() + return + + current_row = self.table.currentRow() + if current_row < 0: + # Add after last + last_x, last_y = sorted_pts[-1] + new_x = min(last_x + 50.0, self.curve_widget.curve.x_max) + new_y = last_y + else: + if current_row == len(sorted_pts) - 1: + # After last + base_x, base_y = sorted_pts[current_row] + new_x = min(base_x + 50.0, self.curve_widget.curve.x_max) + new_y = base_y + else: + x1, y1 = sorted_pts[current_row] + x2, y2 = sorted_pts[current_row + 1] + new_x = x1 + (x2 - x1) / 2.0 + new_y = y1 + (y2 - y1) / 2.0 + + self.curve_widget.curve.add_cv_point(new_x, new_y) + self.curve_widget.update() + self.curve_widget._push_state("Add point (button)") + self.curve_widget.pointsChanged.emit() + + def _handle_remove_selected(self) -> None: + """ + Remove selected point (in table sorted order) if more than one point exists. + """ + row = self.table.currentRow() + if row < 0: + return + internal_points = self.curve_widget.curve.get_cv_points() + if len(internal_points) <= 1: + return + # Ensure internal order is sorted, then delete by index + self.curve_widget.curve.build_curve() + del internal_points[row] + self.curve_widget.curve.build_curve() + self.curve_widget.update() + self.curve_widget._push_state("Delete point (button)") + self.curve_widget.pointsChanged.emit() - # Ensure the curve widget can receive focus for keyboard shortcuts - self.curve_widget.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus) - self.curve_widget.setFocus() + # ------------------------------------------------------------------ # + # Close event / convenience + # ------------------------------------------------------------------ # + def closeEvent(self, a0: QtGui.QCloseEvent | None) -> None: # type: ignore[override] + super().closeEvent(a0) def main() -> int: - """Main function with proper error handling""" - try: - app = QtWidgets.QApplication(sys.argv) - app.setApplicationName("Qt6 Curve Editor") - - editor = Editor() - editor.show() - - return app.exec() - except Exception as e: - print(f"Error starting application: {e}") - return 1 + app = QtWidgets.QApplication(sys.argv) + app.setApplicationName("Qt6 Curve Editor") + window = Editor() + window.show() + return app.exec() if __name__ == "__main__": diff --git a/config-create/models/curve.py b/config-create/models/curve.py index 28cd582..02791c0 100644 --- a/config-create/models/curve.py +++ b/config-create/models/curve.py @@ -17,7 +17,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Iterable, List +from collections.abc import Iterable ControlPoint = list[float] # [x, y] @@ -181,7 +181,7 @@ def get_value(self, offset: float) -> float: return 0.0 # Ensure ordering (cheap if already sorted). - sorted_points: List[ControlPoint] = sorted(self._cv_points, key=lambda v: v[0]) + sorted_points: list[ControlPoint] = sorted(self._cv_points, key=lambda v: v[0]) # Clamp to range if offset <= sorted_points[0][0]: From 42d1410cc172692f4d8c6d673dda82504795e627 Mon Sep 17 00:00:00 2001 From: Tnixc Date: Fri, 19 Sep 2025 22:36:57 -0400 Subject: [PATCH 09/12] more columns on sidebar info --- config-create/main.py | 46 +++++++++++++++++++++------ config-create/widgets/curve_widget.py | 21 ++++++++++++ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/config-create/main.py b/config-create/main.py index 0699bd0..4fab8c4 100644 --- a/config-create/main.py +++ b/config-create/main.py @@ -60,18 +60,22 @@ class PointsTable(QtWidgets.QTableWidget): Table displaying control points: Columns: - 0 - Index (sorted order) - 1 - X position (editable) - 2 - Y position (editable) - 3 - ΔY from previous point (read only) + 0 - Index (sorted order) + 1 - X position (editable) + 2 - Y position (editable) + 3 - ΔX from previous point (read only) + 4 - ΔY from previous point (read only) + 5 - Slope (ΔY/ΔX from previous point) """ COL_INDEX = 0 COL_X = 1 COL_Y = 2 - COL_DY = 3 + COL_DX = 3 + COL_DY = 4 + COL_SLOPE = 5 - headers = ["Idx", "X", "Y", "ΔY"] + headers = ["Idx", "X", "Y", "ΔX", "ΔY", "Slope"] pointEdited = QtCore.pyqtSignal(int, float, float) # row index (sorted), new x, new y @@ -86,9 +90,13 @@ def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: self.setAlternatingRowColors(True) self._suppress_item_handler = False - # Narrow index / delta columns - self.setColumnWidth(self.COL_INDEX, 40) + # Narrow index / delta / slope columns + self.setColumnWidth(self.COL_INDEX, 60) + self.setColumnWidth(self.COL_X, 60) + self.setColumnWidth(self.COL_Y, 60) + self.setColumnWidth(self.COL_DX, 60) self.setColumnWidth(self.COL_DY, 60) + self.setColumnWidth(self.COL_SLOPE, 60) self.itemChanged.connect(self._handle_item_changed) @@ -112,10 +120,28 @@ def populate(self, points: list[list[float]]) -> None: y_item.setFlags(self._editable_flags()) self.setItem(row, self.COL_Y, y_item) - dy_value = 0.0 if row == 0 else (y - points[row - 1][1]) + prev_x, prev_y = (x, y) if row == 0 else points[row - 1] + + # ΔX (difference from previous X) + dx_value = 0.0 if row == 0 else (x - prev_x) + dx_item = QtWidgets.QTableWidgetItem(self._format_float(dx_value)) + dx_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled) + self.setItem(row, self.COL_DX, dx_item) + + # ΔY (difference from previous Y) + dy_value = 0.0 if row == 0 else (y - prev_y) dy_item = QtWidgets.QTableWidgetItem(self._format_float(dy_value)) dy_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled) self.setItem(row, self.COL_DY, dy_item) + + # Slope (ΔY / ΔX) relative to previous point (color coded) + if row == 0 or dx_value == 0: + slope_value = 0.0 + else: + slope_value = dy_value / dx_value + slope_item = QtWidgets.QTableWidgetItem(self._format_float(slope_value)) + slope_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled) + self.setItem(row, self.COL_SLOPE, slope_item) finally: self._suppress_item_handler = False @@ -207,7 +233,7 @@ def __init__(self) -> None: splitter.setStretchFactor(0, 0) splitter.setStretchFactor(1, 1) - splitter.setSizes([260, 640]) + splitter.setSizes([360, 640]) # Connections self.curve_widget.pointsChanged.connect(self._refresh_table_from_curve) diff --git a/config-create/widgets/curve_widget.py b/config-create/widgets/curve_widget.py index 18c7b37..52e0c5c 100644 --- a/config-create/widgets/curve_widget.py +++ b/config-create/widgets/curve_widget.py @@ -211,6 +211,20 @@ def _create_controls(self) -> None: self.y_max_spinbox.setValue(1200) self.y_max_spinbox.valueChanged.connect(self._update_scales) + # Copy Points button (copies points as (x1,y1),(x2,y2)...) + self.copy_points_button: QtWidgets.QPushButton = QtWidgets.QPushButton("Copy Points") + self.copy_points_button.setToolTip("Copy points as (x1,y1),(x2,y2)") + + def _do_copy(): + pts = sorted(self.curve.get_cv_points(), key=lambda p: p[0]) + + def fmt(v: float) -> str: + return str(int(v)) if float(v).is_integer() else f"{v:.2f}" + + QtWidgets.QApplication.clipboard().setText(",".join(f"({fmt(x)},{fmt(y)})" for x, y in pts)) + + self.copy_points_button.clicked.connect(_do_copy) + # Help / instruction label (populated in _update_window_title) self.help_label: QtWidgets.QLabel = QtWidgets.QLabel() self.help_label.setText(self._build_help_text()) @@ -495,6 +509,13 @@ def _position_controls(self, y_pos: int) -> None: self.y_max_spinbox.resize(80, 25) self.y_max_spinbox.show() + # Copy Points button + x_offset += 150 + self.copy_points_button.setParent(self) + self.copy_points_button.move(x_offset, y_pos + 8) + self.copy_points_button.resize(110, 25) + self.copy_points_button.show() + # Position help label in a separate bar below the control bar self.help_label.setParent(self) help_bar_y = y_pos + self._control_bar_height From 25e7f10c93f9edbf1f84677ac849f7bece5f6c9d Mon Sep 17 00:00:00 2001 From: Tnixc Date: Fri, 19 Sep 2025 22:40:50 -0400 Subject: [PATCH 10/12] move curve editor, update requirements --- requirements.txt | 2 ++ {config-create => tools/gui/curve-editor}/main.py | 0 {config-create => tools/gui/curve-editor}/models/curve.py | 0 .../gui/curve-editor}/widgets/curve_widget.py | 0 4 files changed, 2 insertions(+) rename {config-create => tools/gui/curve-editor}/main.py (100%) rename {config-create => tools/gui/curve-editor}/models/curve.py (100%) rename {config-create => tools/gui/curve-editor}/widgets/curve_widget.py (100%) diff --git a/requirements.txt b/requirements.txt index 1ff21bf..883866f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,5 @@ ruff esptool mpremote + +pyqt6 diff --git a/config-create/main.py b/tools/gui/curve-editor/main.py similarity index 100% rename from config-create/main.py rename to tools/gui/curve-editor/main.py diff --git a/config-create/models/curve.py b/tools/gui/curve-editor/models/curve.py similarity index 100% rename from config-create/models/curve.py rename to tools/gui/curve-editor/models/curve.py diff --git a/config-create/widgets/curve_widget.py b/tools/gui/curve-editor/widgets/curve_widget.py similarity index 100% rename from config-create/widgets/curve_widget.py rename to tools/gui/curve-editor/widgets/curve_widget.py From 86151ae303679d254f5be6808874bbe08368df07 Mon Sep 17 00:00:00 2001 From: Tnixc Date: Tue, 23 Sep 2025 16:50:49 -0400 Subject: [PATCH 11/12] remove unused functions --- tools/gui/curve-editor/models/curve.py | 85 ------------------- .../gui/curve-editor/widgets/curve_widget.py | 6 -- 2 files changed, 91 deletions(-) diff --git a/tools/gui/curve-editor/models/curve.py b/tools/gui/curve-editor/models/curve.py index 02791c0..eacd52b 100644 --- a/tools/gui/curve-editor/models/curve.py +++ b/tools/gui/curve-editor/models/curve.py @@ -137,91 +137,6 @@ def add_cv_point(self, x_value: float, y_value: float) -> None: self._cv_points.append([float(x_value), float(y_value)]) self.build_curve() - def extend_cv_points(self, points: Iterable[Iterable[float]]) -> None: - """ - Bulk append multiple control points (optional helper) and re-sort. - - Parameters - ---------- - points : Iterable[Iterable[float]] - An iterable of 2-length iterables representing [x, y] pairs. - """ - for p in points: - x, y = p - self._cv_points.append([float(x), float(y)]) - self.build_curve() - - # --------------------------------------------------------------------- # - # Interpolation - # --------------------------------------------------------------------- # - def get_value(self, offset: float) -> float: - """ - Perform linear interpolation for a given X offset. - - Parameters - ---------- - offset : float - X coordinate at which to evaluate the curve. This value is expected - to be within the domain [min_x, max_x] covered by control points, - but clamping behavior is provided for values outside the range. - - Returns - ------- - float - Interpolated Y value. - - Notes - ----- - Unlike the previous docstring that mentioned normalized 0..1 offsets, - this implementation operates in the same unit space as the control - point X values (so typically 0..x_max). Callers that want normalized - sampling should scale accordingly (offset * x_max). - """ - if not self._cv_points: - return 0.0 - - # Ensure ordering (cheap if already sorted). - sorted_points: list[ControlPoint] = sorted(self._cv_points, key=lambda v: v[0]) - - # Clamp to range - if offset <= sorted_points[0][0]: - return sorted_points[0][1] - if offset >= sorted_points[-1][0]: - return sorted_points[-1][1] - - # Linear segment search (O(n)); acceptable for small point counts. - for i in range(len(sorted_points) - 1): - x1, y1 = sorted_points[i] - x2, y2 = sorted_points[i + 1] - if x1 <= offset <= x2: - if x2 == x1: - # Degenerate segment; return start value. - return y1 - t = (offset - x1) / (x2 - x1) - return y1 + t * (y2 - y1) - - # Fallback (should not reach here given earlier clamps) - return 0.0 - - # --------------------------------------------------------------------- # - # Utility / representation - # --------------------------------------------------------------------- # - def copy(self) -> "Curve": - """ - Create a deep (logical) copy of the curve. - - Returns - ------- - Curve - New curve instance with duplicated control points and axis scales. - """ - return Curve( - x_max=self.x_max, - y_min=self.y_min, - y_max=self.y_max, - _cv_points=[p.copy() for p in self._cv_points], - ) - def __repr__(self) -> str: # pragma: no cover - convenience return f"Curve(x_max={self.x_max}, y_min={self.y_min}, y_max={self.y_max}, points={self._cv_points})" diff --git a/tools/gui/curve-editor/widgets/curve_widget.py b/tools/gui/curve-editor/widgets/curve_widget.py index 52e0c5c..20257f4 100644 --- a/tools/gui/curve-editor/widgets/curve_widget.py +++ b/tools/gui/curve-editor/widgets/curve_widget.py @@ -87,12 +87,6 @@ def _push_state(self, action_name: str = "Unknown") -> None: if DEBUG_UNDO: print(f"UNDO DEBUG: push '{action_name}' -> index {self._history_index} / {len(self._history)}") - # Backwards compatibility (legacy code/tests may call _save_state before modifying) - def _save_state(self, action_name: str = "Unknown") -> None: - """Deprecated: kept for backward compatibility. - Unlike legacy behavior we now snapshot current state (post-mod).""" - self._push_state(action_name) - def _undo(self) -> None: if self._history_index > 0: self._history_index -= 1 From fe703e368fd76007b7ed2ed8aba8447b21f0bab8 Mon Sep 17 00:00:00 2001 From: Tnixc Date: Tue, 23 Sep 2025 17:03:48 -0400 Subject: [PATCH 12/12] remove debugging code, shorten docstrings --- tools/gui/curve-editor/main.py | 56 ++-------- tools/gui/curve-editor/models/curve.py | 104 +----------------- .../gui/curve-editor/widgets/curve_widget.py | 33 ++---- 3 files changed, 26 insertions(+), 167 deletions(-) diff --git a/tools/gui/curve-editor/main.py b/tools/gui/curve-editor/main.py index 4fab8c4..69709b0 100644 --- a/tools/gui/curve-editor/main.py +++ b/tools/gui/curve-editor/main.py @@ -11,17 +11,7 @@ class CurveWidget(BaseCurveWidget): - """ - Thin subclass of the existing CurveWidget that emits signals whenever the - set of control points changes or the selected point changes. - - We hook into _push_state (called after each logical modification) and - _restore_state (used by undo/redo) to broadcast changes to outside - components like the sidebar table. - - NOTE: We deliberately keep the parent widget logic untouched; only signal - emission is added here to avoid editing the original file. - """ + """Subclass that emits signals when control points or selection change.""" pointsChanged = QtCore.pyqtSignal() # Emitted after points list changes selectionChanged = QtCore.pyqtSignal(int) # Emitted with selected point index or -1 @@ -56,17 +46,7 @@ def mouseReleaseEvent(self, a0: QtGui.QMouseEvent | None) -> None: # type: igno class PointsTable(QtWidgets.QTableWidget): - """ - Table displaying control points: - - Columns: - 0 - Index (sorted order) - 1 - X position (editable) - 2 - Y position (editable) - 3 - ΔX from previous point (read only) - 4 - ΔY from previous point (read only) - 5 - Slope (ΔY/ΔX from previous point) - """ + """Table of control points: Idx, X, Y, ΔX, ΔY, Slope.""" COL_INDEX = 0 COL_X = 1 @@ -101,9 +81,7 @@ def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: self.itemChanged.connect(self._handle_item_changed) def populate(self, points: list[list[float]]) -> None: - """ - Populate table with sorted points. - """ + # Populate table with sorted points self._suppress_item_handler = True try: self.setRowCount(len(points)) @@ -146,9 +124,7 @@ def populate(self, points: list[list[float]]) -> None: self._suppress_item_handler = False def select_point(self, sorted_index: int) -> None: - """ - Select the table row corresponding to the given sorted index. - """ + # Select row by sorted index if 0 <= sorted_index < self.rowCount(): self.setCurrentCell(sorted_index, self.COL_INDEX) @@ -189,9 +165,7 @@ def _format_float(v: float) -> str: class Editor(QtWidgets.QMainWindow): - """ - Main window hosting the curve editor and the sidebar table. - """ + """Main window hosting curve widget + sidebar table.""" def __init__(self) -> None: super().__init__() @@ -267,11 +241,7 @@ def _handle_curve_selection(self, index: int) -> None: break def _handle_table_edit(self, sorted_row: int, new_x: float, new_y: float) -> None: - """ - Apply edited X/Y back to underlying curve. - - sorted_row: row index in sorted order (0..n-1) - """ + # Apply edited X/Y back to underlying curve (sorted index) points = self._sorted_points() if not (0 <= sorted_row < len(points)): return @@ -294,9 +264,7 @@ def _handle_table_edit(self, sorted_row: int, new_x: float, new_y: float) -> Non self.curve_widget.pointsChanged.emit() def _handle_add_point(self) -> None: - """ - Add a point halfway between currently selected and next (or end). - """ + # Add point midway between current and next (or append after last) sorted_pts = self._sorted_points() if not sorted_pts: self.curve_widget.curve.add_cv_point(0.0, self.curve_widget.curve.y_min) @@ -328,9 +296,7 @@ def _handle_add_point(self) -> None: self.curve_widget.pointsChanged.emit() def _handle_remove_selected(self) -> None: - """ - Remove selected point (in table sorted order) if more than one point exists. - """ + # Remove selected point if more than one remains row = self.table.currentRow() if row < 0: return @@ -345,11 +311,7 @@ def _handle_remove_selected(self) -> None: self.curve_widget._push_state("Delete point (button)") self.curve_widget.pointsChanged.emit() - # ------------------------------------------------------------------ # - # Close event / convenience - # ------------------------------------------------------------------ # - def closeEvent(self, a0: QtGui.QCloseEvent | None) -> None: # type: ignore[override] - super().closeEvent(a0) + # (Removed unused closeEvent override) def main() -> int: diff --git a/tools/gui/curve-editor/models/curve.py b/tools/gui/curve-editor/models/curve.py index eacd52b..54a4829 100644 --- a/tools/gui/curve-editor/models/curve.py +++ b/tools/gui/curve-editor/models/curve.py @@ -1,49 +1,14 @@ -""" -Curve model providing linear interpolation between control points. - -This module separates the data/model responsibilities from the Qt widget -implementation (see widgets/curve_widget.py). The Curve class is intentionally -light‑weight and framework agnostic so it can be re-used or unit tested -independently. - -Design notes: -- Control points are stored as a list of 2-item float lists: [[x, y], ...] - (list of lists instead of tuples) to remain compatible with existing code - that mutates the inner lists by re-assignment (e.g. replacing an element). -- Public API mirrors the previous in-widget implementation for drop‑in use. -- All methods include type hints and docstrings. -""" - from __future__ import annotations from dataclasses import dataclass, field -from collections.abc import Iterable - -ControlPoint = list[float] # [x, y] -ControlPoints = list[ControlPoint] # collection alias +# Lightweight curve data model (control points kept sorted by X). +ControlPoint = list[float] +ControlPoints = list[ControlPoint] @dataclass class Curve: - """ - Represents a 2D curve defined by ordered control points and provides - linear interpolation between those points. - - Attributes - ---------- - x_max : float - Maximum X axis value (domain upper bound). - y_min : float - Minimum Y axis value (range lower bound). - y_max : float - Maximum Y axis value (range upper bound). - _cv_points : list[list[float]] - Internal mutable list of control points. Each point is [x, y]. - Points are expected (but not strictly required) to be within the - configured axis scales. They are kept sorted by X via build_curve(). - """ - x_max: float = 1400.0 y_min: float = 25.0 y_max: float = 1200.0 @@ -56,89 +21,30 @@ class Curve: ] ) - # --------------------------------------------------------------------- # - # Initialization / configuration - # --------------------------------------------------------------------- # def __post_init__(self) -> None: - """ - Normalize numeric types and ensure control points are sorted. - """ - # Coerce to float explicitly (in case ints are passed) + # Normalize numeric types then sort points self.x_max = float(self.x_max) self.y_min = float(self.y_min) self.y_max = float(self.y_max) self.build_curve() def set_axis_scales(self, x_max: float, y_min: float, y_max: float) -> None: - """ - Update axis scale values. - - Parameters - ---------- - x_max : float - New maximum X value. - y_min : float - New minimum Y value. - y_max : float - New maximum Y value (must be > y_min for meaningful interpolation). - """ self.x_max = float(x_max) self.y_min = float(y_min) self.y_max = float(y_max) - # --------------------------------------------------------------------- # - # Control point management - # --------------------------------------------------------------------- # def get_cv_points(self) -> ControlPoints: - """ - Return the current list of control points. - - Returns - ------- - list[list[float]] - The internal list (NOT a copy). Caller should treat it as read-only - unless deliberately mutating. Widget code historically mutates it. - """ - return self._cv_points + return self._cv_points # Internal list (mutated by widget code) def build_curve(self) -> None: - """ - Sort control points by X coordinate (stable) to maintain correct - linear interpolation behavior. - """ self._cv_points.sort(key=lambda v: v[0]) def set_cv_value(self, index: int, x_value: float, y_value: float) -> None: - """ - Update the control point at the specified index. - - Parameters - ---------- - index : int - Index of the control point to update. - x_value : float - New X coordinate. - y_value : float - New Y coordinate. - """ self._cv_points[index] = [float(x_value), float(y_value)] def add_cv_point(self, x_value: float, y_value: float) -> None: - """ - Add a new control point and re-sort the curve. - - Parameters - ---------- - x_value : float - X coordinate of the new point. - y_value : float - Y coordinate of the new point. - """ self._cv_points.append([float(x_value), float(y_value)]) self.build_curve() - def __repr__(self) -> str: # pragma: no cover - convenience - return f"Curve(x_max={self.x_max}, y_min={self.y_min}, y_max={self.y_max}, points={self._cv_points})" - __all__ = ["Curve", "ControlPoint", "ControlPoints"] diff --git a/tools/gui/curve-editor/widgets/curve_widget.py b/tools/gui/curve-editor/widgets/curve_widget.py index 20257f4..e2ab8a8 100644 --- a/tools/gui/curve-editor/widgets/curve_widget.py +++ b/tools/gui/curve-editor/widgets/curve_widget.py @@ -9,9 +9,6 @@ from models.curve import Curve -# Set to True to enable debug logging for state saves -DEBUG_UNDO = False - class Snapshot(TypedDict): cv_points: list[list[float]] @@ -21,7 +18,7 @@ class Snapshot(TypedDict): class CurveWidget(QtWidgets.QWidget): - """Resizable widget displaying an editable curve with undo/redo support.""" + # Editable curve widget with undo/redo support (short description). def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: super().__init__(parent) @@ -60,7 +57,7 @@ def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: # Undo/Redo infrastructure # ------------------------------------------------------------------ def _capture_state(self) -> Snapshot: - """Return a deep copy snapshot of current logical state.""" + # Deep copy snapshot of current logical state return Snapshot( cv_points=copy.deepcopy(self.curve._cv_points), x_max=self.curve.x_max, @@ -68,40 +65,34 @@ def _capture_state(self) -> Snapshot: y_max=self.curve.y_max, ) - def _states_equal(self, a: Snapshot, b: Snapshot) -> bool: - return a["cv_points"] == b["cv_points"] and a["x_max"] == b["x_max"] and a["y_min"] == b["y_min"] and a["y_max"] == b["y_max"] - def _push_state(self, action_name: str = "Unknown") -> None: - """Record current state AFTER a modification.""" + # Record current state AFTER a modification (simplified, no debug) current = self._capture_state() - # Truncate redo branch if we are not at the end if self._history_index < len(self._history) - 1: self._history = self._history[: self._history_index + 1] - # Avoid duplicates - if self._history and self._states_equal(current, self._history[-1]): - if DEBUG_UNDO: - print(f"UNDO DEBUG: skip duplicate after '{action_name}'") - return + if self._history: + last = self._history[-1] + if ( + current["cv_points"] == last["cv_points"] + and current["x_max"] == last["x_max"] + and current["y_min"] == last["y_min"] + and current["y_max"] == last["y_max"] + ): + return self._history.append(current) self._history_index = len(self._history) - 1 - if DEBUG_UNDO: - print(f"UNDO DEBUG: push '{action_name}' -> index {self._history_index} / {len(self._history)}") def _undo(self) -> None: if self._history_index > 0: self._history_index -= 1 self._restore_state(self._history[self._history_index]) self._update_window_title() - if DEBUG_UNDO: - print(f"UNDO DEBUG: undo -> index {self._history_index} / {len(self._history)}") def _redo(self) -> None: if self._history_index < len(self._history) - 1: self._history_index += 1 self._restore_state(self._history[self._history_index]) self._update_window_title() - if DEBUG_UNDO: - print(f"UNDO DEBUG: redo -> index {self._history_index} / {len(self._history)}") def _restore_state(self, state: Snapshot) -> None: self._restoring_state = True