← Back to PlotSettingsPanel documentation
PlotSettingsPanel - Source Code¶
File: Distributed_Design_Optimizer/postprocess/widgets/PlotSettingsPanel.py
# Copyright (C) The DistributedDesignOptimizer Contributors
# Licensed under the GNU General Public License v3.0. See LICENSE file for details.
#
# Additional Request:
# We kindly request that any modifications or changes to the source code be
# shared with us by submitting them as pull requests to our GitHub repository.
# This request is not legally binding but is made in the spirit of
# collaboration and open-source development.
"""Plot settings panel with collapsible sections for configuring chart appearance."""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout,
QCheckBox,
QComboBox, QSpinBox, QDoubleSpinBox, QLineEdit,
QPushButton, QScrollArea, QGridLayout, QLabel, QFrame,
QSizePolicy, QMessageBox,
)
from PySide6.QtCore import Signal, Qt, QTimer, QLocale
from PySide6.QtGui import QColor, QPainter, QPen, QPainterPath
import math
from ..models.PlotConfig import PlotConfig, FontConfig, LineConfig, ReferenceLine
from ..styles.Theme import get_accent_color, PRIMARY, PRIMARY_LIGHT, PRIMARY_DARK, SECONDARY
from .CollapsibleSection import CollapsibleSection as _CollapsibleSection
from .ToggleSwitch import ToggleSwitch
# ======================================================================
# Plot sketch background widget
# ======================================================================
class _PlotSketchWidget(QWidget):
"""Greyed-out exemplary x-y line plot as visual guide."""
def __init__(self, parent: QWidget | None = None) -> None:
"""Initialize the sketch widget.
Args:
parent: Optional parent widget.
"""
super().__init__(parent)
self.setMinimumSize(200, 160)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
def paintEvent(self, event) -> None: # noqa: ARG002
"""Draw the exemplary convergence plot sketch.
Args:
event: The paint event triggered by Qt.
"""
p = QPainter()
if not p.begin(self):
return
p.setRenderHint(QPainter.RenderHint.Antialiasing)
w, h = self.width(), self.height()
m = 14
al, ar, at, ab = m, w - m, m, h - m
pw, ph = ar - al, ab - at
# Axes
axis_c = QColor(195, 195, 195)
p.setPen(QPen(axis_c, 1.5))
p.drawLine(al, at, al, ab)
p.drawLine(al, ab, ar, ab)
# Ticks & faint grid
grid_pen = QPen(QColor(230, 230, 230), 1, Qt.PenStyle.DotLine)
tick_pen = QPen(axis_c, 1)
for i in range(1, 6):
x = int(al + i * pw / 5)
p.setPen(tick_pen); p.drawLine(x, ab, x, ab + 3)
p.setPen(grid_pen); p.drawLine(x, at, x, ab)
for i in range(1, 5):
y = int(ab - i * ph / 4)
p.setPen(tick_pen); p.drawLine(al - 3, y, al, y)
p.setPen(grid_pen); p.drawLine(al, y, ar, y)
# Sample convergence curve
p.setPen(QPen(QColor(170, 200, 220), 2.5))
path = QPainterPath()
n = 60
for i in range(n):
t = i / (n - 1)
val = 0.75 * math.exp(-3 * t) + 0.15 + 0.06 * math.sin(10 * t) * math.exp(-2 * t)
xi = al + t * pw
yi = ab - val * ph
if i == 0:
path.moveTo(xi, yi)
else:
path.lineTo(xi, yi)
p.drawPath(path)
p.end()
# ======================================================================
# Main settings panel
# ======================================================================
class PlotSettingsPanel(QWidget):
"""Single scrollable panel with collapsible sections for all plot settings.
All changes auto-apply (P13) — no manual Apply button needed.
Topology controls are NOT included here (P2 — they live in TopologyPanel).
"""
settings_changed = Signal()
customization_reset = Signal()
# Debounce timer interval (ms) for auto-apply
_DEBOUNCE_MS = 250
# Common fixed width for text input fields
_INPUT_WIDTH = 150
@staticmethod
def _make_combo(items: list[str] | None = None) -> QComboBox:
"""Create a QComboBox that auto-sizes to its widest content."""
combo = QComboBox()
combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
combo.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
if items:
combo.addItems(items)
return combo
def __init__(self, parent: QWidget | None = None) -> None:
"""Initialize the settings panel and build the UI.
Args:
parent: Optional parent widget.
"""
super().__init__(parent)
self._ref_line_widgets: list[dict] = []
self._legend_entries: dict[str, QLineEdit] = {}
self._per_line_widgets: dict[str, dict] = {}
self._user_collapsed_per_line = False
# Debounce timer for auto-apply
self._debounce_timer = QTimer(self)
self._debounce_timer.setSingleShot(True)
self._debounce_timer.setInterval(self._DEBOUNCE_MS)
self._debounce_timer.timeout.connect(self._emit_settings)
self._setup_ui()
def _setup_ui(self) -> None:
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll_content = QWidget()
self._main_layout = QVBoxLayout(scroll_content)
self._main_layout.setContentsMargins(4, 4, 4, 4)
self._main_layout.setSpacing(2)
self._build_plot_layout_section()
self._build_per_line_section()
# D5: Track user-initiated collapses
self._per_line_section.user_toggled.connect(self._on_per_line_user_toggled)
self._build_legend_section()
self._build_ref_lines_section()
self._build_reset_section()
self._main_layout.addStretch()
scroll.setWidget(scroll_content)
outer.addWidget(scroll)
# ------------------------------------------------------------------
# Section: Plot Layout (visual arrangement around exemplary plot)
# ------------------------------------------------------------------
def _build_plot_layout_section(self) -> None:
sec = _CollapsibleSection("Plot Layout", parent=self, initially_expanded=True)
grid = QGridLayout()
grid.setSpacing(8)
# Row 0, Col 1: Title input + font (above the plot)
title_col = QVBoxLayout()
title_row = QHBoxLayout()
title_lbl = QLabel("Title:")
title_lbl.setStyleSheet("font-weight: bold; font-size: 9pt;")
title_row.addWidget(title_lbl)
self._title_edit = QLineEdit()
self._title_edit.setMinimumWidth(self._INPUT_WIDTH)
self._title_edit.setPlaceholderText("(no default title)")
self._title_edit.textChanged.connect(self._schedule_change)
title_row.addWidget(self._title_edit)
title_col.addLayout(title_row)
self._title_font_size = self._build_font_size_row(title_col)
# Stacked titles container (shown when Y-axis = Stacked)
self._stacked_titles_container = QWidget()
self._stacked_titles_layout = QVBoxLayout(self._stacked_titles_container)
self._stacked_titles_layout.setContentsMargins(0, 4, 0, 0)
self._stacked_titles_container.hide()
self._stacked_title_edits: list[QLineEdit] = []
title_col.addWidget(self._stacked_titles_container)
grid.addLayout(title_col, 0, 1)
# Row 1, Col 0: Y-axis controls (along left / y-axis)
grid.addLayout(self._build_y_axis_column(), 1, 0)
# Row 1, Col 1: Exemplary plot sketch (background)
self._sketch = _PlotSketchWidget()
grid.addWidget(self._sketch, 1, 1)
# Row 2, Col 0: Display options (origin corner)
grid.addLayout(self._build_display_column(), 2, 0)
# Row 2, Col 1: X-axis controls (along bottom / x-axis)
grid.addLayout(self._build_x_axis_column(), 2, 1)
# Let the sketch area expand
grid.setColumnStretch(0, 0)
grid.setColumnStretch(1, 1)
grid.setColumnStretch(2, 0)
sec.content_layout.addLayout(grid)
self._main_layout.addWidget(sec)
# Initialize descriptions
self._on_x_axis_changed(0)
self._on_y_axis_changed(0)
def _build_y_axis_column(self) -> QVBoxLayout:
"""Left side (along y-axis): segmented control, log toggle, label."""
col = QVBoxLayout()
y_header = QLabel("Y-Axis")
y_header.setStyleSheet("font-weight: bold; font-size: 9pt; margin-bottom: 4px;")
col.addWidget(y_header)
self._y_axis_options = [
("shared", "Shared", "All series share one Y-axis"),
("independent", "Independent", "Each series gets its own Y-axis"),
("centered", "Centered", "Y-axis centered around zero"),
("stacked", "Stacked", "Series stacked in separate sub-plots"),
]
self._y_axis_keys = [k for k, _, _ in self._y_axis_options]
self._y_axis_tips = [t for _, _, t in self._y_axis_options]
y_labels = [lbl for _, lbl, _ in self._y_axis_options]
self._y_axis_segment = self._make_combo(y_labels)
self._y_axis_segment.setCurrentIndex(0)
self._y_axis_segment.setToolTip("Select Y-axis layout mode")
self._y_axis_segment.currentIndexChanged.connect(self._on_y_axis_changed)
col.addWidget(self._y_axis_segment)
self._y_desc_label = QLabel()
self._y_desc_label.setWordWrap(True)
self._y_desc_label.setStyleSheet("color: gray; font-size: 8pt;")
col.addWidget(self._y_desc_label)
col.addSpacing(6)
scale_row = QHBoxLayout()
scale_lbl = QLabel("Scale:")
scale_lbl.setStyleSheet("font-size: 8pt; color: black;")
scale_row.addWidget(scale_lbl)
self._scale_segment = self._make_combo(["Linear", "Log"])
self._scale_segment.setCurrentIndex(0)
self._scale_segment.setToolTip("Select y-axis scaling")
self._scale_segment.currentIndexChanged.connect(self._schedule_change)
scale_row.addWidget(self._scale_segment)
scale_row.addStretch()
col.addLayout(scale_row)
col.addSpacing(6)
# Unified y-label container: a single "Label" field for shared/centered
# modes, or one field per data series for independent/stacked modes.
# Rebuilt on Y-axis mode change and on data refresh; typed values are
# cached per mode so they persist across mode switches.
self._plottable_data: list[tuple[str, list]] = []
self._y_label_edits: list[QLineEdit] = []
self._y_label_cache: dict[str, list[str]] = {}
self._y_labels_current_bucket: str | None = None
self._y_labels_container = QWidget()
self._y_labels_layout = QVBoxLayout(self._y_labels_container)
self._y_labels_layout.setContentsMargins(0, 0, 0, 0)
col.addWidget(self._y_labels_container)
self._y_font_size = self._build_font_size_row(col)
col.addSpacing(4)
self._y_tick_label_size = self._build_size_row(col, "Tick label size:")
self._y_tick_marker_size = self._build_size_row(col, "Tick marker size:", default=8, min_val=2, max_val=20)
col.addStretch()
return col
def _build_display_column(self) -> QVBoxLayout:
"""Bottom-left (origin corner): display options."""
col = QVBoxLayout()
display_header = QLabel("Display")
display_header.setStyleSheet("font-weight: bold; font-size: 9pt; margin-bottom: 4px;")
col.addWidget(display_header)
self._show_grid_cb = ToggleSwitch("Grid")
self._show_grid_cb.setToolTip("Overlay grid lines on the plot background")
self._show_grid_cb.toggled.connect(self._schedule_change)
col.addWidget(self._show_grid_cb)
col.addStretch()
return col
# ------------------------------------------------------------------
# Section: Legend (toggle-activated)
# ------------------------------------------------------------------
def _build_legend_section(self) -> None:
# Collapsible section with toggle switch in the header
self._legend_collapsible = _CollapsibleSection(
"Legend", parent=self, initially_expanded=False
)
# Insert toggle switch into the collapsible header row
self._show_legend_cb = ToggleSwitch("")
self._show_legend_cb.setChecked(False)
self._show_legend_cb.setToolTip("Enable legend on the plot")
self._show_legend_cb.toggled.connect(self._on_legend_show_toggled)
# Add toggle to the right of the header button
header_layout = QHBoxLayout()
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.setSpacing(0)
header_layout.addWidget(self._legend_collapsible._toggle_btn)
header_layout.addStretch()
header_layout.addWidget(self._show_legend_cb)
header_widget = QWidget()
header_widget.setLayout(header_layout)
# Replace the default toggle button in the collapsible's layout
self._legend_collapsible.layout().removeWidget(self._legend_collapsible._toggle_btn)
self._legend_collapsible.layout().insertWidget(0, header_widget)
# Disable expand until toggle is switched on
self._legend_collapsible._toggle_btn.setEnabled(False)
legend_detail = self._legend_collapsible.content_layout
legend_detail.setSpacing(4)
self._legend_pos_options = [
"upper right", "upper left", "lower left", "lower right",
"upper center", "lower center", "center left",
"center right",
]
pos_lbl = QLabel("Position:")
pos_lbl.setStyleSheet("font-size: 8pt; color: black;")
legend_detail.addWidget(pos_lbl)
self._legend_pos_segment = self._make_combo(self._legend_pos_options)
self._legend_pos_segment.setCurrentIndex(0)
self._legend_pos_segment.setToolTip("Legend placement")
self._legend_pos_segment.currentIndexChanged.connect(self._schedule_change)
legend_detail.addWidget(self._legend_pos_segment)
self._legend_font_size = self._build_font_size_row(legend_detail)
# Custom names title + scroll area
custom_names_lbl = QLabel("Custom Names")
custom_names_lbl.setStyleSheet("font-weight: bold; font-size: 9pt; margin-top: 6px;")
legend_detail.addWidget(custom_names_lbl)
self._legend_scroll = QScrollArea()
self._legend_scroll.setWidgetResizable(True)
self._legend_scroll.setFrameShape(QFrame.Shape.NoFrame)
self._legend_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._legend_scroll_content = QWidget()
self._legend_entries_layout = QVBoxLayout(self._legend_scroll_content)
self._legend_scroll.setWidget(self._legend_scroll_content)
self._legend_scroll.setMaximumHeight(120)
legend_detail.addWidget(self._legend_scroll)
self._main_layout.addWidget(self._legend_collapsible)
def _build_x_axis_column(self) -> QVBoxLayout:
"""Bottom (along x-axis): segmented control, description, boundaries, label."""
col = QVBoxLayout()
x_header = QLabel("X-Axis")
x_header.setStyleSheet("font-weight: bold; font-size: 9pt; margin-bottom: 4px;")
col.addWidget(x_header)
self._x_axis_options = [
("queue_count", "Sequential",
"Simple 0,1,2,… index per data point"),
("combined_ticks", "Equispaced Outer/Inner Iterations",
"Aligns series by outer-loop iteration with inner-loop minor ticks"),
("equispaced_major", "Equispaced Outer Iterations",
"Equal spacing between outer-loop iterations"),
("cumulative_runtime", "Runtime",
"X-axis shows accumulated wall-clock time"),
("cumulative_evaluations", "Number of Evaluations",
"X-axis shows accumulated design variable evaluations"),
]
self._x_axis_keys = [k for k, _, _ in self._x_axis_options]
self._x_axis_tips = [t for _, _, t in self._x_axis_options]
x_labels = [lbl for _, lbl, _ in self._x_axis_options]
self._x_axis_segment = self._make_combo(x_labels)
self._x_axis_segment.setCurrentIndex(0)
self._x_axis_segment.setToolTip("Select how the x-axis is scaled")
self._x_axis_segment.currentIndexChanged.connect(self._on_x_axis_changed)
col.addWidget(self._x_axis_segment)
self._x_desc_label = QLabel()
self._x_desc_label.setWordWrap(True)
self._x_desc_label.setStyleSheet("color: gray; font-size: 8pt;")
col.addWidget(self._x_desc_label)
col.addSpacing(4)
x_lbl = QLabel("Label:")
x_lbl.setStyleSheet("font-size: 8pt; color: black;")
col.addWidget(x_lbl)
self._x_label_edit = QLineEdit()
self._x_label_edit.setMinimumWidth(self._INPUT_WIDTH)
self._x_label_edit.setPlaceholderText("Sequence Index")
self._x_label_edit.textChanged.connect(self._schedule_change)
col.addWidget(self._x_label_edit)
self._x_font_size = self._build_font_size_row(col)
col.addSpacing(4)
self._x_tick_label_size = self._build_size_row(col, "Tick label size:")
self._x_tick_marker_size = self._build_size_row(col, "Tick marker size:", default=8, min_val=2, max_val=20)
return col
# ------------------------------------------------------------------
# Helper: inline font size row
# ------------------------------------------------------------------
def _build_font_size_row(self, parent_layout: QVBoxLayout) -> QSpinBox:
"""Add a compact 'Font size: [spin]' row to a layout and return the spin box."""
row = QHBoxLayout()
lbl = QLabel("Font size:")
lbl.setStyleSheet("font-size: 8pt; color: black;")
row.addWidget(lbl)
spin = QSpinBox()
spin.setRange(6, 30)
spin.setValue(12)
spin.setFixedWidth(50)
spin.valueChanged.connect(self._schedule_change)
row.addWidget(spin)
row.addStretch()
parent_layout.addLayout(row)
return spin
def _build_size_row(self, parent_layout: QVBoxLayout, label: str,
default: int = 12, min_val: int = 6, max_val: int = 30) -> QSpinBox:
"""Add a compact labeled size spinbox row and return the spin box."""
row = QHBoxLayout()
lbl = QLabel(label)
lbl.setStyleSheet("font-size: 8pt; color: black;")
row.addWidget(lbl)
spin = QSpinBox()
spin.setRange(min_val, max_val)
spin.setValue(default)
spin.setFixedWidth(50)
spin.valueChanged.connect(self._schedule_change)
row.addWidget(spin)
row.addStretch()
parent_layout.addLayout(row)
return spin
def _read_font_config(self, size_spin: QSpinBox) -> FontConfig:
"""Read a FontConfig from a font size spinbox."""
return FontConfig(size=size_spin.value())
def _reset_font_size(self, size_spin: QSpinBox) -> None:
"""Reset a font size spinbox to default."""
size_spin.setValue(12)
# ------------------------------------------------------------------
# Section: Per-Line Settings
# ------------------------------------------------------------------
def _build_per_line_section(self) -> None:
self._per_line_section = _CollapsibleSection(
"Per-Line Settings", parent=self, initially_expanded=False
)
self._per_line_container = QVBoxLayout()
self._per_line_placeholder = QLabel("Plot data to configure per-line styles")
self._per_line_placeholder.setStyleSheet("color: gray;")
self._per_line_container.addWidget(self._per_line_placeholder)
self._per_line_section.content_layout.addLayout(self._per_line_container)
self._main_layout.addWidget(self._per_line_section)
# ------------------------------------------------------------------
# Section: Reference Lines (toggle-activated)
# ------------------------------------------------------------------
def _build_ref_lines_section(self) -> None:
# Collapsible section with toggle switch in the header
self._ref_lines_collapsible = _CollapsibleSection(
"Reference Lines", parent=self, initially_expanded=False
)
# Insert toggle switch into the collapsible header row
self._show_ref_lines_cb = ToggleSwitch("")
self._show_ref_lines_cb.setChecked(False)
self._show_ref_lines_cb.setToolTip("Enable horizontal reference lines on the plot")
self._show_ref_lines_cb.toggled.connect(self._on_ref_lines_toggled)
header_layout = QHBoxLayout()
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.setSpacing(0)
header_layout.addWidget(self._ref_lines_collapsible._toggle_btn)
header_layout.addStretch()
header_layout.addWidget(self._show_ref_lines_cb)
header_widget = QWidget()
header_widget.setLayout(header_layout)
self._ref_lines_collapsible.layout().removeWidget(self._ref_lines_collapsible._toggle_btn)
self._ref_lines_collapsible.layout().insertWidget(0, header_widget)
# Disable expand until toggle is switched on
self._ref_lines_collapsible._toggle_btn.setEnabled(False)
ref_detail = self._ref_lines_collapsible.content_layout
ref_detail.setSpacing(4)
self._ref_lines_container = QVBoxLayout()
ref_detail.addLayout(self._ref_lines_container)
add_ref_btn = QPushButton("+ Add Reference Line")
add_ref_btn.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
add_ref_btn.setStyleSheet(
f"QPushButton {{ background-color: {PRIMARY}; color: white; "
f"font-weight: bold; padding: 5px 12px; border-radius: 3px; }}"
f"QPushButton:hover {{ background-color: {PRIMARY_LIGHT}; }}"
)
add_ref_btn.clicked.connect(self._add_reference_line)
ref_detail.addWidget(add_ref_btn)
self._main_layout.addWidget(self._ref_lines_collapsible)
# ------------------------------------------------------------------
# Section: Reset
# ------------------------------------------------------------------
def _build_reset_section(self) -> None:
self._reset_btn = QPushButton("Reset All Customization")
self._reset_btn.setToolTip("Reset font, labels, legend, reference lines and per-line styles")
self._reset_btn.setStyleSheet(
"QPushButton { background-color: #cc4444; color: white; "
"font-weight: bold; padding: 5px 12px; border-radius: 3px; }"
"QPushButton:hover { background-color: #aa3333; }"
)
self._reset_btn.clicked.connect(self._on_reset_customization)
self._main_layout.addWidget(self._reset_btn)
# ==================================================================
# Public API
# ==================================================================
def get_plot_config(self) -> PlotConfig:
"""Collect all widget state into a PlotConfig dataclass.
Returns:
A PlotConfig instance reflecting the current UI selections.
"""
y_idx = self._y_axis_segment.currentIndex()
plot_type = self._y_axis_keys[y_idx]
x_idx = self._x_axis_segment.currentIndex()
x_axis_type = self._x_axis_keys[x_idx]
line_configs: dict[str, LineConfig] = {}
for path, edit in self._legend_entries.items():
style = "solid"
width = 2.0
color: str | None = None
markers = True
if path in self._per_line_widgets:
style = self._per_line_widgets[path]["style"].currentText()
width = self._per_line_widgets[path]["width"].value()
c = self._per_line_widgets[path]["color"].currentText()
color = None if c == "auto" else c
markers = self._per_line_widgets[path]["markers"].isChecked()
line_configs[path] = LineConfig(
item_path=path,
label=edit.text() or self._format_legend_label(path),
style=style,
width=width,
color=color,
markers=markers,
)
ref_lines: list[ReferenceLine] = []
for i, w in enumerate(self._ref_line_widgets):
ref_key = f"__ref_{i}"
# Defaults
r_style = "dash"
r_color = "red"
r_width = 1.5
r_markers = False
if ref_key in self._per_line_widgets:
r_style = self._per_line_widgets[ref_key]["style"].currentText()
c = self._per_line_widgets[ref_key]["color"].currentText()
r_color = "red" if c == "auto" else c
r_width = self._per_line_widgets[ref_key]["width"].value()
r_markers = self._per_line_widgets[ref_key]["markers"].isChecked()
ref_lines.append(ReferenceLine(
name=w["name"].text() or w["name"].placeholderText(),
value=w["value"].value(),
color=r_color,
style=r_style,
width=r_width,
enabled=w["enabled"].isChecked(),
markers=r_markers,
))
y_mode = self._y_axis_keys[self._y_axis_segment.currentIndex()]
y_texts = [e.text() for e in self._y_label_edits]
return PlotConfig(
plot_type=plot_type,
log_scale=(self._scale_segment.currentIndex() == 1),
x_axis_type=x_axis_type,
show_legend=self._show_legend_cb.isChecked(),
show_grid=self._show_grid_cb.isChecked(),
legend_position=self._legend_pos_options[
self._legend_pos_segment.currentIndex()
],
custom_x_label=self._x_label_edit.text(),
custom_y_label=(y_texts[0] if y_mode == "shared" and y_texts else ""),
custom_title=self._title_edit.text(),
stacked_titles=[e.text() for e in self._stacked_title_edits],
stacked_y_labels=(y_texts if y_mode == "stacked" else []),
independent_y_labels=(y_texts if y_mode in ("independent", "centered") else []),
title_font=self._read_font_config(self._title_font_size),
x_label_font=self._read_font_config(self._x_font_size),
y_label_font=self._read_font_config(self._y_font_size),
legend_font=self._read_font_config(self._legend_font_size),
x_tick_label_font=self._read_font_config(self._x_tick_label_size),
y_tick_label_font=self._read_font_config(self._y_tick_label_size),
x_tick_size=self._x_tick_marker_size.value(),
y_tick_size=self._y_tick_marker_size.value(),
line_configs=line_configs,
reference_lines=ref_lines,
)
def refresh_line_entries(self, plottable_data: list[tuple[str, list]]) -> None:
"""Rebuild custom legend name fields and per-line style controls.
P12: Auto-expand Legend and Per-Line sections when data is populated.
Args:
plottable_data: List of (item_path, values) tuples for each series.
"""
self._legend_entries.clear()
self._per_line_widgets.clear()
self._plottable_data = plottable_data
if not plottable_data:
old_legend = self._legend_scroll.takeWidget()
if old_legend:
old_legend.deleteLater()
new_legend = QWidget()
QVBoxLayout(new_legend)
self._legend_scroll.setWidget(new_legend)
self._legend_entries_layout = new_legend.layout()
self._replace_per_line_container(QLabel("Plot data to configure per-line styles"))
self._per_line_section.set_expanded(False)
self._rebuild_y_labels()
return
# P12: auto-expand sections when data is populated
# Fresh legend scroll content
old_legend = self._legend_scroll.takeWidget()
if old_legend:
old_legend.deleteLater()
new_legend_content = QWidget()
new_legend_layout = QVBoxLayout(new_legend_content)
self._legend_scroll.setWidget(new_legend_content)
self._legend_entries_layout = new_legend_layout
# Fresh per-line content
per_line_content = QWidget()
per_line_layout = QVBoxLayout(per_line_content)
per_line_layout.setContentsMargins(0, 0, 0, 0)
color_options = [
"auto", "red", "blue", "green", "orange", "purple",
"black", "gray", "cyan", "magenta", "brown", "pink",
]
for item_path, _ in plottable_data:
label = self._format_legend_label(item_path)
# Legend name entry
row = QHBoxLayout()
name_edit = QLineEdit(label)
name_edit.setFixedWidth(400)
name_edit.setPlaceholderText(label)
name_edit.textChanged.connect(self._schedule_change)
scroll_lbl = self._make_scrollable_label(f"{label}:")
row.addWidget(scroll_lbl, 1) # stretch factor 1
row.addWidget(name_edit, 0) # stretch factor 0: fixed, doesn't grow
self._legend_entries_layout.addLayout(row)
self._legend_entries[item_path] = name_edit
# Per-line style/color/width
pl_row = QHBoxLayout()
pl_row.addWidget(self._make_scrollable_label(label))
pl_row.addWidget(QLabel("Style:"))
style_combo = self._make_combo(["solid", "dash", "dot", "dashdot"])
style_combo.currentIndexChanged.connect(self._schedule_change)
pl_row.addWidget(style_combo)
pl_row.addWidget(QLabel("Color:"))
color_combo = self._make_combo(color_options)
color_combo.currentIndexChanged.connect(self._schedule_change)
pl_row.addWidget(color_combo)
pl_row.addWidget(QLabel("Width:"))
width_spin = QDoubleSpinBox()
width_spin.setFixedWidth(80)
width_spin.setRange(0.5, 10.0)
width_spin.setValue(2.0)
width_spin.setSingleStep(0.5)
width_spin.valueChanged.connect(self._schedule_change)
pl_row.addWidget(width_spin)
markers_switch = ToggleSwitch("Markers")
markers_switch.setChecked(True)
markers_switch.toggled.connect(self._schedule_change)
pl_row.addWidget(markers_switch)
per_line_layout.addLayout(pl_row)
self._per_line_widgets[item_path] = {
"style": style_combo, "color": color_combo, "width": width_spin,
"markers": markers_switch,
}
# Include active reference lines in per-line settings
for i, w in enumerate(self._ref_line_widgets):
if w["enabled"].isChecked():
ref_name = w["name"].text() or w["name"].placeholderText()
ref_key = f"__ref_{i}"
pl_row = QHBoxLayout()
ref_label_widget = self._make_scrollable_label(f"[Ref] {ref_name}")
pl_row.addWidget(ref_label_widget)
pl_row.addWidget(QLabel("Style:"))
style_combo = self._make_combo(["solid", "dash", "dot", "dashdot"])
style_combo.setCurrentText("dash")
style_combo.currentIndexChanged.connect(self._schedule_change)
pl_row.addWidget(style_combo)
pl_row.addWidget(QLabel("Color:"))
color_combo = self._make_combo(color_options)
color_combo.setCurrentText("red")
color_combo.currentIndexChanged.connect(self._schedule_change)
pl_row.addWidget(color_combo)
pl_row.addWidget(QLabel("Width:"))
width_spin = QDoubleSpinBox()
width_spin.setFixedWidth(80)
width_spin.setRange(0.5, 10.0)
width_spin.setValue(1.5)
width_spin.setSingleStep(0.5)
width_spin.valueChanged.connect(self._schedule_change)
pl_row.addWidget(width_spin)
markers_switch = ToggleSwitch("Markers")
markers_switch.toggled.connect(self._schedule_change)
pl_row.addWidget(markers_switch)
per_line_layout.addLayout(pl_row)
self._per_line_widgets[ref_key] = {
"style": style_combo, "color": color_combo, "width": width_spin,
"markers": markers_switch,
"label": ref_label_widget.widget(),
}
self._replace_per_line_container(per_line_content)
# Rebuild per-subplot title fields (stacked mode) and the unified
# y-label field(s) for the current data series / Y-axis mode.
self._rebuild_stacked_titles(plottable_data)
self._rebuild_y_labels()
# ==================================================================
# Private helpers
# ==================================================================
def _rebuild_stacked_titles(self, plottable_data: list[tuple[str, list]]) -> None:
"""Rebuild per-subplot title fields for stacked mode."""
# Clear old stacked title edits
while self._stacked_titles_layout.count():
item = self._stacked_titles_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
self._stacked_title_edits.clear()
if not plottable_data:
return
# Add header
title_header = QLabel("Per-subplot titles:")
title_header.setStyleSheet("font-size: 8pt; color: black;")
self._stacked_titles_layout.addWidget(title_header)
for item_path, _ in plottable_data:
label = self._format_legend_label(item_path)
# Title field
title_edit = QLineEdit()
title_edit.setPlaceholderText(label)
title_edit.setMinimumWidth(self._INPUT_WIDTH)
title_edit.textChanged.connect(self._schedule_change)
self._stacked_titles_layout.addWidget(title_edit)
self._stacked_title_edits.append(title_edit)
@staticmethod
def _y_label_bucket(mode: str) -> str:
"""Cache bucket for a Y-axis mode (each mode keeps its own values)."""
return mode
def _snapshot_y_labels(self) -> None:
"""Save the currently displayed y-label texts into the per-mode cache."""
if self._y_labels_current_bucket is not None:
self._y_label_cache[self._y_labels_current_bucket] = [
e.text() for e in self._y_label_edits
]
def _rebuild_y_labels(self) -> None:
"""(Re)build the unified y-label field(s) for the current Y-axis mode.
Shared/Centered show a single "Label" field; Independent/Stacked show
one field per data series. Values are cached per mode so they persist
across mode switches.
"""
# Preserve whatever the user has currently typed before clearing.
self._snapshot_y_labels()
while self._y_labels_layout.count():
item = self._y_labels_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
self._y_label_edits = []
mode = self._y_axis_keys[self._y_axis_segment.currentIndex()]
bucket = self._y_label_bucket(mode)
saved = self._y_label_cache.get(bucket, [])
per_series = mode in ("independent", "centered", "stacked")
if per_series and self._plottable_data:
header = QLabel(
"Per-subplot Y-labels:" if mode == "stacked"
else "Per-axis Y-labels:"
)
header.setStyleSheet("font-size: 8pt; color: black;")
self._y_labels_layout.addWidget(header)
placeholders = [
self._format_legend_label(item_path)
for item_path, _ in self._plottable_data
]
else:
lbl = QLabel("Label:")
lbl.setStyleSheet("font-size: 8pt; color: black;")
self._y_labels_layout.addWidget(lbl)
placeholders = [self._Y_AXIS_DEFAULT_LABELS.get(mode, "Value")]
for i, placeholder in enumerate(placeholders):
edit = QLineEdit()
edit.setFixedWidth(self._INPUT_WIDTH)
edit.setPlaceholderText(placeholder)
if i < len(saved):
edit.setText(saved[i])
edit.textChanged.connect(self._schedule_change)
self._y_labels_layout.addWidget(edit)
self._y_label_edits.append(edit)
self._y_labels_current_bucket = bucket
def _schedule_change(self, *_args) -> None:
"""Debounced auto-apply: restart timer on each change."""
self._debounce_timer.start()
@staticmethod
def _format_legend_label(item_path: str) -> str:
"""Format an item_path into a readable legend label using full file path."""
parts = item_path.split("/", 1)
if len(parts) == 2:
entry_id = parts[0].replace("historyfile_", "")
return f"{entry_id}/{parts[1]}"
return item_path
@staticmethod
def _make_scrollable_label(text: str, min_width: int = 300) -> QScrollArea:
"""Create a horizontally-scrollable label with minimum width that can grow."""
lbl = QLabel(text)
lbl.setStyleSheet("background: transparent;")
scroll = QScrollArea()
scroll.setWidget(lbl)
scroll.setMinimumWidth(min_width)
scroll.setFixedHeight(lbl.sizeHint().height() + 18)
scroll.setWidgetResizable(False)
scroll.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setStyleSheet("QScrollArea { background: transparent; }")
# Scroll to the right end so the most specific part is visible
QTimer.singleShot(0, lambda: scroll.horizontalScrollBar().setValue(
scroll.horizontalScrollBar().maximum()))
return scroll
def _emit_settings(self) -> None:
self.settings_changed.emit()
def _on_per_line_user_toggled(self, expanded: bool) -> None:
self._user_collapsed_per_line = not expanded
def _on_y_axis_changed(self, index) -> None:
if index is None:
return
key = self._y_axis_keys[index]
# Independent/Centered/Stacked require at least 2 data series
if key in ("independent", "centered", "stacked") and len(self._legend_entries) < 2:
QMessageBox.warning(
self, "Insufficient Data",
f"The \"{self._y_axis_options[index][1]}\" Y-axis mode requires "
"at least 2 different quantities to be selected.",
)
self._y_axis_segment.blockSignals(True)
self._y_axis_segment.setCurrentIndex(0) # revert to Shared
self._y_axis_segment.blockSignals(False)
self._y_desc_label.setText(self._y_axis_tips[0])
return
self._y_desc_label.setText(self._y_axis_tips[index])
is_stacked = key == "stacked"
self._stacked_titles_container.setVisible(is_stacked)
self._rebuild_y_labels()
self._schedule_change()
# Default placeholder labels per x-axis option
_X_AXIS_DEFAULT_LABELS: dict[str, str] = {
"queue_count": "Sequence Index",
"combined_ticks": "Outer and Innerloop Iterations",
"equispaced_major": "Outer and Innerloop Iterations",
"cumulative_runtime": "Runtime in ms",
"cumulative_evaluations": "Number of Evaluations",
}
# Default placeholder labels per y-axis option
_Y_AXIS_DEFAULT_LABELS: dict[str, str] = {
"shared": "Value",
"independent": "(per-axis labels below)",
"centered": "Value",
"stacked": "(per-subplot labels below)",
}
def _on_x_axis_changed(self, index) -> None:
if index is None:
return
self._x_desc_label.setText(self._x_axis_tips[index])
key = self._x_axis_keys[index]
self._x_label_edit.setPlaceholderText(
self._X_AXIS_DEFAULT_LABELS.get(key, "Sequence Index")
)
self._schedule_change()
def _on_legend_show_toggled(self, checked: bool) -> None:
self._legend_collapsible._toggle_btn.setEnabled(checked)
if checked:
self._legend_collapsible.set_expanded(True)
else:
self._legend_collapsible.set_expanded(False)
self._schedule_change()
def _on_ref_lines_toggled(self, checked: bool) -> None:
self._ref_lines_collapsible._toggle_btn.setEnabled(checked)
if checked:
self._ref_lines_collapsible.set_expanded(True)
else:
self._ref_lines_collapsible.set_expanded(False)
self._schedule_change()
def _replace_per_line_container(self, new_widget: QWidget) -> None:
parent_layout = self._per_line_container
while parent_layout.count():
item = parent_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
elif item.layout():
self._clear_layout_recursive(item.layout())
parent_layout.addWidget(new_widget)
@staticmethod
def _clear_layout_recursive(layout) -> None:
while layout.count():
child = layout.takeAt(0)
if child.widget():
child.widget().deleteLater()
elif child.layout():
PlotSettingsPanel._clear_layout_recursive(child.layout())
# Reference lines
def _add_reference_line(self) -> None:
row = QHBoxLayout()
row.setSpacing(4)
name_lbl = QLabel("Name:")
name_lbl.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
row.addWidget(name_lbl)
name_edit = QLineEdit()
name_edit.setMinimumWidth(self._INPUT_WIDTH)
name_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
name_edit.setPlaceholderText(f"Ref {len(self._ref_line_widgets) + 1}")
name_edit.textChanged.connect(self._schedule_change)
name_edit.textChanged.connect(self._on_ref_name_changed)
row.addWidget(name_edit)
value_lbl = QLabel("Value:")
value_lbl.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
row.addWidget(value_lbl)
value_spin = QDoubleSpinBox()
value_spin.setLocale(QLocale(QLocale.Language.C))
value_spin.setRange(-1e9, 1e9)
value_spin.setDecimals(4)
value_spin.setFixedWidth(80)
value_spin.valueChanged.connect(self._schedule_change)
row.addWidget(value_spin)
show_toggle = ToggleSwitch("")
show_toggle.setChecked(True)
show_toggle.toggled.connect(self._schedule_change)
row.addWidget(show_toggle)
clear_btn = QPushButton("Clear")
clear_btn.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
clear_btn.setStyleSheet(
"QPushButton { background-color: #cc4444; color: white; "
"font-weight: bold; padding: 3px 8px; border-radius: 3px; }"
"QPushButton:hover { background-color: #aa3333; }"
)
row.addWidget(clear_btn)
# Use a QVBoxLayout wrapper so _remove_reference_line works unchanged
container = QVBoxLayout()
container.addLayout(row)
widget_data: dict = {
"enabled": show_toggle, "name": name_edit,
"value": value_spin, "layout": container,
}
clear_btn.clicked.connect(lambda checked=False, wd=widget_data: self._remove_reference_line(wd))
widget_data["remove"] = clear_btn
self._ref_lines_container.addLayout(container)
self._ref_line_widgets.append(widget_data)
self._schedule_change()
def _remove_reference_line(self, widget_data: dict) -> None:
if widget_data in self._ref_line_widgets:
self._ref_line_widgets.remove(widget_data)
layout = widget_data["layout"]
# Clear sub-layouts and widgets recursively
self._clear_layout_recursive(layout)
self._ref_lines_container.removeItem(layout)
self._schedule_change()
def _on_ref_name_changed(self, *_args) -> None:
"""Update per-line settings labels when a reference line name changes."""
for i, w in enumerate(self._ref_line_widgets):
ref_key = f"__ref_{i}"
if ref_key in self._per_line_widgets:
ref_name = w["name"].text() or w["name"].placeholderText()
pw = self._per_line_widgets[ref_key]
if "label" in pw:
pw["label"].setText(f"[Ref] {ref_name}")
def _on_reset_customization(self) -> None:
msg = QMessageBox(
QMessageBox.Icon.Question,
"Confirm Reset",
"Reset all plot customizations to defaults?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
self,
)
msg.setDefaultButton(QMessageBox.StandardButton.No)
msg.setStyleSheet(
"QPushButton { border: 1px solid #888; border-radius: 3px; "
"padding: 4px 16px; min-width: 60px; }"
"QPushButton:hover { background-color: #e0e0e0; }"
)
reply = msg.exec()
if reply != QMessageBox.StandardButton.Yes:
return
self._reset_font_size(self._title_font_size)
self._reset_font_size(self._x_font_size)
self._reset_font_size(self._y_font_size)
self._reset_font_size(self._legend_font_size)
self._reset_font_size(self._x_tick_label_size)
self._reset_font_size(self._y_tick_label_size)
self._x_tick_marker_size.setValue(8)
self._y_tick_marker_size.setValue(8)
self._x_label_edit.clear()
self._y_label_cache.clear()
for edit in self._y_label_edits:
edit.clear()
self._title_edit.clear()
for wd in list(self._ref_line_widgets):
self._remove_reference_line(wd)
self._per_line_widgets.clear()
self._replace_per_line_container(QLabel("Plot data to configure per-line styles"))
self.customization_reset.emit()
self._schedule_change()