1212 QWidget , QVBoxLayout , QHBoxLayout ,
1313 QPushButton , QToolButton , QLabel ,
1414 QComboBox , QMenu , QWidgetAction ,
15- QLineEdit , QDoubleSpinBox , QColorDialog , QInputDialog ,
15+ QLineEdit , QDoubleSpinBox , QSpinBox , QColorDialog , QInputDialog ,
1616 QSlider , QMessageBox , QApplication
1717)
1818from PySide6 .QtCore import QObject , QEvent , Qt , Signal , QSize , QTimer , QPoint
3030
3131from spectroview import ICON_DIR , X_AXIS_UNIT , Y_AXIS_UNIT , PLOT_POLICY_LIGHT , PLOT_POLICY_DARK , PLOT_POLICY_SOFT_DARK , DEFAULT_COLORS
3232from spectroview .viewmodel .utils import copy_fig_to_clb , get_tinted_icon , fano_display_amplitude
33- from spectroview .view .components .customized_widgets import NoDoubleClickZoomToolbar
33+ from spectroview .view .components .customized_widgets import (
34+ CustomizedPalette ,
35+ NoDoubleClickZoomToolbar ,
36+ )
37+
38+
39+ SPECTRA_DISCRETE_PALETTES = (
40+ "DEFAULT_COLORS" ,
41+ "tab20" , "tab20b" , "tab20c" , "Dark2" , "Paired" , "Accent" ,
42+ )
43+ SPECTRA_GRADIENT_PALETTES = (
44+ "viridis" , "plasma" , "jet" , "cividis" , "magma" ,
45+ )
46+ SPECTRA_COLOR_PALETTES = (
47+ SPECTRA_DISCRETE_PALETTES + SPECTRA_GRADIENT_PALETTES
48+ )
49+ SPECTRA_CUSTOM_PALETTES = {
50+ "DEFAULT_COLORS" : DEFAULT_COLORS ,
51+ }
3452
3553
3654class _MockPeakModelObj :
@@ -389,6 +407,22 @@ def _create_options_menu(self):
389407 self .cbb_plotstyle .currentIndexChanged .connect (self ._emit_view_options )
390408 menu .addAction (self ._wrap ("Spectrum plot style:" , self .cbb_plotstyle ))
391409
410+ # Spectrum color palette. Qualitative palettes cycle through distinct
411+ # colors; sequential/rainbow palettes are sampled across all selected
412+ # spectra so ordered series (time, temperature, etc.) form a gradient.
413+ self .cbb_color_palette = CustomizedPalette (
414+ palette_list = SPECTRA_COLOR_PALETTES ,
415+ custom_palettes = SPECTRA_CUSTOM_PALETTES ,
416+ )
417+ self .cbb_color_palette .setCurrentText ("DEFAULT_COLORS" )
418+ self .cbb_color_palette .setToolTip (
419+ "Choose colors for the selected spectrum series. Custom colors "
420+ "set from the legend continue to take priority."
421+ )
422+ self .cbb_color_palette .currentIndexChanged .connect (
423+ self ._emit_view_options )
424+ menu .addAction (self ._wrap ("Color palette:" , self .cbb_color_palette ))
425+
392426 # Line width
393427 self .spin_lw = QDoubleSpinBox ()
394428 self .spin_lw .setRange (0.1 , 5 )
@@ -434,12 +468,16 @@ def _create_options_menu(self):
434468 menu .addSeparator ()
435469
436470 # Max legend items / heavy overlays
437- self .spin_max_overlays = QDoubleSpinBox ()
438- self .spin_max_overlays .setRange (1 , 1000 )
439- self .spin_max_overlays .setValue (10 )
440- self .spin_max_overlays .valueChanged .connect (self ._emit_view_options )
441- self .spin_max_overlays .valueChanged .connect (self ._plot )
442- menu .addAction (self ._wrap ("Max legend items:" , self .spin_max_overlays ))
471+ self .spin_max_legend_items = QSpinBox ()
472+ self .spin_max_legend_items .setRange (1 , 1000 )
473+ self .spin_max_legend_items .setValue (15 )
474+ self .spin_max_legend_items .valueChanged .connect (self ._emit_view_options )
475+ menu .addAction (self ._wrap (
476+ "Max legend items:" , self .spin_max_legend_items ))
477+
478+ # Backwards-compatible alias for internal/external code that used the
479+ # old name when this control was first introduced.
480+ self .spin_max_overlays = self .spin_max_legend_items
443481
444482 menu .addSeparator ()
445483
@@ -612,7 +650,7 @@ def _plot_internal(self):
612650 plot_style = self .cbb_plotstyle .currentText ()
613651 lw = self .spin_lw .value ()
614652 dot_size = self .spin_dotsize .value ()
615- colors_cycle = self ._get_colors_cycle ()
653+ colors_cycle = self ._get_colors_cycle (len ( self . _tensor_data [ "y" ]) )
616654
617655 # ── Step 1: Build bulk segment data (main spectra + raw overlay) ──
618656 segments = self ._build_tensor_segments (
@@ -631,11 +669,51 @@ def _plot_internal(self):
631669 # Plot helpers: segment builders
632670 # ─────────────────────────────────────────────
633671
634- def _get_colors_cycle (self ):
635- """Return the current matplotlib color cycle or a sensible default."""
636- prop_cycle = plt .rcParams .get ('axes.prop_cycle' )
637- return (prop_cycle .by_key ()['color' ] if prop_cycle
638- else DEFAULT_COLORS )
672+ def _get_colors_cycle (self , count = None ):
673+ """Return colors sampled from the selected spectrum palette.
674+
675+ Discrete palettes repeat only after all of their distinct colors have
676+ been used. Gradient palettes are sampled evenly from end to end, which
677+ makes the spectrum order visible for time/temperature series.
678+ """
679+ palette_name = (
680+ self .cbb_color_palette .currentText ()
681+ if hasattr (self , "cbb_color_palette" ) else "DEFAULT_COLORS"
682+ )
683+ requested_count = (
684+ len (DEFAULT_COLORS ) if count is None else max (0 , int (count ))
685+ )
686+
687+ if palette_name in SPECTRA_CUSTOM_PALETTES :
688+ base_colors = list (SPECTRA_CUSTOM_PALETTES [palette_name ])
689+ else :
690+ try :
691+ cmap = mpl .colormaps [palette_name ]
692+ except (KeyError , AttributeError ):
693+ try :
694+ cmap = mpl .cm .get_cmap (palette_name )
695+ except (ValueError , KeyError ):
696+ base_colors = list (DEFAULT_COLORS )
697+ cmap = None
698+
699+ if palette_name in SPECTRA_DISCRETE_PALETTES and cmap is not None :
700+ cmap_colors = getattr (cmap , "colors" , None )
701+ if cmap_colors is None :
702+ cmap_colors = cmap (np .linspace (0.0 , 1.0 , cmap .N ))
703+ base_colors = [mpl .colors .to_hex (color )
704+ for color in cmap_colors ]
705+ elif cmap is not None :
706+ if requested_count == 0 :
707+ return []
708+ positions = (np .array ([0.5 ]) if requested_count == 1
709+ else np .linspace (0.0 , 1.0 , requested_count ))
710+ return [mpl .colors .to_hex (cmap (position ))
711+ for position in positions ]
712+
713+ if not base_colors :
714+ base_colors = list (DEFAULT_COLORS ) or ["#1f77b4" ]
715+ return [base_colors [i % len (base_colors )]
716+ for i in range (requested_count )]
639717
640718 def _build_tensor_segments (self , x_shift_step , y_shift_step ,
641719 plot_style , lw , fg_color , colors_cycle ):
@@ -655,9 +733,15 @@ def _build_tensor_segments(self, x_shift_step, y_shift_step,
655733 Y_norm = self ._get_normalized_y_tensor (x , Y )
656734
657735 # Resolve each spectrum's color: use stored color if available, else cycle
658- t_colors = self ._tensor_data .get ("colors" , [None ] * N )
659- main_colors = [c if c else colors_cycle [i % len (colors_cycle )]
660- for i , c in enumerate (t_colors )]
736+ t_colors = self ._tensor_data .get ("colors" )
737+ if t_colors is None :
738+ t_colors = []
739+ main_colors = [
740+ t_colors [i ]
741+ if i < len (t_colors ) and t_colors [i ]
742+ else colors_cycle [i ]
743+ for i in range (N )
744+ ]
661745
662746 # tensor_list stores ragged arrays as a Python list; tensor stores a uniform 2-D ndarray
663747 is_list = isinstance (Y , list )
@@ -716,7 +800,7 @@ def _build_tensor_segments(self, x_shift_step, y_shift_step,
716800 t_labels = self ._tensor_data .get ("labels" , [])
717801 t_fnames = self ._tensor_data .get ("fnames" , [])
718802 proxies = self ._tensor_data .get ("proxies" , []) # Spectrum objects for interactive tooltip
719- for spec_idx in range (min (N , int ( self .spin_max_overlays .value () ))):
803+ for spec_idx in range (min (N , self .spin_max_legend_items .value ())):
720804 spec_color = main_colors [spec_idx ]
721805 # Priority: custom label > filename > generic fallback
722806 label_str = (
@@ -758,7 +842,8 @@ def _draw_tensor_overlays(self, x_shift_step, y_shift_step, lw,
758842 is_tensor_list = self ._tensor_data .get ("type" ) == "tensor_list"
759843 proxies = self ._tensor_data .get ("proxies" , [])
760844
761- for spec_idx in range (min (n_specs , int (self .spin_max_overlays .value ()))):
845+ for spec_idx in range (
846+ min (n_specs , self .spin_max_legend_items .value ())):
762847 x_val = self ._tensor_data .get ("x" )
763848 x = x_val [spec_idx ] if isinstance (x_val , list ) else x_val
764849
@@ -1045,7 +1130,10 @@ def _finalize_plot(self, segments, plot_style, lw, dot_size, fg_color,
10451130
10461131 # ── Legend / axes / grid ──
10471132 if self .btn_legend .isChecked ():
1048- legend = self .ax .legend (loc = "best" )
1133+ handles , labels = self .ax .get_legend_handles_labels ()
1134+ max_items = self .spin_max_legend_items .value ()
1135+ legend = self .ax .legend (
1136+ handles [:max_items ], labels [:max_items ], loc = "best" )
10491137 self ._make_legend_pickable (legend )
10501138
10511139 if self .act_grid .isChecked ():
@@ -1099,7 +1187,11 @@ def _make_legend_pickable(self, legend):
10991187
11001188 # Cache legend and its artists for double-click hit-testing
11011189 self ._legend_obj = legend
1102- self ._legend_bbox = legend .get_window_extent (self .canvas .renderer )
1190+ # ``renderer`` is only installed as an attribute after the first draw;
1191+ # get_renderer() also works when data arrives before the widget has
1192+ # been painted (common during workspace restoration and in tests).
1193+ self ._legend_bbox = legend .get_window_extent (
1194+ self .canvas .get_renderer ())
11031195
11041196 # Connect double-click handler once (replaces pick_event)
11051197 if not hasattr (self , "_legend_dblclick_connected" ):
@@ -1256,6 +1348,7 @@ def get_options_state(self):
12561348 "yaxis" : self .cbb_yaxis .currentText () if hasattr (self , "cbb_yaxis" ) else "" ,
12571349 "yscale" : self .cbb_yscale .currentText () if hasattr (self , "cbb_yscale" ) else "Linear" ,
12581350 "plotstyle" : self .cbb_plotstyle .currentText () if hasattr (self , "cbb_plotstyle" ) else "line" ,
1351+ "color_palette" : self .cbb_color_palette .currentText () if hasattr (self , "cbb_color_palette" ) else "DEFAULT_COLORS" ,
12591352 "lw" : self .spin_lw .value () if hasattr (self , "spin_lw" ) else 1.5 ,
12601353 "dotsize" : self .spin_dotsize .value () if hasattr (self , "spin_dotsize" ) else 3.0 ,
12611354 "raw" : self .act_raw .isChecked () if hasattr (self , "act_raw" ) else False ,
@@ -1268,6 +1361,7 @@ def get_options_state(self):
12681361 "height" : self .height_entry .text () if hasattr (self , "height_entry" ) else "4.0" ,
12691362 "legend" : self .btn_legend .isChecked () if hasattr (self , "btn_legend" ) else False ,
12701363 "bestfit" : self .btn_bestfit .isChecked () if hasattr (self , "btn_bestfit" ) else False ,
1364+ "max_legend_items" : self .spin_max_legend_items .value () if hasattr (self , "spin_max_legend_items" ) else 15 ,
12711365 "copy_fig_theme" : self .cbb_copy_theme .currentText () if hasattr (self , "cbb_copy_theme" ) else "Light Mode" ,
12721366 }
12731367
@@ -1291,6 +1385,10 @@ def _update(widget, setter, value):
12911385 _update (self .cbb_yaxis , self .cbb_yaxis .setCurrentText , state .get ("yaxis" ))
12921386 _update (self .cbb_yscale , self .cbb_yscale .setCurrentText , state .get ("yscale" ))
12931387 _update (self .cbb_plotstyle , self .cbb_plotstyle .setCurrentText , state .get ("plotstyle" ))
1388+ if hasattr (self , "cbb_color_palette" ):
1389+ _update (self .cbb_color_palette ,
1390+ self .cbb_color_palette .setCurrentText ,
1391+ state .get ("color_palette" , "DEFAULT_COLORS" ))
12941392 _update (self .spin_lw , self .spin_lw .setValue , state .get ("lw" ))
12951393 _update (self .spin_dotsize , self .spin_dotsize .setValue , state .get ("dotsize" ))
12961394 _update (self .act_raw , self .act_raw .setChecked , state .get ("raw" ))
@@ -1304,6 +1402,10 @@ def _update(widget, setter, value):
13041402 _update (self .height_entry , self .height_entry .setText , state .get ("height" ))
13051403 _update (self .btn_legend , self .btn_legend .setChecked , state .get ("legend" ))
13061404 _update (self .btn_bestfit , self .btn_bestfit .setChecked , state .get ("bestfit" ))
1405+ if hasattr (self , "spin_max_legend_items" ):
1406+ _update (self .spin_max_legend_items ,
1407+ self .spin_max_legend_items .setValue ,
1408+ state .get ("max_legend_items" , 15 ))
13071409 if hasattr (self , "cbb_copy_theme" ):
13081410 _update (self .cbb_copy_theme , self .cbb_copy_theme .setCurrentText , state .get ("copy_fig_theme" , "Light Mode" ))
13091411
0 commit comments