Refactor/interactive plotting #1

Merged
mikkeli merged 3 commits from refactor/interactive-plotting into main 2026-06-13 16:17:21 +00:00
7 changed files with 365 additions and 83 deletions
Showing only changes of commit a45bd4ba9b - Show all commits
+18 -7
View File
@@ -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
+69 -49
View File
@@ -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:
+50 -7
View File
@@ -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)
+18 -20
View File
@@ -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(),
)
+56
View File
@@ -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()
+1
View File
@@ -33,6 +33,7 @@ py-modules = [
"font_manager",
"font_control_widget",
"plot_control_widget",
"ref_line_widget",
"logger_setup",
"setup_fonts",
]
+153
View File
@@ -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()