From a45bd4ba9bb0ca7b0f1ce4d4494285e746b2c0d6 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Sun, 14 Jun 2026 00:51:33 +0900 Subject: [PATCH] Reference-line manager, relative time axis, axis-aware ref-line UX Reference lines: - Side-panel cluster (ref_line_widget.py): list of lines with Add/Edit/Remove/ Clear and a properties dialog (value, colour, line style, tag) - Lines own their state as RefLineProps (held by MainWindow), drawn with a triangle drag-handle; dragging writes the value back and refreshes the list - Replaces the old centre-only add-and-drag-the-whole-line approach Comparison: - Time-axis mode selector: Absolute (seconds) vs Relative (% of each track's own length), so a long track and a short one line up by song position. Pure view transform (plotspec.apply_x_mode), no recompute - Overlaid different-length tracks now autorange their x union in absolute mode instead of clipping to the first track's span Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 25 ++++-- audio_visualization_widget.py | 118 +++++++++++++++----------- main.py | 57 +++++++++++-- plot_control_widget.py | 38 ++++----- plotspec.py | 56 +++++++++++++ pyproject.toml | 1 + ref_line_widget.py | 153 ++++++++++++++++++++++++++++++++++ 7 files changed, 365 insertions(+), 83 deletions(-) create mode 100644 ref_line_widget.py diff --git a/CLAUDE.md b/CLAUDE.md index 761529a..9092a7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,9 +57,18 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th #### `plot_control_widget.py` - Metric selector dropdown driven by the `metrics.METRICS` registry -- Log-frequency toggle (view-state; recompute-free, currently honoured by the - spectrogram) and the `Refresh Plot` button -- Compare/overlay is *not* here — it is driven by the file-list checkboxes +- Log-frequency toggle and a time-axis mode selector — Absolute (seconds) vs + Relative (% of each track's own length) — both view-state, recompute-free +- `Refresh Plot` button. Compare/overlay membership is the file-list checkboxes; + reference lines have their own cluster + +#### `ref_line_widget.py` +- `RefLineControlWidget`: side-panel list of custom reference lines with + Add / Edit… / Remove / Clear; a pure view over the `RefLineProps` list the + main window owns, emitting intents +- `RefLineDialog`: edits one line's value, colour, line style, and tag +- The plot draws each line with a triangle drag-handle; dragging writes the new + value back into the shared `RefLineProps` and refreshes the list #### `metrics.py` - Pluggable `Metric` ABC: `compute(audio_file) -> data` (heavy, worker thread, @@ -114,11 +123,13 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th (pyqtgraph ViewBox); log/linear frequency toggle. Scroll zooms both axes; **Ctrl+scroll** zooms time only, **Shift+scroll** zooms the value axis only (`_AxisZoomViewBox`); scrolling over an axis also zooms just that axis -- **Custom reference lines**: "Add ref line" drops a draggable horizontal marker - on any metric (e.g. an eyeballed effective average); lines persist across - redraws/overlay changes and are cleared automatically when the metric changes +- **Time-axis mode**: Absolute (seconds) or Relative (% of each track's own + length), so tracks of very different durations line up by song position +- **Custom reference lines**: side-panel list (Add/Edit/Remove/Clear) of draggable + horizontal markers with value/colour/style/tag; dragged via a triangle handle. + Persist across redraws/overlay changes; cleared when the metric changes - **Font control**: Unified font selector with size control -- **Plot control**: Metric selector + log-frequency toggle + ref-line add/clear +- **Plot control**: Metric selector + log-frequency toggle + time-axis mode + refresh-plot button - **Analysis display**: Real-time visualization with metadata panels - **Modular architecture**: Self-contained widgets for easy layout management diff --git a/audio_visualization_widget.py b/audio_visualization_widget.py index 0021e83..6148b37 100644 --- a/audio_visualization_widget.py +++ b/audio_visualization_widget.py @@ -11,8 +11,9 @@ Interaction notes: - Plain scroll zooms both axes; Ctrl+scroll zooms time only; Shift+scroll zooms the value axis only (see `_AxisZoomViewBox`). Scrolling directly over an axis also zooms just that axis (pyqtgraph default). -- User reference lines (`add_user_line`) are draggable, survive redraws within a - metric, and are cleared by the GUI when the metric changes (units change). +- Reference lines (`set_reference_lines`) are draggable via a triangle handle, + survive redraws, and write their position back into the GUI-owned RefLineProps; + the GUI clears them when the metric changes (units change). Why the spectrogram is special: pyqtgraph's ImageItem is affine-only, so it does not follow a log-scaled axis. Log frequency is therefore realised by resampling @@ -24,9 +25,9 @@ import numpy as np import pyqtgraph as pg from scipy.interpolate import interp1d from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, pyqtSignal -from plotspec import PlotSpec, ViewState, DEFAULT_VIEW +from plotspec import PlotSpec, ViewState, DEFAULT_VIEW, RefLineProps # White canvas / black ink to match the previous matplotlib aesthetic. pg.setConfigOption("background", "w") @@ -45,9 +46,6 @@ _PEN_STYLE = {"solid": Qt.SolidLine, "dash": Qt.DashLine, "dot": Qt.DotLine} # "Nice" frequencies to label on a log frequency axis, in Hz. _LOG_FREQ_TICKS = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000] -# Colour for user-added reference lines (neutral so it reads on any metric). -_USER_LINE_COLOR = "#444444" - def dataset_color(index: int) -> str: """Stable dataset colour for a given index (e.g. a file's row in the list).""" @@ -83,9 +81,40 @@ class _AxisZoomViewBox(pg.ViewBox): super().wheelEvent(ev, axis=axis) +class _RefLine(pg.InfiniteLine): + """A draggable horizontal reference line bound to a RefLineProps. + + Carries a triangle grab-handle at the left edge and writes its position back + into the props on drag, notifying the widget so the side-panel list refreshes. + """ + + def __init__(self, index: int, props: RefLineProps, on_moved): + pen = pg.mkPen(props.color, width=1.4, + style=_PEN_STYLE.get(props.style, Qt.DashLine)) + super().__init__( + pos=props.value, angle=0, movable=True, pen=pen, + label=props.label or "{value:.2f}", + labelOpts={"position": 0.06, "color": props.color, + "fill": (255, 255, 255, 180)}, + ) + self._index = index + self._props = props + self._on_moved = on_moved + self.addMarker("|>", position=0.0, size=12) # triangle handle at the start + self.sigPositionChangeFinished.connect(self._commit) + + def _commit(self): + self._props.value = float(self.value()) + self._on_moved(self._index) + + class AudioVisualizationWidget(QWidget): """Persistent interactive plot. Call `show_specs` to (re)draw.""" + # Emitted (with the line's index) when a reference line is dragged, so the + # side-panel list can refresh its displayed value. + referenceLineMoved = pyqtSignal(int) + def __init__(self, parent=None): super().__init__(parent) layout = QVBoxLayout(self) @@ -101,10 +130,10 @@ class AudioVisualizationWidget(QWidget): layout.addWidget(self.status_label) self._colorbar = None - # User reference lines persist by value across redraws; the items are rebuilt - # each render. Cleared by the GUI on metric change (units change). - self._user_line_values: list[float] = [] - self._user_lines: list[pg.InfiniteLine] = [] + # Reference lines are owned by the GUI controller (RefLineProps objects) and + # passed in via set_reference_lines; the line items are rebuilt each render. + self._ref_props: list[RefLineProps] = [] + self._ref_lines: list[_RefLine] = [] self._show_empty() # ---- public API --------------------------------------------------------- @@ -131,8 +160,8 @@ class AudioVisualizationWidget(QWidget): self._render_heatmap(spec, view) if len(specs) > 1: self.set_status(f"{spec.title or label}: spectrogram shows one track at a time") - self._apply_axes(base_axes, log_y_image_handled=True) - self._draw_user_lines() + self._apply_axes(base_axes, single=True, log_y_image_handled=True) + self._draw_ref_lines() return single = len(specs) == 1 @@ -150,21 +179,18 @@ class AudioVisualizationWidget(QWidget): for note in spec.annotations: self._legend_note(prefix + note) - self._apply_axes(base_axes) - self._draw_user_lines() + self._apply_axes(base_axes, single=single) + self._draw_ref_lines() - def add_user_line(self, value: float | None = None): - """Add a draggable horizontal reference line at `value` (default: view centre).""" - if value is None: - (_, _), (y0, y1) = self.plot.viewRange() - value = (y0 + y1) / 2.0 - self._user_line_values.append(float(value)) - self._draw_user_lines() + def set_reference_lines(self, props: list[RefLineProps]): + """Set the reference-line set (RefLineProps owned by the GUI) and redraw them.""" + self._ref_props = props + self._draw_ref_lines() - def clear_user_lines(self): - """Remove all user reference lines (called when the metric changes).""" - self._user_line_values.clear() - self._remove_user_line_items() + def current_view_center_y(self) -> float: + """Mid-point of the current y view — a sane default position for a new line.""" + (_, _), (y0, y1) = self.plot.viewRange() + return (y0 + y1) / 2.0 def set_status(self, message: str): self.status_label.setText(message) @@ -261,11 +287,16 @@ class AudioVisualizationWidget(QWidget): self._colorbar.setImageItem(img) self.glw.addItem(self._colorbar, row=0, col=1) - def _apply_axes(self, axes, log_y_image_handled: bool = False): + def _apply_axes(self, axes, single: bool, log_y_image_handled: bool = False): self.plot.setLabel("bottom", axes.x_label) self.plot.setLabel("left", axes.y_label) - if axes.x_range: + # Frame x exactly only for a single dataset; overlaid tracks of different + # lengths (absolute mode) should autorange to their union rather than clip to + # the first one's span. In relative mode every spec is 0-100, so either works. + if axes.x_range and single: self.plot.setXRange(*axes.x_range, padding=0) + elif not single: + self.plot.enableAutoRange(axis=pg.ViewBox.XAxis) if axes.y_range and not log_y_image_handled: self.plot.setYRange(*axes.y_range, padding=0) if not log_y_image_handled: @@ -274,29 +305,18 @@ class AudioVisualizationWidget(QWidget): # ---- user reference lines ----------------------------------------------- - def _draw_user_lines(self): - """(Re)create draggable lines from the stored values, preserving positions.""" - self._remove_user_line_items() - for idx in range(len(self._user_line_values)): - line = pg.InfiniteLine( - pos=self._user_line_values[idx], angle=0, movable=True, - pen=pg.mkPen(_USER_LINE_COLOR, width=1.2, style=Qt.DashLine), - label="{value:.2f}", - labelOpts={"position": 0.05, "color": _USER_LINE_COLOR, - "fill": (255, 255, 255, 180)}, - ) - line.sigPositionChanged.connect(lambda ln, i=idx: self._on_user_line_moved(i, ln)) + def _draw_ref_lines(self): + """(Re)create draggable lines from the current RefLineProps set.""" + self._remove_ref_line_items() + for idx, props in enumerate(self._ref_props): + line = _RefLine(idx, props, on_moved=self.referenceLineMoved.emit) self.plot.addItem(line) - self._user_lines.append(line) + self._ref_lines.append(line) - def _on_user_line_moved(self, index: int, line: pg.InfiniteLine): - if 0 <= index < len(self._user_line_values): - self._user_line_values[index] = float(line.value()) - - def _remove_user_line_items(self): - for line in self._user_lines: + def _remove_ref_line_items(self): + for line in self._ref_lines: self.plot.removeItem(line) - self._user_lines.clear() + self._ref_lines.clear() # ---- legend / lifecycle ------------------------------------------------- @@ -307,7 +327,7 @@ class AudioVisualizationWidget(QWidget): self.legend.addItem(pg.PlotDataItem(pen=None), text) def _reset_plot(self): - self._remove_user_line_items() # cleared from scene; values persist for redraw + self._remove_ref_line_items() # cleared from scene; props persist for redraw self.plot.clear() if self._colorbar is not None: try: diff --git a/main.py b/main.py index 70e5f9f..6582e5b 100644 --- a/main.py +++ b/main.py @@ -12,7 +12,9 @@ from logger_setup import setup_logging, parse_log_args from font_manager import initialize_fonts, get_font_manager from font_control_widget import FontControlWidget from plot_control_widget import PlotControlWidget +from ref_line_widget import RefLineControlWidget, RefLineDialog from metrics import METRICS +from plotspec import RefLineProps, apply_x_mode class MainWindow(QMainWindow): @@ -24,6 +26,8 @@ class MainWindow(QMainWindow): self.analysis_manager = AnalysisResultsManager() # Guards programmatic list mutations from triggering re-render storms. self._suppress_list_signals = False + # Custom reference lines, owned here and pushed to the plot each render. + self.ref_lines: list[RefLineProps] = [] self.initUI() self.connect_signals() @@ -74,10 +78,16 @@ class MainWindow(QMainWindow): self.plot_control.metricChanged.connect(self.on_metric_changed) self.plot_control.viewChanged.connect(self.on_view_changed) self.plot_control.plotRefreshRequested.connect(self.on_plot_refresh_requested) - self.plot_control.addReferenceLineRequested.connect(self.on_add_reference_line) - self.plot_control.clearReferenceLinesRequested.connect(self.on_clear_reference_lines) layout.addWidget(self.plot_control) + # Reference-line management cluster (list + add/edit/remove/clear). + self.ref_line_control = RefLineControlWidget() + self.ref_line_control.addRequested.connect(self.on_add_reference_line) + self.ref_line_control.editRequested.connect(self.on_edit_reference_line) + self.ref_line_control.removeRequested.connect(self.on_remove_reference_line) + self.ref_line_control.clearRequested.connect(self.on_clear_reference_lines) + layout.addWidget(self.ref_line_control) + # File list. Each item carries a checkbox: the checked set is the overlay # set drawn on the graph; the highlighted item drives the metadata panel. self.file_list_label = QLabel("Analyzed Files (tick to overlay):") @@ -116,6 +126,7 @@ class MainWindow(QMainWindow): self.analysis_manager.metricComputeStarted.connect(self.on_metric_compute_started) self.analysis_manager.metricReady.connect(self.on_metric_ready) self.analysis_manager.metricComputeError.connect(self.on_metric_compute_error) + self.visualization_widget.referenceLineMoved.connect(self.on_reference_line_moved) def dragEnterEvent(self, event): """Handle drag enter event for file drops.""" @@ -223,16 +234,47 @@ class MainWindow(QMainWindow): self.logger.info(f"Metric changed via GUI: {metric_id}") # The value axis units change with the metric, so custom reference lines # placed against the old metric no longer mean anything — drop them. - self.visualization_widget.clear_user_lines() + self.ref_lines.clear() + self._sync_ref_lines() self._refresh_view() def on_add_reference_line(self): - """Drop a draggable reference line on the current plot.""" - self.visualization_widget.add_user_line() + """Add a reference line at the current view centre, then edit it.""" + value = self.visualization_widget.current_view_center_y() + props = RefLineProps(value=round(value, 2)) + self.ref_lines.append(props) + self._sync_ref_lines() + # Open the editor immediately so colour/tag/value can be set right away. + self.on_edit_reference_line(len(self.ref_lines) - 1) + + def on_edit_reference_line(self, index: int): + """Open the properties dialog for a reference line.""" + if not (0 <= index < len(self.ref_lines)): + return + dialog = RefLineDialog(self, self.ref_lines[index]) + if dialog.exec_(): + self.ref_lines[index] = dialog.result_props() + self._sync_ref_lines() + + def on_remove_reference_line(self, index: int): + """Delete a reference line.""" + if 0 <= index < len(self.ref_lines): + del self.ref_lines[index] + self._sync_ref_lines() def on_clear_reference_lines(self): """Remove all custom reference lines.""" - self.visualization_widget.clear_user_lines() + self.ref_lines.clear() + self._sync_ref_lines() + + def on_reference_line_moved(self, index: int): + """A line was dragged on the plot — its value is already updated; refresh list.""" + self.ref_line_control.set_lines(self.ref_lines) + + def _sync_ref_lines(self): + """Push the current reference-line set to both the list view and the plot.""" + self.ref_line_control.set_lines(self.ref_lines) + self.visualization_widget.set_reference_lines(self.ref_lines) def on_view_changed(self): """Called when a view-scale toggle (lin/log) changes. Recompute-free redraw.""" @@ -320,7 +362,8 @@ class MainWindow(QMainWindow): # Colour is keyed to the file's row, not its position in the overlay # subset, so a song keeps its colour as others are ticked/unticked. color = dataset_color(self._row_index(path)) - specs.append((label, metric.build_spec(data, view), color)) + spec = apply_x_mode(metric.build_spec(data, view), view.x_mode) + specs.append((label, spec, color)) if specs: self.visualization_widget.show_specs(specs, view) diff --git a/plot_control_widget.py b/plot_control_widget.py index 9b61f59..2c86c50 100644 --- a/plot_control_widget.py +++ b/plot_control_widget.py @@ -13,21 +13,20 @@ from PyQt5.QtWidgets import ( from PyQt5.QtCore import pyqtSignal from metrics import METRICS, DEFAULT_METRIC_ID -from plotspec import ViewState +from plotspec import ViewState, X_ABSOLUTE, X_RELATIVE class PlotControlWidget(QWidget): - """Metric selector, view-scale toggle, and manual plot refresh. + """Metric selector, view-scale/x-mode toggles, and manual plot refresh. - Overlay/compare is driven by the file-list checkboxes, not here — this cluster - only governs *what* metric and *how* its axes are scaled. + Overlay/compare membership is driven by the file-list checkboxes and reference + lines by their own cluster; this one governs *what* metric and *how* its axes + are scaled (lin/log frequency) and laid out (absolute vs relative time). """ metricChanged = pyqtSignal(str) # metric_id - viewChanged = pyqtSignal() # view-state (scale) changed + viewChanged = pyqtSignal() # view-state (scale / x-mode) changed plotRefreshRequested = pyqtSignal() - addReferenceLineRequested = pyqtSignal() - clearReferenceLinesRequested = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) @@ -58,18 +57,14 @@ class PlotControlWidget(QWidget): self.log_freq_check.toggled.connect(lambda _: self.viewChanged.emit()) group_layout.addWidget(self.log_freq_check) - # Custom reference lines: drop a draggable horizontal marker (e.g. an - # eyeballed effective average) onto whatever metric is showing. - ref_row = QHBoxLayout() - self.add_ref_button = QPushButton("Add ref line") - self.add_ref_button.setToolTip("Drop a draggable horizontal reference line") - self.add_ref_button.clicked.connect(self.addReferenceLineRequested.emit) - ref_row.addWidget(self.add_ref_button) - self.clear_ref_button = QPushButton("Clear") - self.clear_ref_button.setToolTip("Remove all custom reference lines") - self.clear_ref_button.clicked.connect(self.clearReferenceLinesRequested.emit) - ref_row.addWidget(self.clear_ref_button) - group_layout.addLayout(ref_row) + # Time axis: absolute seconds vs relative % of each track's own length, so + # tracks of very different durations line up by song position when overlaid. + group_layout.addWidget(QLabel("Time axis:")) + self.x_mode_combo = QComboBox() + self.x_mode_combo.addItem("Absolute (seconds)", X_ABSOLUTE) + self.x_mode_combo.addItem("Relative (%)", X_RELATIVE) + self.x_mode_combo.currentIndexChanged.connect(lambda _: self.viewChanged.emit()) + group_layout.addWidget(self.x_mode_combo) button_row = QHBoxLayout() self.refresh_button = QPushButton("Refresh Plot") @@ -90,4 +85,7 @@ class PlotControlWidget(QWidget): return self.metric_combo.currentData() or DEFAULT_METRIC_ID def current_view_state(self) -> ViewState: - return ViewState(y_log=self.log_freq_check.isChecked()) + return ViewState( + y_log=self.log_freq_check.isChecked(), + x_mode=self.x_mode_combo.currentData(), + ) diff --git a/plotspec.py b/plotspec.py index f849647..b66ca0a 100644 --- a/plotspec.py +++ b/plotspec.py @@ -102,6 +102,25 @@ class PlotSpec: return self.heatmap is not None +@dataclass +class RefLineProps: + """A user-defined horizontal reference line. + + Owned by the GUI controller and passed to the renderer, which draws it as a + draggable line and writes `value` back on drag. Persists across redraws; the + GUI clears the set when the metric changes (the value axis units change). + """ + value: float + color: str = "#444444" + style: str = "dash" # 'solid' | 'dash' | 'dot' + label: str = "" # tag shown on the line; falls back to the value + + +# X-axis modes for comparison. +X_ABSOLUTE = "absolute" # time in seconds (native) +X_RELATIVE = "relative" # 0-100% of each track's own length + + @dataclass class ViewState: """User-controlled, recompute-free view options. @@ -112,6 +131,7 @@ class ViewState: """ y_log: Optional[bool] = None x_log: Optional[bool] = None + x_mode: str = X_ABSOLUTE def resolve_y_log(self, default: bool) -> bool: return self.y_log if self.y_log is not None else default @@ -120,5 +140,41 @@ class ViewState: return self.x_log if self.x_log is not None else default +def apply_x_mode(spec: PlotSpec, mode: str) -> PlotSpec: + """Rewrite a spec's x-axis to relative position (0-100%) in place, if asked. + + Each dataset is normalised to *its own* span, so tracks of different lengths + line up by song position — the point of relative mode. A pure view transform: + it reassigns the x arrays (cached data is left untouched) and relabels the + axis. No-op for absolute mode. + """ + if mode != X_RELATIVE: + return spec + + xs = [c.x for c in spec.curves] + [b.x for b in spec.bands] + if spec.heatmap is not None: + xs.append(spec.heatmap.x) + xs = [x for x in xs if len(x)] + if not xs: + return spec + + lo = min(float(x[0]) for x in xs) + hi = max(float(x[-1]) for x in xs) + span = (hi - lo) or 1.0 + + def rel(x): + return (x - lo) / span * 100.0 + + for c in spec.curves: + c.x = rel(c.x) + for b in spec.bands: + b.x = rel(b.x) + if spec.heatmap is not None: + spec.heatmap.x = rel(spec.heatmap.x) + spec.axes.x_label = "Position (%)" + spec.axes.x_range = (0.0, 100.0) + return spec + + # A neutral default reused wherever a caller hasn't supplied view options. DEFAULT_VIEW = ViewState() diff --git a/pyproject.toml b/pyproject.toml index 7126fe0..ebb8690 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ py-modules = [ "font_manager", "font_control_widget", "plot_control_widget", + "ref_line_widget", "logger_setup", "setup_fonts", ] diff --git a/ref_line_widget.py b/ref_line_widget.py new file mode 100644 index 0000000..d9dc5a9 --- /dev/null +++ b/ref_line_widget.py @@ -0,0 +1,153 @@ +""" +Reference-line management: a side-panel list of custom horizontal markers plus a +properties dialog. + +`RefLineControlWidget` is a pure view over a list of `RefLineProps` owned by the +main window: it renders the list and emits intents (add / edit / remove / clear). +`RefLineDialog` edits one line's value, colour, line style, and tag. +""" + +import logging + +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QListWidget, QPushButton, + QDialog, QFormLayout, QDoubleSpinBox, QComboBox, QLineEdit, QColorDialog, + QDialogButtonBox, +) +from PyQt5.QtGui import QColor +from PyQt5.QtCore import pyqtSignal + +from plotspec import RefLineProps + + +_STYLE_CHOICES = [("Solid", "solid"), ("Dashed", "dash"), ("Dotted", "dot")] + + +class RefLineDialog(QDialog): + """Edit one reference line's properties. Read the result via `result_props`.""" + + def __init__(self, parent, props: RefLineProps): + super().__init__(parent) + self.setWindowTitle("Reference line") + self._color = props.color + + form = QFormLayout(self) + + self.value_spin = QDoubleSpinBox() + self.value_spin.setRange(-1e6, 1e6) + self.value_spin.setDecimals(2) + self.value_spin.setValue(props.value) + form.addRow("Value:", self.value_spin) + + self.color_button = QPushButton() + self.color_button.clicked.connect(self._pick_color) + self._refresh_color_button() + form.addRow("Colour:", self.color_button) + + self.style_combo = QComboBox() + for label, key in _STYLE_CHOICES: + self.style_combo.addItem(label, key) + idx = self.style_combo.findData(props.style) + if idx >= 0: + self.style_combo.setCurrentIndex(idx) + form.addRow("Line style:", self.style_combo) + + self.label_edit = QLineEdit(props.label) + self.label_edit.setPlaceholderText("(optional tag)") + form.addRow("Tag:", self.label_edit) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + form.addRow(buttons) + + def _pick_color(self): + chosen = QColorDialog.getColor(QColor(self._color), self, "Reference line colour") + if chosen.isValid(): + self._color = chosen.name() + self._refresh_color_button() + + def _refresh_color_button(self): + self.color_button.setText(self._color) + # Show the colour as the button's background for a quick read. + self.color_button.setStyleSheet(f"background-color: {self._color};") + + def result_props(self) -> RefLineProps: + return RefLineProps( + value=float(self.value_spin.value()), + color=self._color, + style=self.style_combo.currentData(), + label=self.label_edit.text().strip(), + ) + + +class RefLineControlWidget(QWidget): + """List of reference lines with Add / Edit / Remove / Clear controls.""" + + addRequested = pyqtSignal() + editRequested = pyqtSignal(int) + removeRequested = pyqtSignal(int) + clearRequested = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.logger = logging.getLogger(__name__) + self.initUI() + + def initUI(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + + group_box = QGroupBox("Reference lines") + group_layout = QVBoxLayout(group_box) + + self.line_list = QListWidget() + self.line_list.setMaximumHeight(110) + self.line_list.itemDoubleClicked.connect(self._on_double_click) + group_layout.addWidget(self.line_list) + + row = QHBoxLayout() + self.add_button = QPushButton("Add") + self.add_button.clicked.connect(self.addRequested.emit) + row.addWidget(self.add_button) + + self.edit_button = QPushButton("Edit…") + self.edit_button.clicked.connect(self._emit_edit) + row.addWidget(self.edit_button) + + self.remove_button = QPushButton("Remove") + self.remove_button.clicked.connect(self._emit_remove) + row.addWidget(self.remove_button) + + self.clear_button = QPushButton("Clear") + self.clear_button.clicked.connect(self.clearRequested.emit) + row.addWidget(self.clear_button) + + group_layout.addLayout(row) + layout.addWidget(group_box) + + def set_lines(self, lines: list[RefLineProps]): + """Repopulate the list display from the current props (preserving selection).""" + current = self.line_list.currentRow() + self.line_list.clear() + for p in lines: + tag = f" {p.label}" if p.label else "" + self.line_list.addItem(f"{p.value:.2f}{tag}") + if 0 <= current < self.line_list.count(): + self.line_list.setCurrentRow(current) + + def _selected_row(self) -> int: + return self.line_list.currentRow() + + def _emit_edit(self): + row = self._selected_row() + if row >= 0: + self.editRequested.emit(row) + + def _emit_remove(self): + row = self._selected_row() + if row >= 0: + self.removeRequested.emit(row) + + def _on_double_click(self, _item): + self._emit_edit()