batplot 1.8.1__py3-none-any.whl → 1.8.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of batplot might be problematic. Click here for more details.

Files changed (42) hide show
  1. batplot/__init__.py +1 -1
  2. batplot/args.py +2 -0
  3. batplot/batch.py +23 -0
  4. batplot/batplot.py +101 -12
  5. batplot/cpc_interactive.py +25 -3
  6. batplot/electrochem_interactive.py +20 -4
  7. batplot/interactive.py +19 -15
  8. batplot/modes.py +12 -12
  9. batplot/operando_ec_interactive.py +4 -4
  10. batplot/session.py +218 -0
  11. batplot/style.py +21 -2
  12. batplot/version_check.py +1 -1
  13. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/METADATA +1 -1
  14. batplot-1.8.3.dist-info/RECORD +75 -0
  15. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/top_level.txt +1 -0
  16. batplot_backup_20251221_101150/__init__.py +5 -0
  17. batplot_backup_20251221_101150/args.py +625 -0
  18. batplot_backup_20251221_101150/batch.py +1176 -0
  19. batplot_backup_20251221_101150/batplot.py +3589 -0
  20. batplot_backup_20251221_101150/cif.py +823 -0
  21. batplot_backup_20251221_101150/cli.py +149 -0
  22. batplot_backup_20251221_101150/color_utils.py +547 -0
  23. batplot_backup_20251221_101150/config.py +198 -0
  24. batplot_backup_20251221_101150/converters.py +204 -0
  25. batplot_backup_20251221_101150/cpc_interactive.py +4409 -0
  26. batplot_backup_20251221_101150/electrochem_interactive.py +4520 -0
  27. batplot_backup_20251221_101150/interactive.py +3894 -0
  28. batplot_backup_20251221_101150/manual.py +323 -0
  29. batplot_backup_20251221_101150/modes.py +799 -0
  30. batplot_backup_20251221_101150/operando.py +603 -0
  31. batplot_backup_20251221_101150/operando_ec_interactive.py +5487 -0
  32. batplot_backup_20251221_101150/plotting.py +228 -0
  33. batplot_backup_20251221_101150/readers.py +2607 -0
  34. batplot_backup_20251221_101150/session.py +2951 -0
  35. batplot_backup_20251221_101150/style.py +1441 -0
  36. batplot_backup_20251221_101150/ui.py +790 -0
  37. batplot_backup_20251221_101150/utils.py +1046 -0
  38. batplot_backup_20251221_101150/version_check.py +253 -0
  39. batplot-1.8.1.dist-info/RECORD +0 -52
  40. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/WHEEL +0 -0
  41. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/entry_points.txt +0 -0
  42. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1441 @@
1
+ """Style helpers for batplot: print diagnostics, export/import style configs.
2
+
3
+ These utilities keep batplot.py slimmer by centralizing style logic.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import List, Dict, Any, Callable, Optional
9
+ import json
10
+ import importlib
11
+ import sys
12
+ import numpy as np
13
+ import matplotlib.pyplot as plt
14
+ import matplotlib.colors as mcolors
15
+
16
+ from .utils import _confirm_overwrite
17
+ from .color_utils import color_block
18
+ from .ui import (
19
+ ensure_text_visibility as _ui_ensure_text_visibility,
20
+ update_tick_visibility as _ui_update_tick_visibility,
21
+ position_top_xlabel as _ui_position_top_xlabel,
22
+ position_right_ylabel as _ui_position_right_ylabel,
23
+ position_bottom_xlabel as _ui_position_bottom_xlabel,
24
+ position_left_ylabel as _ui_position_left_ylabel,
25
+ )
26
+
27
+
28
+ def _color_to_hex(value):
29
+ """
30
+ Convert any color representation to hexadecimal format (e.g., '#FF0000').
31
+
32
+ HOW IT WORKS:
33
+ ------------
34
+ Colors can be represented in many ways:
35
+ - Named colors: 'red', 'blue', 'green'
36
+ - Hex codes: '#FF0000', '#00FF00'
37
+ - RGB tuples: (1.0, 0.0, 0.0) or (255, 0, 0)
38
+ - RGBA tuples: (1.0, 0.0, 0.0, 1.0)
39
+
40
+ This function normalizes all of these to hex format for consistent storage.
41
+
42
+ WHY HEX FORMAT?
43
+ --------------
44
+ Hex format (#RRGGBB) is:
45
+ - Human-readable (easy to see what color it is)
46
+ - Standard format (works in CSS, HTML, etc.)
47
+ - Compact (6 characters for any color)
48
+ - Easy to store in JSON files
49
+
50
+ Args:
51
+ value: Color in any format (string, tuple, matplotlib color object, etc.)
52
+
53
+ Returns:
54
+ Hex color string (e.g., '#FF0000'), or original value if conversion fails
55
+ """
56
+ # None values stay None (no conversion needed)
57
+ if value is None:
58
+ return None
59
+
60
+ # Handle special string values that shouldn't be converted
61
+ if isinstance(value, str):
62
+ low = value.lower()
63
+ if low in ('none', 'auto'):
64
+ # 'none' and 'auto' are special matplotlib values - keep as-is
65
+ return value
66
+
67
+ # Try direct conversion (works for most matplotlib color formats)
68
+ try:
69
+ return mcolors.to_hex(value)
70
+ except Exception:
71
+ # Direct conversion failed, try alternative methods
72
+ if isinstance(value, str):
73
+ # Already a string - might be hex or named color, return as-is
74
+ return value
75
+ try:
76
+ # Try converting to RGBA first, then to hex
77
+ # This handles RGB tuples like (1.0, 0.0, 0.0)
78
+ return mcolors.to_hex(mcolors.to_rgba(value))
79
+ except Exception:
80
+ # All conversion methods failed - convert to string as last resort
81
+ return str(value)
82
+
83
+
84
+ def _get_primary_axis_text(ax, axis: str) -> str:
85
+ if axis == 'x':
86
+ label = ax.xaxis.label
87
+ stored_attr = '_stored_xlabel'
88
+ else:
89
+ label = ax.yaxis.label
90
+ stored_attr = '_stored_ylabel'
91
+ text = ''
92
+ try:
93
+ text = label.get_text()
94
+ except Exception:
95
+ text = ''
96
+ if not text and hasattr(ax, stored_attr):
97
+ try:
98
+ stored = getattr(ax, stored_attr)
99
+ if stored:
100
+ text = stored
101
+ except Exception:
102
+ text = ''
103
+ return text or ''
104
+
105
+
106
+ def _get_duplicate_axis_text(ax, artist_attr: str, fallback: str = '') -> str:
107
+ override_attr = '_top_xlabel_text_override' if 'top' in artist_attr else '_right_ylabel_text_override'
108
+ if hasattr(ax, override_attr):
109
+ try:
110
+ override_val = getattr(ax, override_attr)
111
+ if override_val:
112
+ return override_val
113
+ except Exception:
114
+ pass
115
+ art = getattr(ax, artist_attr, None)
116
+ if art is not None and hasattr(art, 'get_text'):
117
+ try:
118
+ txt = art.get_text()
119
+ if txt:
120
+ return txt
121
+ except Exception:
122
+ pass
123
+ return fallback or ''
124
+
125
+
126
+ def _resolve_palette_cmap(palette_name: str):
127
+ """
128
+ Resolve a palette name to a matplotlib colormap object.
129
+
130
+ HOW IT WORKS:
131
+ ------------
132
+ This function tries multiple sources to find a colormap:
133
+
134
+ 1. **Matplotlib built-in**: Try plt.get_cmap() first (fastest)
135
+ - Examples: 'viridis', 'plasma', 'tab10'
136
+
137
+ 2. **cmcrameri package**: Try to load from cmcrameri.cm module
138
+ - cmcrameri is an optional package with scientific colormaps
139
+ - Examples: 'batlow', 'batlowk', 'batloww'
140
+ - Only tried if palette name starts with 'batlow'
141
+
142
+ 3. **Custom colormaps**: Try to create from _CUSTOM_CMAPS dictionary
143
+ - Fallback if cmcrameri not installed
144
+ - Creates colormap from hardcoded color lists
145
+
146
+ REVERSED COLORMAPS:
147
+ ------------------
148
+ Colormaps can be reversed by adding '_r' suffix:
149
+ - 'viridis' → normal (dark to bright)
150
+ - 'viridis_r' → reversed (bright to dark)
151
+
152
+ The function handles this by:
153
+ 1. Removing '_r' suffix to get base name
154
+ 2. Getting the base colormap
155
+ 3. Calling .reversed() if '_r' was present
156
+
157
+ Args:
158
+ palette_name: Name of colormap (e.g., 'viridis', 'batlow', 'viridis_r')
159
+
160
+ Returns:
161
+ Matplotlib colormap object, or None if not found
162
+ """
163
+ # Empty name - return None
164
+ if not palette_name:
165
+ return None
166
+
167
+ # METHOD 1: Try matplotlib's built-in colormaps (most common case)
168
+ try:
169
+ return plt.get_cmap(palette_name)
170
+ except ValueError:
171
+ # Not a built-in colormap, try other sources
172
+ pass
173
+
174
+ # Normalize name to lowercase for case-insensitive matching
175
+ name_lower = palette_name.lower()
176
+ # Extract base name (remove '_r' suffix if present)
177
+ # Example: 'viridis_r' → base_name = 'viridis'
178
+ base_name = name_lower[:-2] if name_lower.endswith('_r') else name_lower
179
+
180
+ # METHOD 2: Try cmcrameri package (for 'batlow' variants)
181
+ if name_lower.startswith('batlow'):
182
+ try:
183
+ # Try to import cmcrameri package (optional dependency)
184
+ cmc = importlib.import_module('cmcrameri.cm')
185
+ # Check if exact name exists (e.g., 'batlow', 'batlowk')
186
+ if hasattr(cmc, name_lower):
187
+ cmap = getattr(cmc, name_lower)
188
+ # Reverse if '_r' suffix was present
189
+ if name_lower.endswith('_r'):
190
+ cmap = cmap.reversed()
191
+ return cmap
192
+ # Fallback: try generic 'batlow' if specific variant not found
193
+ if hasattr(cmc, 'batlow'):
194
+ cmap = getattr(cmc, 'batlow')
195
+ if name_lower.endswith('_r'):
196
+ cmap = cmap.reversed()
197
+ return cmap
198
+ except Exception:
199
+ # cmcrameri not installed or colormap not found, continue to next method
200
+ pass
201
+
202
+ # METHOD 3: Fallback to custom colormaps defined in this package
203
+ try:
204
+ from .color_utils import _CUSTOM_CMAPS
205
+ custom_colors = _CUSTOM_CMAPS.get(base_name)
206
+ if custom_colors:
207
+ from matplotlib.colors import LinearSegmentedColormap
208
+ # Create colormap from list of colors
209
+ # N=256 means create 256 intermediate colors by interpolation
210
+ cmap = LinearSegmentedColormap.from_list(base_name, custom_colors, N=256)
211
+ # Reverse if '_r' suffix was present
212
+ if name_lower.endswith('_r'):
213
+ cmap = cmap.reversed()
214
+ return cmap
215
+ except Exception:
216
+ # Custom colormap creation failed
217
+ pass
218
+
219
+ # All methods failed - colormap not found
220
+ return None
221
+
222
+
223
+ def _apply_curve_palette(ax, record: Dict[str, Any]) -> bool:
224
+ """
225
+ Apply a color palette to curves when loading a saved style/session file.
226
+
227
+ HOW IT WORKS:
228
+ ------------
229
+ This function is called when you load a style file (p i s command) that contains
230
+ palette information. It restores the exact same colors that were used when the
231
+ style was saved.
232
+
233
+ The style file stores:
234
+ - palette_name: Which colormap was used (e.g., 'viridis')
235
+ - indices: Which curves were colored (e.g., [1, 2, 3, 4, 5])
236
+ - low_clip, high_clip: The sampling range used (e.g., 0.08 to 0.85)
237
+
238
+ This function:
239
+ 1. Gets the same colormap that was used originally
240
+ 2. Samples colors at the same positions (using stored low_clip/high_clip)
241
+ 3. Applies colors to the same curves (using stored indices)
242
+
243
+ This ensures that when you reload a style, the colors look exactly the same
244
+ as when you saved it.
245
+
246
+ Args:
247
+ ax: Matplotlib axes object containing the plot lines
248
+ record: Dictionary from style file containing:
249
+ - 'palette': Colormap name (e.g., 'viridis')
250
+ - 'indices': List of curve indices (1-indexed, e.g., [1, 2, 3])
251
+ - 'low_clip': Lower bound for color sampling (default 0.08)
252
+ - 'high_clip': Upper bound for color sampling (default 0.85)
253
+
254
+ Returns:
255
+ True if palette was successfully applied, False otherwise
256
+ """
257
+ # Extract palette information from style record
258
+ palette_name = record.get('palette')
259
+ indices = record.get('indices')
260
+
261
+ # Validate that we have the required information
262
+ if not palette_name or not indices:
263
+ return False
264
+
265
+ # Get the colormap (same one used when style was saved)
266
+ cmap = _resolve_palette_cmap(palette_name)
267
+ if cmap is None:
268
+ print(f"Warning: Unknown palette '{palette_name}' in style file.")
269
+ return False
270
+
271
+ # Convert 1-indexed curve numbers to 0-indexed array indices
272
+ # Style files store curves as 1, 2, 3, ... (user-friendly)
273
+ # But matplotlib uses 0, 1, 2, ... (programmer-friendly)
274
+ try:
275
+ zero_based = [int(i) - 1 for i in indices]
276
+ except Exception:
277
+ zero_based = []
278
+
279
+ # Filter out invalid indices (curves that don't exist)
280
+ zero_based = [i for i in zero_based if 0 <= i < len(ax.lines)]
281
+ if not zero_based:
282
+ return False
283
+
284
+ # Get the color sampling range from style file (or use defaults)
285
+ # These values determine which part of the colormap to sample from
286
+ low_clip = float(record.get('low_clip', 0.08)) # Default: start at 8% into colormap
287
+ high_clip = float(record.get('high_clip', 0.85)) # Default: end at 85% into colormap
288
+
289
+ # Get number of curves to color
290
+ nsel = len(zero_based)
291
+
292
+ # Sample colors from colormap at evenly spaced positions
293
+ # This recreates the exact same color assignment as when the style was saved
294
+ if nsel == 1:
295
+ # Single curve: use middle of colormap
296
+ colors = [cmap(0.55)]
297
+ elif nsel == 2:
298
+ # Two curves: use clipped range endpoints for maximum contrast
299
+ colors = [cmap(low_clip), cmap(high_clip)]
300
+ else:
301
+ # Multiple curves: sample evenly across the stored range
302
+ # np.linspace creates the same positions that were used originally
303
+ positions = np.linspace(low_clip, high_clip, nsel)
304
+ # Sample color at each position
305
+ colors = [cmap(p) for p in positions]
306
+
307
+ # Apply colors to the curves
308
+ # Loop through curve indices and assign corresponding color
309
+ for idx, color in zip(zero_based, colors):
310
+ try:
311
+ ax.lines[idx].set_color(color)
312
+ except Exception:
313
+ # Skip if curve doesn't exist (shouldn't happen due to filtering above)
314
+ pass
315
+
316
+ return True
317
+
318
+
319
+ def print_style_info(
320
+ fig,
321
+ ax,
322
+ y_data_list: List[np.ndarray],
323
+ labels: List[str],
324
+ offsets_list: List[float],
325
+ x_full_list: List[np.ndarray],
326
+ raw_y_full_list: List[np.ndarray],
327
+ args,
328
+ delta: float,
329
+ label_text_objects: List,
330
+ tick_state: Dict[str, bool],
331
+ cif_tick_series: Optional[List[tuple]] = None,
332
+ show_cif_hkl: Optional[bool] = None,
333
+ ) -> None:
334
+ print("\n--- Style / Diagnostics ---")
335
+ fw, fh = fig.get_size_inches()
336
+ print(f"Figure size (inches): {fw:.3f} x {fh:.3f}")
337
+ # DPI omitted from compact style print
338
+ bbox = ax.get_position()
339
+ print(
340
+ f"Axes position (figure fraction): x0={bbox.x0:.3f}, y0={bbox.y0:.3f}, w={bbox.width:.3f}, h={bbox.height:.3f}"
341
+ )
342
+ frame_w_in = bbox.width * fw
343
+ frame_h_in = bbox.height * fh
344
+ print(f"Plot frame size (inches): {frame_w_in:.3f} x {frame_h_in:.3f}")
345
+ sp = fig.subplotpars
346
+ print(
347
+ f"Margins (subplot fractions): left={sp.left:.3f}, right={sp.right:.3f}, bottom={sp.bottom:.3f}, top={sp.top:.3f}"
348
+ )
349
+ # Omit ranges and axis labels from style print
350
+ # Font info
351
+ if label_text_objects:
352
+ fs_any = label_text_objects[0].get_fontsize()
353
+ ff_any = label_text_objects[0].get_fontfamily()
354
+ else:
355
+ fs_any = plt.rcParams.get("font.size")
356
+ ff_any = plt.rcParams.get("font.family")
357
+ print(f"Effective font size (labels/ticks): {fs_any}")
358
+ print(f"Font family chain (rcParams['font.sans-serif']): {plt.rcParams.get('font.sans-serif')}")
359
+ print(f"Mathtext fontset: {plt.rcParams.get('mathtext.fontset')}")
360
+
361
+ # Rotation angle
362
+ rotation_angle = getattr(ax, '_rotation_angle', 0)
363
+ if rotation_angle != 0:
364
+ print(f"Rotation angle (ro): {rotation_angle}°")
365
+
366
+ # Per-side matrix summary (spine, major, minor, labels, title)
367
+ def _onoff(v):
368
+ return 'ON ' if bool(v) else 'off'
369
+ def _label_visible(axis_obj, primary: bool) -> bool:
370
+ try:
371
+ if primary:
372
+ return bool(axis_obj.label.get_visible())
373
+ return bool(axis_obj)
374
+ except Exception:
375
+ return bool(axis_obj)
376
+
377
+ sides = (
378
+ ('bottom',
379
+ ax.spines.get('bottom').get_visible() if ax.spines.get('bottom') else False,
380
+ tick_state.get('b_ticks', tick_state.get('bx', True)),
381
+ tick_state.get('mbx', False),
382
+ tick_state.get('b_labels', tick_state.get('bx', True)),
383
+ bool(ax.xaxis.label.get_visible())),
384
+ ('top',
385
+ ax.spines.get('top').get_visible() if ax.spines.get('top') else False,
386
+ tick_state.get('t_ticks', tick_state.get('tx', False)),
387
+ tick_state.get('mtx', False),
388
+ tick_state.get('t_labels', tick_state.get('tx', False)),
389
+ bool(getattr(ax, '_top_xlabel_on', False))),
390
+ ('left',
391
+ ax.spines.get('left').get_visible() if ax.spines.get('left') else False,
392
+ tick_state.get('l_ticks', tick_state.get('ly', True)),
393
+ tick_state.get('mly', False),
394
+ tick_state.get('l_labels', tick_state.get('ly', True)),
395
+ bool(ax.yaxis.label.get_visible())),
396
+ ('right',
397
+ ax.spines.get('right').get_visible() if ax.spines.get('right') else False,
398
+ tick_state.get('r_ticks', tick_state.get('ry', False)),
399
+ tick_state.get('mry', False),
400
+ tick_state.get('r_labels', tick_state.get('ry', False)),
401
+ bool(getattr(ax, '_right_ylabel_on', False))),
402
+ )
403
+ print("Per-side: spine, major, minor, labels, title")
404
+ for name, spine, mj, mn, lbl, title in sides:
405
+ print(f" {name:<6}: spine={_onoff(spine)} major={_onoff(mj)} minor={_onoff(mn)} labels={_onoff(lbl)} title={_onoff(title)}")
406
+
407
+ # Tick widths helper
408
+ def axis_tick_width(axis, which):
409
+ ticks = axis.get_major_ticks() if which == "major" else axis.get_minor_ticks()
410
+ for t in ticks:
411
+ line = t.tick1line
412
+ if line.get_visible():
413
+ return line.get_linewidth()
414
+ return None
415
+
416
+ x_major_w = axis_tick_width(ax.xaxis, "major")
417
+ x_minor_w = axis_tick_width(ax.xaxis, "minor")
418
+ y_major_w = axis_tick_width(ax.yaxis, "major")
419
+ y_minor_w = axis_tick_width(ax.yaxis, "minor")
420
+ print(
421
+ f"Tick widths (major/minor): X=({x_major_w}, {x_minor_w}) Y=({y_major_w}, {y_minor_w})"
422
+ )
423
+
424
+ # Spines
425
+ print("Spines:")
426
+ for name, spn in ax.spines.items():
427
+ print(
428
+ f" {name:<5} lw={spn.get_linewidth()} color={spn.get_edgecolor()} visible={spn.get_visible()}"
429
+ )
430
+
431
+ # Tick colors
432
+ try:
433
+ x_color = ax.xaxis.get_tick_params()['color'] if ax.xaxis.get_tick_params() else 'black'
434
+ y_color = ax.yaxis.get_tick_params()['color'] if ax.yaxis.get_tick_params() else 'black'
435
+ print(f"Tick colors: X={x_color} Y={y_color}")
436
+ except Exception:
437
+ pass
438
+
439
+ # Axis label colors
440
+ try:
441
+ x_label_color = ax.xaxis.label.get_color()
442
+ y_label_color = ax.yaxis.label.get_color()
443
+ print(f"Axis label colors: X={x_label_color} Y={y_label_color}")
444
+ except Exception:
445
+ pass
446
+
447
+ # Omit CIF/HKL details from compact style print
448
+
449
+ # Omit non-style global flags (mode/raw/autoscale/delta)
450
+
451
+ # Curve names visibility
452
+ names_visible = True
453
+ if label_text_objects and len(label_text_objects) > 0:
454
+ try:
455
+ names_visible = bool(label_text_objects[0].get_visible())
456
+ except Exception:
457
+ names_visible = True
458
+ print(f"Curve names (h): {'shown' if names_visible else 'hidden'}")
459
+
460
+ # Legend/label anchor summary
461
+ stack_label_at_bottom = getattr(fig, '_stack_label_at_bottom', False)
462
+ label_anchor_left = getattr(fig, '_label_anchor_left', False)
463
+ legend_pos = f"{'bottom' if stack_label_at_bottom else 'top'}-{'left' if label_anchor_left else 'right'}"
464
+ print(f"Curve label anchor: {legend_pos} (stack mode={getattr(args, 'stack', False)})")
465
+
466
+ # Curves
467
+ print("Lines (style):")
468
+ for i, ln in enumerate(ax.lines):
469
+ col_val = ln.get_color()
470
+ col_hex = _color_to_hex(col_val)
471
+ col_disp = f"{color_block(col_hex)} {col_hex}" if col_hex else str(col_val)
472
+ lw = ln.get_linewidth(); ls = ln.get_linestyle()
473
+ mk = ln.get_marker(); ms = ln.get_markersize(); a = ln.get_alpha()
474
+ base_label = labels[i] if i < len(labels) else ""
475
+ offset_val = offsets_list[i] if i < len(offsets_list) else 0.0
476
+ offset_str = f" offset={offset_val:.4g}" if offset_val != 0.0 else ""
477
+ print(f" {i+1:02d}: label='{base_label}' color={col_disp} lw={lw} ls={ls} marker={mk} ms={ms} alpha={a}{offset_str}")
478
+ palette_hist = getattr(fig, '_curve_palette_history', None)
479
+ if palette_hist:
480
+ print("Palette history:")
481
+ for entry in palette_hist:
482
+ palette_name = entry.get('palette', '')
483
+ idxs = entry.get('indices', [])
484
+ idx_str = ", ".join(str(i) for i in idxs)
485
+ print(f" {palette_name or 'unknown'} -> [{idx_str}]")
486
+ print("--- End diagnostics ---\n")
487
+
488
+
489
+ def export_style_config(
490
+ filename: str,
491
+ fig,
492
+ ax,
493
+ y_data_list: List[np.ndarray],
494
+ labels: List[str],
495
+ delta: float,
496
+ args,
497
+ tick_state: Dict[str, bool],
498
+ offsets_list: List[float],
499
+ cif_tick_series: Optional[List[tuple]] = None,
500
+ label_text_objects: Optional[List] = None,
501
+ base_path: Optional[str] = None,
502
+ show_cif_titles: Optional[bool] = None,
503
+ overwrite_path: Optional[str] = None,
504
+ ) -> Optional[str]:
505
+ """Export style configuration after displaying a summary and prompting the user.
506
+
507
+ This function now matches the EC menu workflow: display summary, then prompt for export.
508
+ """
509
+ try:
510
+ fw, fh = fig.get_size_inches()
511
+ sp = fig.subplotpars
512
+
513
+ def axis_tick_width(axis, which):
514
+ ticks = axis.get_major_ticks() if which == "major" else axis.get_minor_ticks()
515
+ for t in ticks:
516
+ line = t.tick1line
517
+ if line.get_visible():
518
+ return line.get_linewidth()
519
+ return None
520
+
521
+ spine_vis = {name: spn.get_visible() for name, spn in ax.spines.items()}
522
+
523
+ bbox = ax.get_position()
524
+ frame_w_in = bbox.width * fw
525
+ frame_h_in = bbox.height * fh
526
+
527
+ # Build WASD state (20 parameters: 4 sides × 5 properties each)
528
+ def _get_spine_visible(which: str) -> bool:
529
+ sp = ax.spines.get(which)
530
+ try:
531
+ return bool(sp.get_visible()) if sp is not None else False
532
+ except Exception:
533
+ return False
534
+
535
+ wasd_state = {
536
+ 'top': {
537
+ 'spine': _get_spine_visible('top'),
538
+ 'ticks': bool(tick_state.get('t_ticks', tick_state.get('tx', False))),
539
+ 'minor': bool(tick_state.get('mtx', False)),
540
+ 'labels': bool(tick_state.get('t_labels', tick_state.get('tx', False))),
541
+ 'title': bool(getattr(ax, '_top_xlabel_on', False))
542
+ },
543
+ 'bottom': {
544
+ 'spine': _get_spine_visible('bottom'),
545
+ 'ticks': bool(tick_state.get('b_ticks', tick_state.get('bx', True))),
546
+ 'minor': bool(tick_state.get('mbx', False)),
547
+ 'labels': bool(tick_state.get('b_labels', tick_state.get('bx', True))),
548
+ 'title': bool(ax.xaxis.label.get_visible())
549
+ },
550
+ 'left': {
551
+ 'spine': _get_spine_visible('left'),
552
+ 'ticks': bool(tick_state.get('l_ticks', tick_state.get('ly', True))),
553
+ 'minor': bool(tick_state.get('mly', False)),
554
+ 'labels': bool(tick_state.get('l_labels', tick_state.get('ly', True))),
555
+ 'title': bool(ax.yaxis.label.get_visible())
556
+ },
557
+ 'right': {
558
+ 'spine': _get_spine_visible('right'),
559
+ 'ticks': bool(tick_state.get('r_ticks', tick_state.get('ry', False))),
560
+ 'minor': bool(tick_state.get('mry', False)),
561
+ 'labels': bool(tick_state.get('r_labels', tick_state.get('ry', False))),
562
+ 'title': bool(getattr(ax, '_right_ylabel_on', False))
563
+ },
564
+ }
565
+
566
+ cfg = {
567
+ "figure": {
568
+ "size": [fw, fh],
569
+ "dpi": fig.dpi,
570
+ "frame_size": [frame_w_in, frame_h_in],
571
+ "axes_fraction": [bbox.x0, bbox.y0, bbox.width, bbox.height],
572
+ },
573
+ "margins": {
574
+ "left": sp.left,
575
+ "right": sp.right,
576
+ "bottom": sp.bottom,
577
+ "top": sp.top,
578
+ },
579
+ "font": {
580
+ "size": plt.rcParams.get("font.size"),
581
+ "family_chain": plt.rcParams.get("font.sans-serif"),
582
+ },
583
+ "ticks": {
584
+ "x_major_width": axis_tick_width(ax.xaxis, "major"),
585
+ "x_minor_width": axis_tick_width(ax.xaxis, "minor"),
586
+ "y_major_width": axis_tick_width(ax.yaxis, "major"),
587
+ "y_minor_width": axis_tick_width(ax.yaxis, "minor"),
588
+ },
589
+ "wasd_state": wasd_state,
590
+ "spines": {
591
+ name: {
592
+ "linewidth": spn.get_linewidth(),
593
+ "color": spn.get_edgecolor(),
594
+ "visible": spine_vis.get(name, True),
595
+ }
596
+ for name, spn in ax.spines.items()
597
+ },
598
+ "tick_colors": {
599
+ "x": _color_to_hex((ax.xaxis.get_tick_params() or {}).get('color', 'black')),
600
+ "y": _color_to_hex((ax.yaxis.get_tick_params() or {}).get('color', 'black')),
601
+ },
602
+ "axis_label_colors": {
603
+ "x": _color_to_hex(ax.xaxis.label.get_color()),
604
+ "y": _color_to_hex(ax.yaxis.label.get_color()),
605
+ },
606
+ "grid": ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else False,
607
+ "lines": [
608
+ {
609
+ "index": i,
610
+ # label text is not a style item (handled by 'r'), don't export it
611
+ "color": _color_to_hex(ln.get_color()),
612
+ "linewidth": ln.get_linewidth(),
613
+ "linestyle": ln.get_linestyle(),
614
+ "marker": ln.get_marker(),
615
+ "markersize": ln.get_markersize(),
616
+ "markerfacecolor": _color_to_hex(ln.get_markerfacecolor()),
617
+ "markeredgecolor": _color_to_hex(ln.get_markeredgecolor()),
618
+ "alpha": ln.get_alpha(),
619
+ "offset": offsets_list[i] if i < len(offsets_list) else 0.0,
620
+ }
621
+ for i, ln in enumerate(ax.lines)
622
+ ],
623
+ }
624
+ bottom_label_text = _get_primary_axis_text(ax, 'x')
625
+ left_label_text = _get_primary_axis_text(ax, 'y')
626
+ axis_title_texts = {
627
+ "top_x": _get_duplicate_axis_text(ax, '_top_xlabel_artist', bottom_label_text),
628
+ "bottom_x": bottom_label_text,
629
+ "left_y": left_label_text,
630
+ "right_y": _get_duplicate_axis_text(ax, '_right_ylabel_artist', left_label_text),
631
+ }
632
+ cfg["axis_titles"] = {
633
+ "top_x": bool(getattr(ax, "_top_xlabel_on", False)),
634
+ "right_y": bool(getattr(ax, "_right_ylabel_on", False)),
635
+ "has_bottom_x": bool(ax.xaxis.label.get_visible()),
636
+ "has_left_y": bool(ax.yaxis.label.get_visible()),
637
+ }
638
+ cfg["axis_title_texts"] = axis_title_texts
639
+ cfg["title_offsets"] = {
640
+ "top_y": float(getattr(ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0),
641
+ "top_x": float(getattr(ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0),
642
+ "bottom_y": float(getattr(ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0),
643
+ "left_x": float(getattr(ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0),
644
+ "right_x": float(getattr(ax, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0),
645
+ "right_y": float(getattr(ax, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0),
646
+ }
647
+ # Save rotation angle
648
+ cfg["rotation_angle"] = getattr(ax, '_rotation_angle', 0)
649
+
650
+ # Save curve names visibility
651
+ cfg["curve_names_visible"] = True # Default to visible
652
+ if label_text_objects and len(label_text_objects) > 0:
653
+ try:
654
+ cfg["curve_names_visible"] = bool(label_text_objects[0].get_visible())
655
+ except Exception:
656
+ pass
657
+
658
+ # Save stack/legend anchor preferences
659
+ cfg["stack_label_at_bottom"] = getattr(fig, '_stack_label_at_bottom', False)
660
+ cfg["label_anchor_left"] = getattr(fig, '_label_anchor_left', False)
661
+ # Save CIF title visibility
662
+ if show_cif_titles is not None:
663
+ cfg["show_cif_titles"] = bool(show_cif_titles)
664
+ if cif_tick_series:
665
+ cfg["cif_ticks"] = [
666
+ {"index": i, "color": color}
667
+ for i, (lab, fname, peaksQ, wl, qmax_sim, color) in enumerate(cif_tick_series)
668
+ ]
669
+ palette_history = getattr(fig, '_curve_palette_history', None)
670
+ if palette_history:
671
+ serialized_palettes = []
672
+ for entry in palette_history:
673
+ palette_name = entry.get('palette')
674
+ indices = entry.get('indices', [])
675
+ if not palette_name or not indices:
676
+ continue
677
+ serialized_palettes.append({
678
+ 'palette': palette_name,
679
+ 'indices': list(indices),
680
+ 'low_clip': float(entry.get('low_clip', 0.08)),
681
+ 'high_clip': float(entry.get('high_clip', 0.85)),
682
+ })
683
+ if serialized_palettes:
684
+ cfg['curve_palettes'] = serialized_palettes
685
+
686
+ # If overwrite_path is provided, determine export type from existing file
687
+ if overwrite_path:
688
+ try:
689
+ with open(overwrite_path, 'r', encoding='utf-8') as f:
690
+ old_cfg = json.load(f)
691
+ old_kind = old_cfg.get('kind', '')
692
+ if old_kind == 'xy_style_geom':
693
+ exp_choice = 'psg'
694
+ else:
695
+ exp_choice = 'ps'
696
+ except Exception:
697
+ exp_choice = 'ps' # Default to style-only if can't read
698
+ else:
699
+ # Ask user for style-only or style+geometry
700
+ print("\nExport options:")
701
+ print(" ps = style only (.bps)")
702
+ print(" psg = style + geometry (.bpsg)")
703
+ exp_choice = input("Export choice (ps/psg, q=cancel): ").strip().lower()
704
+ if not exp_choice or exp_choice == 'q':
705
+ print("Style export canceled.")
706
+ return None
707
+
708
+ # Determine file extension and add geometry if requested
709
+ if exp_choice == 'ps':
710
+ cfg['kind'] = 'xy_style'
711
+ default_ext = '.bps'
712
+ elif exp_choice == 'psg':
713
+ cfg['kind'] = 'xy_style_geom'
714
+ # Add geometry information
715
+ cfg['geometry'] = {
716
+ 'xlabel': ax.get_xlabel() or '',
717
+ 'ylabel': ax.get_ylabel() or '',
718
+ 'xlim': list(ax.get_xlim()),
719
+ 'ylim': list(ax.get_ylim()),
720
+ # Store the x/y ranges that the current data was normalized to
721
+ 'norm_xlim': list(getattr(ax, '_norm_xlim', ax.get_xlim())),
722
+ 'norm_ylim': list(getattr(ax, '_norm_ylim', ax.get_ylim())),
723
+ }
724
+ default_ext = '.bpsg'
725
+ else:
726
+ print(f"Unknown option: {exp_choice}")
727
+ return
728
+
729
+ # If overwrite_path is provided, use it directly
730
+ if overwrite_path:
731
+ target_path = overwrite_path
732
+ else:
733
+ # List existing files for user convenience (from Styles subdirectory)
734
+ import os
735
+ from .utils import list_files_in_subdirectory, get_organized_path
736
+
737
+ if base_path:
738
+ print(f"\nChosen path: {base_path}")
739
+ file_list = list_files_in_subdirectory((default_ext, '.bpcfg'), 'style', base_path=base_path)
740
+ style_files = [f[0] for f in file_list]
741
+
742
+ if style_files:
743
+ styles_root = base_path if base_path else os.getcwd()
744
+ styles_dir = os.path.join(styles_root, 'Styles')
745
+ print(f"\nExisting {default_ext} files in {styles_dir}:")
746
+ for i, f in enumerate(style_files, 1):
747
+ print(f" {i}: {f}")
748
+
749
+ last_style_path = getattr(fig, '_last_style_export_path', None)
750
+ if last_style_path:
751
+ choice = input("Export to file? Enter filename, number to overwrite, or o to overwrite last (q=cancel): ").strip()
752
+ else:
753
+ choice = input("Export to file? Enter filename or number to overwrite (q=cancel): ").strip()
754
+ if not choice or choice.lower() == 'q':
755
+ print("Style export canceled.")
756
+ return None
757
+ if choice.lower() == 'o':
758
+ # Overwrite last exported style file - handled by caller
759
+ if not last_style_path:
760
+ print("No previous export found.")
761
+ return None
762
+ if not os.path.exists(last_style_path):
763
+ print(f"Previous export file not found: {last_style_path}")
764
+ return None
765
+ target_path = last_style_path
766
+ else:
767
+ # Determine the target path
768
+ if choice.isdigit() and style_files and 1 <= int(choice) <= len(style_files):
769
+ target_path = file_list[int(choice) - 1][1] # Full path from list
770
+ else:
771
+ # Add default extension if no extension provided
772
+ if not any(choice.lower().endswith(ext) for ext in ['.bps', '.bpsg', '.bpcfg']):
773
+ filename_with_ext = f"{choice}{default_ext}"
774
+ else:
775
+ filename_with_ext = choice
776
+
777
+ # Use organized path unless it's an absolute path
778
+ if os.path.isabs(filename_with_ext):
779
+ target_path = filename_with_ext
780
+ else:
781
+ target_path = get_organized_path(filename_with_ext, 'style', base_path=base_path)
782
+
783
+ # Only prompt ONCE for overwrite if the file exists
784
+ if os.path.exists(target_path):
785
+ yn = input(f"Overwrite '{os.path.basename(target_path)}'? (y/n): ").strip().lower()
786
+ if yn != 'y':
787
+ print("Style export canceled.")
788
+ return None
789
+
790
+ # Ensure exact case is preserved (important for macOS case-insensitive filesystem)
791
+ from .utils import ensure_exact_case_filename
792
+ target_path = ensure_exact_case_filename(target_path)
793
+
794
+ with open(target_path, "w", encoding="utf-8") as f:
795
+ json.dump(cfg, f, indent=2)
796
+ print(f"Exported style to {target_path}")
797
+ return target_path
798
+ except Exception as e:
799
+ print(f"Error exporting style: {e}")
800
+ return None
801
+
802
+
803
+ def apply_style_config(
804
+ filename: str,
805
+ fig,
806
+ ax,
807
+ x_data_list: List[np.ndarray] | None,
808
+ y_data_list: List[np.ndarray],
809
+ orig_y: List[np.ndarray] | None,
810
+ offsets_list: List[float] | None,
811
+ label_text_objects: List,
812
+ args,
813
+ tick_state: Dict[str, bool],
814
+ labels: List[str],
815
+ update_labels_func: Callable[[Any, List[np.ndarray], List, bool], None],
816
+ cif_tick_series: Optional[List[tuple]] = None,
817
+ cif_hkl_label_map: Optional[Dict[str, Dict[float, str]]] = None,
818
+ adjust_margins_cb: Optional[Callable[[], None]] = None,
819
+ keep_canvas_fixed: bool = False,
820
+ ) -> None:
821
+ def _apply_spine_color(spine_name: str, color) -> None:
822
+ if color is None:
823
+ return
824
+ try:
825
+ if spine_name in ('top', 'bottom'):
826
+ ax.tick_params(axis='x', which='both', colors=color)
827
+ ax.xaxis.label.set_color(color)
828
+ ax._stored_xlabel_color = color
829
+ if spine_name == 'top':
830
+ ax._stored_top_xlabel_color = color
831
+ artist = getattr(ax, '_top_xlabel_artist', None)
832
+ if artist is not None:
833
+ artist.set_color(color)
834
+ else:
835
+ ax._stored_xlabel = ax.get_xlabel()
836
+ else:
837
+ ax.tick_params(axis='y', which='both', colors=color)
838
+ ax.yaxis.label.set_color(color)
839
+ ax._stored_ylabel_color = color
840
+ if spine_name == 'right':
841
+ ax._stored_right_ylabel_color = color
842
+ artist = getattr(ax, '_right_ylabel_artist', None)
843
+ if artist is not None:
844
+ artist.set_color(color)
845
+ else:
846
+ ax._stored_ylabel = ax.get_ylabel()
847
+ except Exception:
848
+ pass
849
+
850
+ try:
851
+ with open(filename, "r", encoding="utf-8") as f:
852
+ cfg = json.load(f)
853
+ except Exception as e:
854
+ print(f"Could not read config: {e}")
855
+ return
856
+ # Save current labelpad values BEFORE any style changes
857
+ saved_xlabelpad = None
858
+ saved_ylabelpad = None
859
+ try:
860
+ saved_xlabelpad = getattr(ax.xaxis, 'labelpad', None)
861
+ except Exception:
862
+ pass
863
+ try:
864
+ saved_ylabelpad = getattr(ax.yaxis, 'labelpad', None)
865
+ except Exception:
866
+ pass
867
+ try:
868
+ figure_cfg = cfg.get("figure", {})
869
+ # Get axes_fraction BEFORE changing canvas size (to preserve exact position)
870
+ axes_frac = figure_cfg.get("axes_fraction")
871
+ frame_size = figure_cfg.get("frame_size")
872
+
873
+ sz = figure_cfg.get("canvas_size") or figure_cfg.get("size")
874
+ if isinstance(sz, (list, tuple)) and len(sz) == 2:
875
+ try:
876
+ fw = float(sz[0])
877
+ fh = float(sz[1])
878
+ if not keep_canvas_fixed:
879
+ # Use forward=False to prevent automatic subplot adjustment that can shift the plot
880
+ fig.set_size_inches(fw, fh, forward=False)
881
+ else:
882
+ print("(Canvas fixed) Ignoring style figure size request.")
883
+ except Exception as e:
884
+ print(f"Warning: could not parse figure size: {e}")
885
+ try:
886
+ if axes_frac and isinstance(axes_frac, (list, tuple)) and len(axes_frac) == 4:
887
+ x0, y0, w, h = axes_frac
888
+ left = float(x0)
889
+ bottom = float(y0)
890
+ right = left + float(w)
891
+ top = bottom + float(h)
892
+ if 0 < left < right <= 1 and 0 < bottom < top <= 1:
893
+ fig.subplots_adjust(left=left, right=right, bottom=bottom, top=top)
894
+ elif frame_size and isinstance(frame_size, (list, tuple)) and len(frame_size) == 2:
895
+ cur_fw, cur_fh = fig.get_size_inches()
896
+ des_w, des_h = float(frame_size[0]), float(frame_size[1])
897
+ min_margin = 0.05
898
+ w_frac = min(des_w / cur_fw, 1 - 2 * min_margin)
899
+ h_frac = min(des_h / cur_fh, 1 - 2 * min_margin)
900
+ left = (1 - w_frac) / 2
901
+ bottom = (1 - h_frac) / 2
902
+ fig.subplots_adjust(left=left, right=left + w_frac, bottom=bottom, top=bottom + h_frac)
903
+ except Exception as e:
904
+ print(f"[DEBUG] Exception in frame/axes fraction adjustment: {e}")
905
+ # Don't restore DPI from style - use system default to avoid display-dependent issues
906
+ # (Retina displays, Windows scaling, etc. can cause saved DPI to differ)
907
+
908
+ # Font
909
+ font_cfg = cfg.get("font", {})
910
+ fam_chain = font_cfg.get("family_chain")
911
+ if not fam_chain:
912
+ # Accept legacy/simple form: { "family": "Arial" }
913
+ fam = font_cfg.get("family")
914
+ if isinstance(fam, str) and fam.strip():
915
+ fam_chain = [fam.strip(), 'DejaVu Sans', 'Arial', 'Helvetica']
916
+ size_val = font_cfg.get("size")
917
+ if fam_chain:
918
+ plt.rcParams["font.family"] = "sans-serif"
919
+ plt.rcParams["font.sans-serif"] = fam_chain
920
+ numeric_size = None
921
+ if size_val is not None:
922
+ try:
923
+ numeric_size = float(size_val)
924
+ plt.rcParams["font.size"] = numeric_size
925
+ except Exception as e:
926
+ print(f"[DEBUG] Exception parsing font size: {e}")
927
+ numeric_size = None
928
+
929
+ # Do not change axis labels or limits in Styles import
930
+
931
+ # Apply font changes to existing text objects
932
+ if fam_chain or numeric_size is not None:
933
+ for txt in label_text_objects:
934
+ if numeric_size is not None:
935
+ txt.set_fontsize(numeric_size)
936
+ if fam_chain:
937
+ txt.set_fontfamily(fam_chain[0])
938
+ for axis_label in (ax.xaxis.label, ax.yaxis.label):
939
+ if numeric_size is not None:
940
+ axis_label.set_fontsize(numeric_size)
941
+ if fam_chain:
942
+ axis_label.set_fontfamily(fam_chain[0])
943
+ for lbl in ax.get_xticklabels() + ax.get_yticklabels():
944
+ if numeric_size is not None:
945
+ lbl.set_fontsize(numeric_size)
946
+ if fam_chain:
947
+ lbl.set_fontfamily(fam_chain[0])
948
+ # Also update top/right tick labels (label2)
949
+ try:
950
+ for t in ax.xaxis.get_major_ticks():
951
+ if hasattr(t, 'label2'):
952
+ if numeric_size is not None:
953
+ t.label2.set_fontsize(numeric_size)
954
+ if fam_chain:
955
+ t.label2.set_fontfamily(fam_chain[0])
956
+ for t in ax.yaxis.get_major_ticks():
957
+ if hasattr(t, 'label2'):
958
+ if numeric_size is not None:
959
+ t.label2.set_fontsize(numeric_size)
960
+ if fam_chain:
961
+ t.label2.set_fontfamily(fam_chain[0])
962
+ except Exception:
963
+ pass
964
+ # Also update duplicate top/right artists if they exist
965
+ try:
966
+ art = getattr(ax, '_top_xlabel_artist', None)
967
+ if art is not None:
968
+ if numeric_size is not None:
969
+ art.set_fontsize(numeric_size)
970
+ if fam_chain:
971
+ art.set_fontfamily(fam_chain[0])
972
+ except Exception:
973
+ pass
974
+ try:
975
+ art = getattr(ax, '_right_ylabel_artist', None)
976
+ if art is not None:
977
+ if numeric_size is not None:
978
+ art.set_fontsize(numeric_size)
979
+ if fam_chain:
980
+ art.set_fontfamily(fam_chain[0])
981
+ except Exception:
982
+ pass
983
+
984
+ # Tick visibility + widths
985
+ ticks_cfg = cfg.get("ticks", {})
986
+
987
+ # Try wasd_state first (version 2), fall back to visibility dict (version 1)
988
+ wasd = cfg.get("wasd_state", {})
989
+ if wasd:
990
+ # Apply WASD state (20 parameters)
991
+ try:
992
+ # Apply spines from wasd
993
+ for side in ('top', 'bottom', 'left', 'right'):
994
+ side_cfg = wasd.get(side, {})
995
+ if 'spine' in side_cfg and side in ax.spines:
996
+ ax.spines[side].set_visible(bool(side_cfg['spine']))
997
+
998
+ # Apply ticks and labels
999
+ top_cfg = wasd.get('top', {})
1000
+ bot_cfg = wasd.get('bottom', {})
1001
+ left_cfg = wasd.get('left', {})
1002
+ right_cfg = wasd.get('right', {})
1003
+
1004
+ ax.tick_params(axis='x',
1005
+ top=bool(top_cfg.get('ticks', False)),
1006
+ bottom=bool(bot_cfg.get('ticks', True)),
1007
+ labeltop=bool(top_cfg.get('labels', False)),
1008
+ labelbottom=bool(bot_cfg.get('labels', True)))
1009
+ ax.tick_params(axis='y',
1010
+ left=bool(left_cfg.get('ticks', True)),
1011
+ right=bool(right_cfg.get('ticks', False)),
1012
+ labelleft=bool(left_cfg.get('labels', True)),
1013
+ labelright=bool(right_cfg.get('labels', False)))
1014
+
1015
+ # Apply minor ticks
1016
+ if top_cfg.get('minor') or bot_cfg.get('minor'):
1017
+ from matplotlib.ticker import AutoMinorLocator, NullFormatter
1018
+ ax.xaxis.set_minor_locator(AutoMinorLocator())
1019
+ ax.xaxis.set_minor_formatter(NullFormatter())
1020
+ ax.tick_params(axis='x', which='minor',
1021
+ top=bool(top_cfg.get('minor', False)),
1022
+ bottom=bool(bot_cfg.get('minor', False)),
1023
+ labeltop=False, labelbottom=False)
1024
+
1025
+ if left_cfg.get('minor') or right_cfg.get('minor'):
1026
+ from matplotlib.ticker import AutoMinorLocator, NullFormatter
1027
+ ax.yaxis.set_minor_locator(AutoMinorLocator())
1028
+ ax.yaxis.set_minor_formatter(NullFormatter())
1029
+ ax.tick_params(axis='y', which='minor',
1030
+ left=bool(left_cfg.get('minor', False)),
1031
+ right=bool(right_cfg.get('minor', False)),
1032
+ labelleft=False, labelright=False)
1033
+
1034
+ # Apply titles
1035
+ ax._top_xlabel_on = bool(top_cfg.get('title', False))
1036
+ ax._right_ylabel_on = bool(right_cfg.get('title', False))
1037
+
1038
+ # Update tick_state for consistency
1039
+ tick_state['t_ticks'] = bool(top_cfg.get('ticks', False))
1040
+ tick_state['t_labels'] = bool(top_cfg.get('labels', False))
1041
+ tick_state['b_ticks'] = bool(bot_cfg.get('ticks', True))
1042
+ tick_state['b_labels'] = bool(bot_cfg.get('labels', True))
1043
+ tick_state['l_ticks'] = bool(left_cfg.get('ticks', True))
1044
+ tick_state['l_labels'] = bool(left_cfg.get('labels', True))
1045
+ tick_state['r_ticks'] = bool(right_cfg.get('ticks', False))
1046
+ tick_state['r_labels'] = bool(right_cfg.get('labels', False))
1047
+ tick_state['mtx'] = bool(top_cfg.get('minor', False))
1048
+ tick_state['mbx'] = bool(bot_cfg.get('minor', False))
1049
+ tick_state['mly'] = bool(left_cfg.get('minor', False))
1050
+ tick_state['mry'] = bool(right_cfg.get('minor', False))
1051
+
1052
+ except Exception as e:
1053
+ print(f"Warning: Could not apply WASD tick visibility: {e}")
1054
+ else:
1055
+ # Fall back to old visibility dict
1056
+ vis_cfg = ticks_cfg.get("visibility", {})
1057
+ changed_visibility = False
1058
+ for k, v in vis_cfg.items():
1059
+ if k in tick_state and isinstance(v, bool):
1060
+ tick_state[k] = v
1061
+ changed_visibility = True
1062
+ if changed_visibility:
1063
+ try:
1064
+ _ui_update_tick_visibility(ax, tick_state)
1065
+ except Exception as e:
1066
+ print(f"[DEBUG] Exception updating tick visibility: {e}")
1067
+
1068
+
1069
+ xmaj = ticks_cfg.get("x_major_width")
1070
+ xminr = ticks_cfg.get("x_minor_width")
1071
+ ymaj = ticks_cfg.get("y_major_width")
1072
+ yminr = ticks_cfg.get("y_minor_width")
1073
+ if any(v is not None for v in (xmaj, xminr, ymaj, yminr)):
1074
+ try:
1075
+ if xmaj is not None:
1076
+ ax.tick_params(axis="x", which="major", width=xmaj)
1077
+ if xminr is not None:
1078
+ ax.tick_params(axis="x", which="minor", width=xminr)
1079
+ if ymaj is not None:
1080
+ ax.tick_params(axis="y", which="major", width=ymaj)
1081
+ if yminr is not None:
1082
+ ax.tick_params(axis="y", which="minor", width=yminr)
1083
+ except Exception as e:
1084
+ print(f"[DEBUG] Exception setting tick widths: {e}")
1085
+
1086
+ # Spines
1087
+ for name, sp_dict in cfg.get("spines", {}).items():
1088
+ if name in ax.spines:
1089
+ if "linewidth" in sp_dict:
1090
+ ax.spines[name].set_linewidth(sp_dict["linewidth"])
1091
+ if "color" in sp_dict:
1092
+ try:
1093
+ ax.spines[name].set_edgecolor(sp_dict["color"])
1094
+ except Exception:
1095
+ pass
1096
+ _apply_spine_color(name, sp_dict.get("color"))
1097
+ if "visible" in sp_dict:
1098
+ ax.spines[name].set_visible(sp_dict["visible"])
1099
+
1100
+ # Tick colors
1101
+ tick_colors = cfg.get("tick_colors", {})
1102
+ if tick_colors:
1103
+ try:
1104
+ if "x" in tick_colors:
1105
+ ax.tick_params(axis='x', which='both', colors=tick_colors["x"])
1106
+ if "y" in tick_colors:
1107
+ ax.tick_params(axis='y', which='both', colors=tick_colors["y"])
1108
+ except Exception as e:
1109
+ print(f"[DEBUG] Exception setting tick colors: {e}")
1110
+
1111
+ # Axis label colors
1112
+ axis_label_colors = cfg.get("axis_label_colors", {})
1113
+ if axis_label_colors:
1114
+ try:
1115
+ if "x" in axis_label_colors:
1116
+ ax.xaxis.label.set_color(axis_label_colors["x"])
1117
+ if "y" in axis_label_colors:
1118
+ ax.yaxis.label.set_color(axis_label_colors["y"])
1119
+ except Exception as e:
1120
+ print(f"[DEBUG] Exception setting axis label colors: {e}")
1121
+
1122
+ # Lines
1123
+ for entry in cfg.get("lines", []):
1124
+ idx = entry.get("index")
1125
+ if idx is None or not (0 <= idx < len(ax.lines)):
1126
+ continue
1127
+ ln = ax.lines[idx]
1128
+ if "color" in entry and entry["color"] is not None:
1129
+ ln.set_color(entry["color"])
1130
+ if "linewidth" in entry:
1131
+ ln.set_linewidth(entry["linewidth"])
1132
+ if "linestyle" in entry:
1133
+ try:
1134
+ ln.set_linestyle(entry["linestyle"])
1135
+ except Exception:
1136
+ pass
1137
+ if "marker" in entry:
1138
+ try:
1139
+ ln.set_marker(entry["marker"])
1140
+ except Exception:
1141
+ pass
1142
+ if "markersize" in entry:
1143
+ try:
1144
+ ln.set_markersize(entry["markersize"])
1145
+ except Exception:
1146
+ pass
1147
+ if "markerfacecolor" in entry and entry["markerfacecolor"] is not None:
1148
+ try:
1149
+ ln.set_markerfacecolor(entry["markerfacecolor"])
1150
+ except Exception:
1151
+ pass
1152
+ if "markeredgecolor" in entry and entry["markeredgecolor"] is not None:
1153
+ try:
1154
+ ln.set_markeredgecolor(entry["markeredgecolor"])
1155
+ except Exception:
1156
+ pass
1157
+ if "alpha" in entry and entry["alpha"] is not None:
1158
+ try:
1159
+ ln.set_alpha(entry["alpha"])
1160
+ except Exception:
1161
+ pass
1162
+ # Restore offset if available
1163
+ if "offset" in entry and offsets_list is not None and orig_y is not None and x_data_list is not None:
1164
+ try:
1165
+ offset_val = float(entry["offset"])
1166
+ if idx < len(offsets_list):
1167
+ offsets_list[idx] = offset_val
1168
+ # Reapply offset to the curve
1169
+ if idx < len(orig_y) and idx < len(y_data_list) and idx < len(x_data_list):
1170
+ y_norm = orig_y[idx]
1171
+ y_with_offset = y_norm + offset_val
1172
+ y_data_list[idx] = y_with_offset
1173
+ ln.set_data(x_data_list[idx], y_with_offset)
1174
+ except Exception as e:
1175
+ print(f"Warning: Could not restore offset for curve {idx+1}: {e}")
1176
+ palette_cfg = cfg.get("curve_palettes", [])
1177
+ if palette_cfg:
1178
+ sanitized_history = []
1179
+ for rec in palette_cfg:
1180
+ if _apply_curve_palette(ax, rec):
1181
+ sanitized_history.append({
1182
+ 'palette': rec.get('palette'),
1183
+ 'indices': list(rec.get('indices', [])),
1184
+ 'low_clip': float(rec.get('low_clip', 0.08)),
1185
+ 'high_clip': float(rec.get('high_clip', 0.85)),
1186
+ })
1187
+ if sanitized_history:
1188
+ fig._curve_palette_history = sanitized_history
1189
+ elif hasattr(fig, '_curve_palette_history'):
1190
+ delattr(fig, '_curve_palette_history')
1191
+ else:
1192
+ if hasattr(fig, '_curve_palette_history'):
1193
+ delattr(fig, '_curve_palette_history')
1194
+ # CIF tick sets (labels & colors)
1195
+ cif_cfg = cfg.get("cif_ticks", [])
1196
+ if cif_cfg and cif_tick_series is not None:
1197
+ for entry in cif_cfg:
1198
+ idx = entry.get("index")
1199
+ if idx is None:
1200
+ continue
1201
+ if 0 <= idx < len(cif_tick_series):
1202
+ lab, fname, peaksQ, wl, qmax_sim, color_old = cif_tick_series[idx]
1203
+ lab_new = entry.get("label", lab)
1204
+ color_new = entry.get("color", color_old)
1205
+ cif_tick_series[idx] = (lab_new, fname, peaksQ, wl, qmax_sim, color_new)
1206
+ # Restore CIF title visibility
1207
+ if "show_cif_titles" in cfg:
1208
+ try:
1209
+ _bp_module = sys.modules.get('__main__')
1210
+ if _bp_module is not None:
1211
+ setattr(_bp_module, 'show_cif_titles', bool(cfg["show_cif_titles"]))
1212
+ except Exception:
1213
+ pass
1214
+ # Redraw CIF ticks after applying changes
1215
+ if (cif_cfg and cif_tick_series is not None) or "show_cif_titles" in cfg:
1216
+ if hasattr(ax, "_cif_draw_func"):
1217
+ try:
1218
+ ax._cif_draw_func()
1219
+ except Exception:
1220
+ pass
1221
+
1222
+ # Restore curve names visibility
1223
+ if "curve_names_visible" in cfg:
1224
+ try:
1225
+ visible = bool(cfg["curve_names_visible"])
1226
+ for txt in label_text_objects:
1227
+ txt.set_visible(visible)
1228
+ # Store on figure for persistence
1229
+ fig._curve_names_visible = visible
1230
+ except Exception as e:
1231
+ print(f"Warning: Could not restore curve names visibility: {e}")
1232
+
1233
+ # Restore stack/legend anchor preferences
1234
+ if "stack_label_at_bottom" in cfg:
1235
+ try:
1236
+ fig._stack_label_at_bottom = bool(cfg["stack_label_at_bottom"])
1237
+ except Exception as e:
1238
+ print(f"Warning: Could not restore stack label position: {e}")
1239
+ if "label_anchor_left" in cfg:
1240
+ try:
1241
+ fig._label_anchor_left = bool(cfg["label_anchor_left"])
1242
+ except Exception as e:
1243
+ print(f"Warning: Could not restore legend horizontal anchor: {e}")
1244
+
1245
+ # Restore rotation angle
1246
+ if "rotation_angle" in cfg:
1247
+ try:
1248
+ ax._rotation_angle = int(cfg["rotation_angle"])
1249
+ except Exception as e:
1250
+ print(f"Warning: Could not restore rotation angle: {e}")
1251
+
1252
+ # Restore title offsets BEFORE positioning titles
1253
+ title_offsets = cfg.get("title_offsets", {})
1254
+ if title_offsets:
1255
+ try:
1256
+ if 'top_y' in title_offsets:
1257
+ ax._top_xlabel_manual_offset_y_pts = float(title_offsets.get('top_y', 0.0) or 0.0)
1258
+ else:
1259
+ # Backward compatibility: old format used 'top' for y-offset
1260
+ ax._top_xlabel_manual_offset_y_pts = float(title_offsets.get('top', 0.0) or 0.0)
1261
+ ax._top_xlabel_manual_offset_x_pts = float(title_offsets.get('top_x', 0.0) or 0.0)
1262
+ ax._bottom_xlabel_manual_offset_y_pts = float(title_offsets.get('bottom_y', 0.0) or 0.0)
1263
+ ax._left_ylabel_manual_offset_x_pts = float(title_offsets.get('left_x', 0.0) or 0.0)
1264
+ if 'right_x' in title_offsets:
1265
+ ax._right_ylabel_manual_offset_x_pts = float(title_offsets.get('right_x', 0.0) or 0.0)
1266
+ else:
1267
+ # Backward compatibility: old format used 'right' for x-offset
1268
+ ax._right_ylabel_manual_offset_x_pts = float(title_offsets.get('right', 0.0) or 0.0)
1269
+ ax._right_ylabel_manual_offset_y_pts = float(title_offsets.get('right_y', 0.0) or 0.0)
1270
+ except Exception as e:
1271
+ print(f"Warning: Could not restore title offsets: {e}")
1272
+
1273
+ # Restore grid state
1274
+ if "grid" in cfg:
1275
+ try:
1276
+ if bool(cfg["grid"]):
1277
+ ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
1278
+ else:
1279
+ ax.grid(False)
1280
+ except Exception as e:
1281
+ print(f"Warning: Could not restore grid state: {e}")
1282
+
1283
+ # Re-run label placement with current mode (no mode changes via Styles)
1284
+ stack_label_bottom = getattr(fig, '_stack_label_at_bottom', False)
1285
+ update_labels_func(ax, y_data_list, label_text_objects, args.stack, stack_label_bottom)
1286
+
1287
+ # Margin / overflow handling
1288
+ try:
1289
+ overflow = _ui_ensure_text_visibility(fig, ax, label_text_objects, check_only=True)
1290
+ except Exception:
1291
+ overflow = False
1292
+ if overflow and adjust_margins_cb is not None:
1293
+ try:
1294
+ adjust_margins_cb()
1295
+ except Exception as e:
1296
+ print(f"[DEBUG] Exception in adjust_margins callback: {e}")
1297
+ try:
1298
+ _ui_ensure_text_visibility(fig, ax, label_text_objects)
1299
+ except Exception as e:
1300
+ print(f"[DEBUG] Exception in ensure_text_visibility: {e}")
1301
+
1302
+ # Apply geometry if present (for .bpsg files)
1303
+ kind = cfg.get('kind', '')
1304
+ if kind == 'xy_style_geom' and 'geometry' in cfg:
1305
+ try:
1306
+ geom = cfg.get('geometry', {})
1307
+ if 'xlabel' in geom and geom['xlabel']:
1308
+ ax.set_xlabel(geom['xlabel'])
1309
+ if 'ylabel' in geom and geom['ylabel']:
1310
+ ax.set_ylabel(geom['ylabel'])
1311
+
1312
+ # Restore normalization ranges (if saved)
1313
+ if 'norm_xlim' in geom and isinstance(geom['norm_xlim'], list) and len(geom['norm_xlim']) == 2:
1314
+ ax._norm_xlim = tuple(geom['norm_xlim'])
1315
+ if 'norm_ylim' in geom and isinstance(geom['norm_ylim'], list) and len(geom['norm_ylim']) == 2:
1316
+ ax._norm_ylim = tuple(geom['norm_ylim'])
1317
+
1318
+ # Restore display limits
1319
+ if 'xlim' in geom and isinstance(geom['xlim'], list) and len(geom['xlim']) == 2:
1320
+ ax.set_xlim(geom['xlim'][0], geom['xlim'][1])
1321
+ if 'ylim' in geom and isinstance(geom['ylim'], list) and len(geom['ylim']) == 2:
1322
+ ax.set_ylim(geom['ylim'][0], geom['ylim'][1])
1323
+ print("Applied geometry (labels and limits)")
1324
+ except Exception as e:
1325
+ print(f"Warning: Could not apply geometry: {e}")
1326
+
1327
+ try:
1328
+ fig.canvas.draw_idle()
1329
+ except Exception as e:
1330
+ print(f"[DEBUG] Exception in fig.canvas.draw_idle: {e}")
1331
+ print(f"Applied style from {filename}")
1332
+
1333
+ # Axis title toggle state
1334
+ try:
1335
+ # Preserve current pads to avoid drift when toggling presence via styles
1336
+ # Use saved values from before style changes, or current if not saved
1337
+ try:
1338
+ if saved_xlabelpad is not None:
1339
+ ax._pending_xlabelpad = saved_xlabelpad
1340
+ else:
1341
+ ax._pending_xlabelpad = getattr(ax.xaxis, 'labelpad', None)
1342
+ except Exception:
1343
+ pass
1344
+ try:
1345
+ if saved_ylabelpad is not None:
1346
+ ax._pending_ylabelpad = saved_ylabelpad
1347
+ else:
1348
+ ax._pending_ylabelpad = getattr(ax.yaxis, 'labelpad', None)
1349
+ except Exception:
1350
+ pass
1351
+ at_cfg = cfg.get("axis_titles", {})
1352
+ title_texts = cfg.get("axis_title_texts", {})
1353
+ bottom_text = title_texts.get("bottom_x")
1354
+ left_text = title_texts.get("left_y")
1355
+ top_text = title_texts.get("top_x")
1356
+ right_text = title_texts.get("right_y")
1357
+ if bottom_text is not None:
1358
+ ax._stored_xlabel = bottom_text
1359
+ if left_text is not None:
1360
+ ax._stored_ylabel = left_text
1361
+ if top_text is not None:
1362
+ if top_text:
1363
+ ax._top_xlabel_text_override = top_text
1364
+ elif hasattr(ax, '_top_xlabel_text_override'):
1365
+ delattr(ax, '_top_xlabel_text_override')
1366
+ if right_text is not None:
1367
+ if right_text:
1368
+ ax._right_ylabel_text_override = right_text
1369
+ elif hasattr(ax, '_right_ylabel_text_override'):
1370
+ delattr(ax, '_right_ylabel_text_override')
1371
+ # Top X duplicate via artist
1372
+ ax._top_xlabel_on = bool(at_cfg.get("top_x", False))
1373
+ try:
1374
+ _ui_position_top_xlabel(ax, fig, tick_state)
1375
+ except Exception:
1376
+ pass
1377
+ # Bottom X presence
1378
+ if not at_cfg.get("has_bottom_x", True):
1379
+ ax.xaxis.label.set_visible(False)
1380
+ else:
1381
+ ax.xaxis.label.set_visible(True)
1382
+ if bottom_text is not None:
1383
+ ax.set_xlabel(bottom_text)
1384
+ elif not ax.get_xlabel() and hasattr(ax, "_stored_xlabel"):
1385
+ ax.set_xlabel(ax._stored_xlabel)
1386
+ # Always re-position bottom xlabel to consume pending pad or set deterministic pad
1387
+ try:
1388
+ _ui_position_bottom_xlabel(ax, fig, tick_state)
1389
+ except Exception:
1390
+ pass
1391
+ # Right Y duplicate via artist
1392
+ ax._right_ylabel_on = bool(at_cfg.get("right_y", False))
1393
+ try:
1394
+ _ui_position_right_ylabel(ax, fig, tick_state)
1395
+ except Exception:
1396
+ pass
1397
+ # Left Y presence
1398
+ if not at_cfg.get("has_left_y", True):
1399
+ ax.yaxis.label.set_visible(False)
1400
+ else:
1401
+ ax.yaxis.label.set_visible(True)
1402
+ if left_text is not None:
1403
+ ax.set_ylabel(left_text)
1404
+ elif not ax.get_ylabel() and hasattr(ax, "_stored_ylabel"):
1405
+ ax.set_ylabel(ax._stored_ylabel)
1406
+ # Always re-position left ylabel to consume pending pad or set deterministic pad
1407
+ try:
1408
+ _ui_position_left_ylabel(ax, fig, tick_state)
1409
+ except Exception:
1410
+ pass
1411
+ # After positioning, ensure duplicate top/right title artists adopt imported font
1412
+ try:
1413
+ if numeric_size is not None:
1414
+ art = getattr(ax, '_top_xlabel_artist', None)
1415
+ if art is not None:
1416
+ art.set_fontsize(numeric_size)
1417
+ art = getattr(ax, '_right_ylabel_artist', None)
1418
+ if art is not None:
1419
+ art.set_fontsize(numeric_size)
1420
+ if fam_chain:
1421
+ fam0 = fam_chain[0]
1422
+ art = getattr(ax, '_top_xlabel_artist', None)
1423
+ if art is not None:
1424
+ art.set_fontfamily(fam0)
1425
+ art = getattr(ax, '_right_ylabel_artist', None)
1426
+ if art is not None:
1427
+ art.set_fontfamily(fam0)
1428
+ except Exception:
1429
+ pass
1430
+ fig.canvas.draw_idle()
1431
+ except Exception as e:
1432
+ print(f"[DEBUG] Exception in axis title toggle: {e}")
1433
+ except Exception as e:
1434
+ print(f"Error applying config: {e}")
1435
+
1436
+
1437
+ __all__ = [
1438
+ "print_style_info",
1439
+ "export_style_config",
1440
+ "apply_style_config",
1441
+ ]