# ******************************************************************************
# pySimBlocks
# Copyright (c) 2026 Université de Lille & INRIA
# ******************************************************************************
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
# for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# ******************************************************************************
# Authors: see Authors.txt
# ******************************************************************************
from __future__ import annotations
from copy import copy
from functools import lru_cache
from matplotlib.markers import MarkerStyle
from PySide6.QtWidgets import (
QComboBox,
QCompleter,
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
QColorDialog,
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor
from pySimBlocks.project.plot_series import (
DEFAULT_SERIES_STYLE,
SeriesStyle,
is_usable_line_marker,
normalize_marker_code,
normalize_plot_marker,
)
[docs]
def marker_display_label(marker: str) -> str:
"""Human-readable marker name for the combo box (matplotlib registry description)."""
desc = MarkerStyle.markers.get(marker)
if isinstance(desc, str) and desc:
return desc
return str(marker)
[docs]
def marker_from_display_name(name: str) -> str:
"""Resolve a display name (or symbol) back to a matplotlib marker code."""
text = name.strip()
if not text or text.lower() == "none":
return ""
_, name_to_symbol = matplotlib_marker_maps()
key = text.lower()
if key in name_to_symbol:
return name_to_symbol[key]
return normalize_plot_marker(text)
[docs]
@lru_cache(maxsize=1)
def matplotlib_marker_maps() -> tuple[list[tuple[str, str]], dict[str, str]]:
"""Build combo options and a lookup from display name / symbol to marker code."""
options: list[tuple[str, str]] = [("None", "")]
name_to_symbol: dict[str, str] = {"none": ""}
labels_seen: dict[str, int] = {}
for marker in sorted(MarkerStyle.markers.keys(), key=lambda m: (len(str(m)), str(m).lower())):
symbol = normalize_marker_code(marker)
if not symbol or not is_usable_line_marker(symbol):
continue
label = marker_display_label(symbol)
count = labels_seen.get(label, 0)
labels_seen[label] = count + 1
if count > 0:
label = f"{label} ({symbol!r})"
options.append((label, symbol))
name_to_symbol[label.lower()] = symbol
name_to_symbol[symbol.lower()] = symbol
desc = MarkerStyle.markers.get(marker)
if isinstance(desc, str) and desc:
name_to_symbol[desc.lower()] = symbol
return options, name_to_symbol
[docs]
def matplotlib_marker_options() -> list[tuple[str, str]]:
"""Return (label, marker) pairs for line-plot markers only."""
return matplotlib_marker_maps()[0]
LINESTYLE_OPTIONS: list[tuple[str, str]] = [
("Solid", "-"),
("Dashed", "--"),
("Dash-dot", "-."),
("Dotted", ":"),
]
COLOR_OPTIONS: list[tuple[str, str]] = [
("Auto", ""),
("Blue", "#1f77b4"),
("Orange", "#ff7f0e"),
("Green", "#2ca02c"),
("Red", "#d62728"),
("Purple", "#9467bd"),
("Brown", "#8c564b"),
("Pink", "#e377c2"),
("Gray", "#7f7f7f"),
("Olive", "#bcbd22"),
("Cyan", "#17becf"),
("Black", "#000000"),
]
[docs]
class SeriesStyleDialog(QDialog):
"""Dialog to edit marker, linestyle, and color for one series."""
def __init__(self, series_label: str, style: SeriesStyle, parent=None):
super().__init__(parent)
self.setWindowTitle(f"Series style - {series_label}")
self._style = copy(style)
self._custom_color = style.color if style.color and not self._is_preset_color(style.color) else ""
layout = QVBoxLayout(self)
layout.addWidget(QLabel(f"<b>Signal</b> (logged): <code>{series_label}</code>"))
layout.addWidget(QLabel("<b>Legend name</b> (empty = default label in plot)"))
self.display_name_edit = QLineEdit()
self.display_name_edit.setText(style.display_name)
self.display_name_edit.setPlaceholderText(series_label)
self.display_name_edit.setToolTip(
"Name shown in the legend and on curves. Leave empty to use the logged signal label."
)
layout.addWidget(self.display_name_edit)
layout.addWidget(QLabel("<b>Marker</b> (None = no markers on the step curve)"))
self.marker_combo = QComboBox()
self.marker_combo.setEditable(True)
self.marker_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
self.marker_combo.setMaxVisibleItems(20)
for label, value in matplotlib_marker_options():
self.marker_combo.addItem(label, value)
completer = QCompleter([self.marker_combo.itemText(i) for i in range(self.marker_combo.count())])
completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
completer.setFilterMode(Qt.MatchFlag.MatchContains)
self.marker_combo.setCompleter(completer)
self._set_marker_combo(self._style.marker)
layout.addWidget(self.marker_combo)
layout.addWidget(QLabel("<b>Line style</b>"))
self.linestyle_combo = QComboBox()
for label, value in LINESTYLE_OPTIONS:
self.linestyle_combo.addItem(label, value)
self._set_combo_by_data(self.linestyle_combo, self._style.linestyle)
layout.addWidget(self.linestyle_combo)
layout.addWidget(QLabel("<b>Color</b>"))
color_row = QHBoxLayout()
self.color_combo = QComboBox()
for label, value in COLOR_OPTIONS:
self.color_combo.addItem(label, value)
if self._style.color and self._is_preset_color(self._style.color):
self._set_combo_by_data(self.color_combo, self._style.color)
else:
self.color_combo.setCurrentIndex(0)
self.color_combo.currentIndexChanged.connect(self._on_preset_color_changed)
color_row.addWidget(self.color_combo, 1)
self.custom_color_btn = QPushButton("Custom color…")
self.custom_color_btn.clicked.connect(self._pick_custom_color)
color_row.addWidget(self.custom_color_btn)
layout.addLayout(color_row)
self.color_preview = QLabel()
self.color_preview.setFixedHeight(24)
layout.addWidget(self.color_preview)
self._update_color_preview()
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
@staticmethod
def _is_preset_color(color: str) -> bool:
return any(value == color for _, value in COLOR_OPTIONS if value)
@staticmethod
def _set_combo_by_data(combo: QComboBox, value: str) -> None:
idx = combo.findData(value)
combo.setCurrentIndex(idx if idx >= 0 else 0)
def _set_marker_combo(self, marker: str) -> None:
"""Select a marker in the combo, adding it if not in the matplotlib registry list."""
if not marker:
self._set_combo_by_data(self.marker_combo, "")
return
idx = self.marker_combo.findData(marker)
if idx >= 0:
self.marker_combo.setCurrentIndex(idx)
return
code = normalize_plot_marker(marker)
if not code:
self._set_combo_by_data(self.marker_combo, "")
return
label = marker_display_label(code)
self.marker_combo.addItem(label, code)
self.marker_combo.setCurrentIndex(self.marker_combo.count() - 1)
def _marker_from_combo(self) -> str:
"""Read the marker code from the marker combo."""
data = self.marker_combo.currentData()
if data is not None:
return normalize_plot_marker(data)
return marker_from_display_name(self.marker_combo.currentText())
def _resolved_color(self) -> str:
if self._custom_color:
return self._custom_color
data = self.color_combo.currentData()
return str(data) if data else ""
def _on_preset_color_changed(self) -> None:
self._custom_color = ""
self._update_color_preview()
def _pick_custom_color(self) -> None:
initial = QColor(self._custom_color or self._resolved_color() or "#1f77b4")
color = QColorDialog.getColor(initial, self, "Series color")
if not color.isValid():
return
self._custom_color = color.name()
self.color_combo.blockSignals(True)
self.color_combo.setCurrentIndex(0)
self.color_combo.blockSignals(False)
self._update_color_preview()
def _update_color_preview(self) -> None:
resolved = self._resolved_color()
if not resolved:
self.color_preview.setText("Preview: automatic color (matplotlib cycle)")
self.color_preview.setStyleSheet("background: palette(base); border: 1px solid palette(mid);")
return
self.color_preview.setText(f"Preview: {resolved}")
self.color_preview.setStyleSheet(
f"background-color: {resolved}; border: 1px solid palette(mid); color: white;"
)
[docs]
def style(self) -> SeriesStyle:
"""Return the style edited in this dialog."""
linestyle = self.linestyle_combo.currentData()
return SeriesStyle(
color=self._resolved_color(),
linestyle=str(linestyle) if linestyle is not None else "-",
marker=self._marker_from_combo(),
display_name=self.display_name_edit.text().strip(),
)