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,3894 @@
1
+ """Interactive menu for normal XY plots (moved from monolithic batplot.py).
2
+
3
+ This module provides interactive_menu(fig, ax, ...). It mirrors the previous
4
+ implementation but lives outside batplot.py to match the pattern used by other
5
+ interactive modes (EC, Operando).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import json
12
+ import random
13
+ import sys
14
+ from typing import List, Optional, Tuple, Dict, Any
15
+
16
+ import numpy as np
17
+ import matplotlib.pyplot as plt
18
+ from matplotlib.ticker import AutoMinorLocator, NullFormatter, NullLocator
19
+ from matplotlib import colors as mcolors
20
+
21
+ from .plotting import update_labels
22
+ from .utils import (
23
+ _confirm_overwrite,
24
+ normalize_label_text,
25
+ choose_save_path,
26
+ choose_style_file,
27
+ list_files_in_subdirectory,
28
+ convert_label_shortcuts,
29
+ get_organized_path,
30
+ )
31
+ import time
32
+ from .session import dump_session as _bp_dump_session
33
+ from .ui import (
34
+ apply_font_changes as _ui_apply_font_changes,
35
+ sync_fonts as _ui_sync_fonts,
36
+ position_top_xlabel as _ui_position_top_xlabel,
37
+ position_right_ylabel as _ui_position_right_ylabel,
38
+ position_bottom_xlabel as _ui_position_bottom_xlabel,
39
+ position_left_ylabel as _ui_position_left_ylabel,
40
+ update_tick_visibility as _ui_update_tick_visibility,
41
+ ensure_text_visibility as _ui_ensure_text_visibility,
42
+ resize_plot_frame as _ui_resize_plot_frame,
43
+ resize_canvas as _ui_resize_canvas,
44
+ )
45
+ from .style import (
46
+ print_style_info as _bp_print_style_info,
47
+ export_style_config as _bp_export_style_config,
48
+ apply_style_config as _bp_apply_style_config,
49
+ )
50
+ from .color_utils import (
51
+ color_block,
52
+ color_bar,
53
+ palette_preview,
54
+ manage_user_colors,
55
+ get_user_color_list,
56
+ resolve_color_token,
57
+ ensure_colormap,
58
+ _CUSTOM_CMAPS,
59
+ )
60
+
61
+
62
+ class _FilterIMKWarning:
63
+ """Filter that suppresses macOS IMKCFRunLoopWakeUpReliable warnings while preserving other errors."""
64
+ def __init__(self, original_stderr):
65
+ self.original_stderr = original_stderr
66
+
67
+ def write(self, message):
68
+ # Filter out the harmless macOS IMK warning
69
+ if 'IMKCFRunLoopWakeUpReliable' not in message:
70
+ self.original_stderr.write(message)
71
+
72
+ def flush(self):
73
+ self.original_stderr.flush()
74
+
75
+
76
+ def _safe_input(prompt: str = "") -> str:
77
+ """Wrapper around input() that suppresses macOS IMKCFRunLoopWakeUpReliable warnings.
78
+
79
+ This is a harmless macOS system message that appears when using input() in terminals.
80
+ """
81
+ # Filter stderr to hide macOS IMK warnings while preserving other errors
82
+ original_stderr = sys.stderr
83
+ sys.stderr = _FilterIMKWarning(original_stderr)
84
+ try:
85
+ result = input(prompt)
86
+ return result
87
+ except (KeyboardInterrupt, EOFError):
88
+ raise
89
+ finally:
90
+ sys.stderr = original_stderr
91
+
92
+
93
+ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
94
+ label_text_objects, delta, x_label, args,
95
+ x_full_list, raw_y_full_list, offsets_list,
96
+ use_Q, use_r, use_E, use_k, use_rft,
97
+ cif_globals: Optional[Dict[str, Any]] = None):
98
+ """Interactive menu for XY plots.
99
+
100
+ Args:
101
+ fig: matplotlib Figure
102
+ ax: matplotlib Axes
103
+ y_data_list: List of y-data arrays (with offsets applied)
104
+ x_data_list: List of x-data arrays (cropped to current view)
105
+ labels: List of curve labels
106
+ orig_y: List of baseline y-data (normalized, no offset)
107
+ label_text_objects: List of matplotlib Text objects for curve labels
108
+ delta: Current offset spacing value
109
+ x_label: X-axis label string
110
+ args: Argument namespace from CLI
111
+ x_full_list: List of full x-data arrays (uncropped)
112
+ raw_y_full_list: List of full raw y-data arrays
113
+ offsets_list: List of current offset values per curve
114
+ use_Q, use_r, use_E, use_k, use_rft: Boolean flags for axis mode
115
+ cif_globals: Optional dict containing CIF-related state:
116
+ - 'cif_tick_series': list of CIF tick data
117
+ - 'cif_hkl_map': dict mapping filenames to hkl reflections
118
+ - 'cif_hkl_label_map': dict mapping Q to hkl label strings
119
+ - 'show_cif_hkl': bool flag for hkl label visibility
120
+ - 'cif_extend_suspended': bool flag to prevent re-entrant extension
121
+ - 'keep_canvas_fixed': bool flag for canvas resize behavior
122
+ """
123
+ # Use the provided fig/ax as-is; do not close or switch figures to avoid spawning new windows
124
+
125
+ # Handle CIF globals - prefer explicit parameter, fallback to __main__ for backward compatibility
126
+ if cif_globals is None:
127
+ # Legacy path: try to access __main__ module for CIF state
128
+ _bp = sys.modules.get('__main__')
129
+ if _bp is not None and hasattr(_bp, 'cif_tick_series'):
130
+ cif_globals = {
131
+ 'cif_tick_series': getattr(_bp, 'cif_tick_series', None),
132
+ 'cif_hkl_map': getattr(_bp, 'cif_hkl_map', None),
133
+ 'cif_hkl_label_map': getattr(_bp, 'cif_hkl_label_map', None),
134
+ 'show_cif_hkl': getattr(_bp, 'show_cif_hkl', False),
135
+ 'show_cif_titles': getattr(_bp, 'show_cif_titles', True),
136
+ 'cif_extend_suspended': getattr(_bp, 'cif_extend_suspended', False),
137
+ 'keep_canvas_fixed': getattr(_bp, 'keep_canvas_fixed', False),
138
+ }
139
+ else:
140
+ cif_globals = {}
141
+
142
+ # Provide a consistent interface for accessing CIF state
143
+ _bp = type('CIFState', (), cif_globals)() if cif_globals else None
144
+
145
+ try:
146
+ raw_source_paths = list(getattr(args, 'files', []) or [])
147
+ except Exception:
148
+ raw_source_paths = []
149
+ source_file_paths = []
150
+ seen_source_paths = set()
151
+ for _p in raw_source_paths:
152
+ if not _p:
153
+ continue
154
+ try:
155
+ abs_p = os.path.abspath(_p)
156
+ except Exception:
157
+ continue
158
+ if not os.path.isfile(abs_p):
159
+ continue
160
+ if abs_p in seen_source_paths:
161
+ continue
162
+ seen_source_paths.add(abs_p)
163
+ source_file_paths.append(abs_p)
164
+ try:
165
+ fig._bp_source_paths = list(source_file_paths)
166
+ except Exception:
167
+ pass
168
+
169
+ # Initialize rotation state (0, 90, 180, or 270 degrees)
170
+ if not hasattr(ax, '_rotation_angle'):
171
+ ax._rotation_angle = 0
172
+
173
+ # Initialize stack label position state (True = bottom, False = top/max)
174
+ if not hasattr(fig, '_stack_label_at_bottom'):
175
+ fig._stack_label_at_bottom = False
176
+ # Track horizontal anchor (False=right, True=left)
177
+ if not hasattr(fig, '_label_anchor_left'):
178
+ fig._label_anchor_left = False
179
+
180
+ # ANSI color codes for menu highlighting
181
+ def colorize_menu(text):
182
+ """Colorize menu items: command in cyan, colon in white, description in default."""
183
+ if ':' not in text:
184
+ return text
185
+ parts = text.split(':', 1)
186
+ cmd = parts[0].strip()
187
+ desc = parts[1].strip() if len(parts) > 1 else ''
188
+ return f"\033[96m{cmd}\033[0m: {desc}" # Cyan for command, default for description
189
+
190
+ def colorize_prompt(text):
191
+ """Colorize commands within input prompts. Handles formats like (s=size, f=family, q=return) or (y/n) or (q=cancel)."""
192
+ import re
193
+ # Pattern to match parenthesized command lists like (s=size, f=family, q=return) or (y/n) or (m/p/s/t/q) or (q=cancel)
194
+ pattern = r'\(([a-z]+=[^,)]+(?:,\s*[a-z]+=[^,)]+)*|[a-z]+(?:/[a-z]+)+)\)'
195
+
196
+ def colorize_match(match):
197
+ content = match.group(1)
198
+ # Check if it's slash-separated (like y/n or m/p/s/t/q)
199
+ if '/' in content:
200
+ parts = content.split('/')
201
+ colored_parts = [f"\033[96m{p.strip()}\033[0m" for p in parts]
202
+ return f"({'/'.join(colored_parts)})"
203
+ # Otherwise it's equals-separated (like s=size, f=family or q=cancel)
204
+ else:
205
+ parts = content.split(',')
206
+ colored_parts = []
207
+ for part in parts:
208
+ part = part.strip()
209
+ if '=' in part:
210
+ cmd, desc = part.split('=', 1)
211
+ colored_parts.append(f"\033[96m{cmd.strip()}\033[0m={desc.strip()}")
212
+ else:
213
+ colored_parts.append(part)
214
+ return f"({', '.join(colored_parts)})"
215
+
216
+ return re.sub(pattern, colorize_match, text)
217
+
218
+ def format_file_timestamp(filepath: str) -> str:
219
+ """Format file modification time for display.
220
+
221
+ Args:
222
+ filepath: Full path to the file
223
+
224
+ Returns:
225
+ Formatted timestamp string (e.g., "2024-01-15 14:30") or empty string if error
226
+ """
227
+ try:
228
+ mtime = os.path.getmtime(filepath)
229
+ # Format as YYYY-MM-DD HH:MM
230
+ return time.strftime("%Y-%m-%d %H:%M", time.localtime(mtime))
231
+ except Exception:
232
+ return ""
233
+
234
+ def colorize_inline_commands(text):
235
+ """Colorize inline command examples in help text. Colors quoted examples and specific known commands."""
236
+ import re
237
+ # Color quoted command examples (like 's2 w5 a4', 'w2 w5', or 'all magma_r')
238
+ text = re.sub(r"'([a-z0-9\s_-]+)'", lambda m: f"'\033[96m{m.group(1)}\033[0m'", text)
239
+ # Color specific known single-letter commands: q, i, l, when they appear as standalone commands
240
+ # Pattern: word boundary + (q|i|l|list|help|all) + space/equals/comma/end
241
+ text = re.sub(r'\b(q|i|l|list|help|all)\b(?=\s*[=,]|\s*$)', lambda m: f"\033[96m{m.group(1)}\033[0m", text)
242
+ return text
243
+
244
+ # REPLACED print_main_menu with column layout (now hides 'd' and 'y' in --stack)
245
+ is_diffraction = use_Q or (not use_r and not use_E and not use_k and not use_rft) # 2θ or Q
246
+ def print_main_menu():
247
+ has_cif = False
248
+ try:
249
+ # Check for CIF files in args.files (handle colon syntax like file.cif:0.25448)
250
+ has_cif = any(f.split(':')[0].lower().endswith('.cif') for f in args.files)
251
+ # Also check if CIF tick series exists (more reliable)
252
+ if not has_cif and _bp is not None:
253
+ has_cif = bool(getattr(_bp, 'cif_tick_series', None))
254
+ except Exception:
255
+ pass
256
+ col1 = ["c: colors", "f: font", "l: line", "t: toggle axes", "g: size", "h: legend"]
257
+ if has_cif:
258
+ col1.append("z: hkl")
259
+ col1.append("j: CIF titles")
260
+ col2 = ["a: rearrange", "d: offset", "r: rename", "x: change X", "y: change Y"]
261
+ col3 = ["v: find peaks", "n: crosshair", "p: print(export) style/geom", "i: import style/geom", "e: export figure", "s: save project", "b: undo", "q: quit"]
262
+
263
+ # Hide offset/y-range in stack mode
264
+ if args.stack:
265
+ col2 = [item for item in col2 if not item.startswith("d:") and not item.startswith("y:")]
266
+
267
+ if not is_diffraction:
268
+ col3 = [item for item in col3 if not item.startswith("n:")]
269
+ # Dynamic widths for cleaner alignment across terminals (account for ANSI codes)
270
+ # Use plain text length for width calculations
271
+ w1 = max(len("(Styles)"), *(len(s) for s in col1), 16)
272
+ w2 = max(len("(Geometries)"), *(len(s) for s in col2), 16)
273
+ w3 = max(len("(Options)"), *(len(s) for s in col3), 16)
274
+ rows = max(len(col1), len(col2), len(col3))
275
+ print("\n\033[1mInteractive menu:\033[0m") # Bold title
276
+ print(f" \033[93m{'(Styles)':<{w1}}\033[0m \033[93m{'(Geometries)':<{w2}}\033[0m \033[93m{'(Options)':<{w3}}\033[0m") # Yellow headers
277
+ for i in range(rows):
278
+ p1 = colorize_menu(col1[i]) if i < len(col1) else ""
279
+ p2 = colorize_menu(col2[i]) if i < len(col2) else ""
280
+ p3 = colorize_menu(col3[i]) if i < len(col3) else ""
281
+ # Add padding to account for ANSI escape codes (9 chars per colorized item)
282
+ pad1 = w1 + (9 if i < len(col1) else 0)
283
+ pad2 = w2 + (9 if i < len(col2) else 0)
284
+ pad3 = w3 + (9 if i < len(col3) else 0)
285
+ print(f" {p1:<{pad1}} {p2:<{pad2}} {p3:<{pad3}}")
286
+
287
+ # --- Helper for spine visibility ---
288
+ def set_spine_visible(which, visible):
289
+ if which in ax.spines:
290
+ ax.spines[which].set_visible(visible)
291
+ fig.canvas.draw_idle()
292
+
293
+ def get_spine_visible(which):
294
+ if which in ax.spines:
295
+ return ax.spines[which].get_visible()
296
+ return False
297
+ # Initial menu display REMOVED to avoid double print
298
+ ax.set_aspect('auto', adjustable='datalim')
299
+
300
+ def on_xlim_change(event_ax):
301
+ stack_label_bottom = getattr(fig, '_stack_label_at_bottom', False)
302
+ update_labels(event_ax, y_data_list, label_text_objects, args.stack, stack_label_bottom)
303
+ # Extend CIF ticks if needed when user pans/zooms horizontally
304
+ try:
305
+ if (
306
+ _bp is not None
307
+ and (not getattr(_bp, 'cif_extend_suspended', False))
308
+ and hasattr(ax, '_cif_extend_func') and hasattr(ax, '_cif_draw_func') and callable(ax._cif_extend_func)
309
+ ):
310
+ current_xlim = ax.get_xlim()
311
+ xmax = current_xlim[1]
312
+ ax._cif_extend_func(xmax)
313
+ except Exception:
314
+ pass
315
+ fig.canvas.draw()
316
+ ax.callbacks.connect('xlim_changed', on_xlim_change)
317
+
318
+ # --------- UPDATED unified font update helper ----------
319
+ def apply_font_changes(new_size=None, new_family=None):
320
+ return _ui_apply_font_changes(ax, fig, label_text_objects, normalize_label_text, new_size, new_family)
321
+
322
+ # Generic font sync (even when size/family unchanged) so newly created labels/twin axes inherit the rcParams size
323
+ def sync_fonts():
324
+ return _ui_sync_fonts(ax, fig, label_text_objects)
325
+
326
+ # Adjust vertical position of duplicate top X label depending on top tick visibility
327
+ def position_top_xlabel():
328
+ return _ui_position_top_xlabel(ax, fig, tick_state)
329
+
330
+ def position_right_ylabel():
331
+ return _ui_position_right_ylabel(ax, fig, tick_state)
332
+
333
+ def position_bottom_xlabel():
334
+ return _ui_position_bottom_xlabel(ax, fig, tick_state)
335
+
336
+ def position_left_ylabel():
337
+ return _ui_position_left_ylabel(ax, fig, tick_state)
338
+
339
+ def _current_label_position() -> str:
340
+ vertical = "bottom" if getattr(fig, '_stack_label_at_bottom', False) else "top"
341
+ horizontal = "left" if getattr(fig, '_label_anchor_left', False) else "right"
342
+ return f"{vertical}-{horizontal}"
343
+
344
+ def _apply_legend_position(bottom: bool, left: bool) -> None:
345
+ fig._stack_label_at_bottom = bottom
346
+ fig._label_anchor_left = left
347
+ update_labels(ax, y_data_list, label_text_objects, args.stack, bottom)
348
+ try:
349
+ fig.canvas.draw_idle()
350
+ except Exception:
351
+ pass
352
+
353
+ def _title_offset_menu():
354
+ """Interactive nudging for duplicate top/right titles."""
355
+ def _dpi():
356
+ try:
357
+ return float(fig.dpi)
358
+ except Exception:
359
+ return 72.0
360
+
361
+ def _px_value(attr):
362
+ try:
363
+ pts = float(getattr(ax, attr, 0.0) or 0.0)
364
+ except Exception:
365
+ pts = 0.0
366
+ return pts * _dpi() / 72.0
367
+
368
+ def _set_attr(attr, pts):
369
+ try:
370
+ setattr(ax, attr, float(pts))
371
+ except Exception:
372
+ pass
373
+
374
+ def _nudge(attr, delta_px):
375
+ try:
376
+ current_pts = float(getattr(ax, attr, 0.0) or 0.0)
377
+ except Exception:
378
+ current_pts = 0.0
379
+ delta_pts = float(delta_px) * 72.0 / _dpi()
380
+ _set_attr(attr, current_pts + delta_pts)
381
+
382
+ snapshot_taken = False
383
+
384
+ def _ensure_snapshot():
385
+ nonlocal snapshot_taken
386
+ if not snapshot_taken:
387
+ push_state("title-offset")
388
+ snapshot_taken = True
389
+
390
+ def _top_menu():
391
+ if not getattr(ax, '_top_xlabel_on', False):
392
+ print("Top duplicate title is currently hidden (toggle with w5).")
393
+ return
394
+ while True:
395
+ current_y_px = _px_value('_top_xlabel_manual_offset_y_pts')
396
+ current_x_px = _px_value('_top_xlabel_manual_offset_x_pts')
397
+ print(f"Top title offset: Y={current_y_px:+.2f} px (positive=up), X={current_x_px:+.2f} px (positive=right)")
398
+ sub = _safe_input(colorize_prompt("top (w=up, s=down, a=left, d=right, 0=reset, q=back): ")).strip().lower()
399
+ if not sub:
400
+ continue
401
+ if sub == 'q':
402
+ break
403
+ if sub == '0':
404
+ _ensure_snapshot()
405
+ _set_attr('_top_xlabel_manual_offset_y_pts', 0.0)
406
+ _set_attr('_top_xlabel_manual_offset_x_pts', 0.0)
407
+ elif sub == 'w':
408
+ _ensure_snapshot()
409
+ _nudge('_top_xlabel_manual_offset_y_pts', +1.0)
410
+ elif sub == 's':
411
+ _ensure_snapshot()
412
+ _nudge('_top_xlabel_manual_offset_y_pts', -1.0)
413
+ elif sub == 'a':
414
+ _ensure_snapshot()
415
+ _nudge('_top_xlabel_manual_offset_x_pts', -1.0)
416
+ elif sub == 'd':
417
+ _ensure_snapshot()
418
+ _nudge('_top_xlabel_manual_offset_x_pts', +1.0)
419
+ else:
420
+ print("Unknown choice (use w/s/a/d/0/q).")
421
+ continue
422
+ position_top_xlabel()
423
+ try:
424
+ fig.canvas.draw_idle()
425
+ except Exception:
426
+ pass
427
+
428
+ def _right_menu():
429
+ if not getattr(ax, '_right_ylabel_on', False):
430
+ print("Right duplicate title is currently hidden (toggle with d5).")
431
+ return
432
+ while True:
433
+ current_x_px = _px_value('_right_ylabel_manual_offset_x_pts')
434
+ current_y_px = _px_value('_right_ylabel_manual_offset_y_pts')
435
+ print(f"Right title offset: X={current_x_px:+.2f} px (positive=right), Y={current_y_px:+.2f} px (positive=up)")
436
+ sub = _safe_input(colorize_prompt("right (d=right, a=left, w=up, s=down, 0=reset, q=back): ")).strip().lower()
437
+ if not sub:
438
+ continue
439
+ if sub == 'q':
440
+ break
441
+ if sub == '0':
442
+ _ensure_snapshot()
443
+ _set_attr('_right_ylabel_manual_offset_x_pts', 0.0)
444
+ _set_attr('_right_ylabel_manual_offset_y_pts', 0.0)
445
+ elif sub == 'd':
446
+ _ensure_snapshot()
447
+ _nudge('_right_ylabel_manual_offset_x_pts', +1.0)
448
+ elif sub == 'a':
449
+ _ensure_snapshot()
450
+ _nudge('_right_ylabel_manual_offset_x_pts', -1.0)
451
+ elif sub == 'w':
452
+ _ensure_snapshot()
453
+ _nudge('_right_ylabel_manual_offset_y_pts', +1.0)
454
+ elif sub == 's':
455
+ _ensure_snapshot()
456
+ _nudge('_right_ylabel_manual_offset_y_pts', -1.0)
457
+ else:
458
+ print("Unknown choice (use d/a/w/s/0/q).")
459
+ continue
460
+ position_right_ylabel()
461
+ try:
462
+ fig.canvas.draw_idle()
463
+ except Exception:
464
+ pass
465
+
466
+ def _bottom_menu():
467
+ if not ax.get_xlabel():
468
+ print("Bottom title is currently hidden.")
469
+ return
470
+ while True:
471
+ current_y_px = _px_value('_bottom_xlabel_manual_offset_y_pts')
472
+ print(f"Bottom title offset: Y={current_y_px:+.2f} px (positive=down)")
473
+ sub = _safe_input(colorize_prompt("bottom (s=down, w=up, 0=reset, q=back): ")).strip().lower()
474
+ if not sub:
475
+ continue
476
+ if sub == 'q':
477
+ break
478
+ if sub == '0':
479
+ _ensure_snapshot()
480
+ _set_attr('_bottom_xlabel_manual_offset_y_pts', 0.0)
481
+ elif sub == 's':
482
+ _ensure_snapshot()
483
+ _nudge('_bottom_xlabel_manual_offset_y_pts', +1.0)
484
+ elif sub == 'w':
485
+ _ensure_snapshot()
486
+ _nudge('_bottom_xlabel_manual_offset_y_pts', -1.0)
487
+ else:
488
+ print("Unknown choice (use s/w/0/q).")
489
+ continue
490
+ position_bottom_xlabel()
491
+ try:
492
+ fig.canvas.draw_idle()
493
+ except Exception:
494
+ pass
495
+
496
+ def _left_menu():
497
+ if not ax.get_ylabel():
498
+ print("Left title is currently hidden.")
499
+ return
500
+ while True:
501
+ current_x_px = _px_value('_left_ylabel_manual_offset_x_pts')
502
+ print(f"Left title offset: X={current_x_px:+.2f} px (positive=left)")
503
+ sub = _safe_input(colorize_prompt("left (a=left, d=right, 0=reset, q=back): ")).strip().lower()
504
+ if not sub:
505
+ continue
506
+ if sub == 'q':
507
+ break
508
+ if sub == '0':
509
+ _ensure_snapshot()
510
+ _set_attr('_left_ylabel_manual_offset_x_pts', 0.0)
511
+ elif sub == 'a':
512
+ _ensure_snapshot()
513
+ _nudge('_left_ylabel_manual_offset_x_pts', +1.0)
514
+ elif sub == 'd':
515
+ _ensure_snapshot()
516
+ _nudge('_left_ylabel_manual_offset_x_pts', -1.0)
517
+ else:
518
+ print("Unknown choice (use a/d/0/q).")
519
+ continue
520
+ position_left_ylabel()
521
+ try:
522
+ fig.canvas.draw_idle()
523
+ except Exception:
524
+ pass
525
+
526
+ while True:
527
+ print(colorize_inline_commands("Title offsets:"))
528
+ print(" " + colorize_menu('w : adjust top title (w=up, s=down, a=left, d=right)'))
529
+ print(" " + colorize_menu('s : adjust bottom title (s=down, w=up)'))
530
+ print(" " + colorize_menu('a : adjust left title (a=left, d=right)'))
531
+ print(" " + colorize_menu('d : adjust right title (d=right, a=left, w=up, s=down)'))
532
+ print(" " + colorize_menu('r : reset all offsets'))
533
+ print(" " + colorize_menu('q : back to toggle menu'))
534
+ choice = _safe_input(colorize_prompt("p> ")).strip().lower()
535
+ if not choice:
536
+ continue
537
+ if choice == 'q':
538
+ break
539
+ if choice == 'w':
540
+ _top_menu()
541
+ continue
542
+ if choice == 's':
543
+ _bottom_menu()
544
+ continue
545
+ if choice == 'a':
546
+ _left_menu()
547
+ continue
548
+ if choice == 'd':
549
+ _right_menu()
550
+ continue
551
+ if choice == 'r':
552
+ _ensure_snapshot()
553
+ _set_attr('_top_xlabel_manual_offset_y_pts', 0.0)
554
+ _set_attr('_top_xlabel_manual_offset_x_pts', 0.0)
555
+ _set_attr('_bottom_xlabel_manual_offset_y_pts', 0.0)
556
+ _set_attr('_left_ylabel_manual_offset_x_pts', 0.0)
557
+ _set_attr('_right_ylabel_manual_offset_x_pts', 0.0)
558
+ _set_attr('_right_ylabel_manual_offset_y_pts', 0.0)
559
+ position_top_xlabel()
560
+ position_bottom_xlabel()
561
+ position_left_ylabel()
562
+ position_right_ylabel()
563
+ try:
564
+ fig.canvas.draw_idle()
565
+ except Exception:
566
+ pass
567
+ print("Reset manual offsets for all titles.")
568
+ continue
569
+ print("Unknown option. Use w/s/a/d/r/q.")
570
+
571
+ def play_jump_game():
572
+ """
573
+ Simple terminal 'jumping bird' (Flappy-style) game.
574
+ Controls: j = jump, Enter = let bird fall, q = quit game.
575
+ Avoid hitting '#' pillars. Score increases when you pass a pillar.
576
+ Difficulty lowered: bigger gaps, stronger jump, sparser pillars.
577
+ """
578
+ # Board/config
579
+ WIDTH = 32
580
+ HEIGHT = 12
581
+ BIRD_X = 5
582
+ GRAVITY = 1
583
+ JUMP_VEL = -3 # stronger jump for easier play
584
+ GAP_SIZE = 5 # larger gap for easier passage
585
+ MIN_OBS_SPACING = 12 # more spacing between obstacles
586
+
587
+ class Obstacle:
588
+ __slots__ = ("x", "gap_start", "scored")
589
+ def __init__(self, x):
590
+ self.x = x
591
+ self.gap_start = random.randint(1, max(1, HEIGHT - GAP_SIZE - 1))
592
+ self.scored = False
593
+
594
+ bird_y = HEIGHT // 2
595
+ vel = 0
596
+ tick = 0
597
+ score = 0
598
+ obstacles = [Obstacle(WIDTH - 1)]
599
+
600
+ def need_new():
601
+ if not obstacles:
602
+ return True
603
+ rightmost = max(o.x for o in obstacles)
604
+ return rightmost < WIDTH - MIN_OBS_SPACING
605
+
606
+ def new_obstacle():
607
+ obstacles.append(Obstacle(WIDTH - 1))
608
+
609
+ def collision():
610
+ # Out of bounds
611
+ if bird_y < 0 or bird_y >= HEIGHT:
612
+ return True
613
+ # Pillar collisions at or just before bird column unless within gap
614
+ for o in obstacles:
615
+ if o.x in (BIRD_X, BIRD_X - 1):
616
+ if not (o.gap_start <= bird_y < o.gap_start + GAP_SIZE):
617
+ return True
618
+ return False
619
+
620
+ def move_obstacles():
621
+ for o in obstacles:
622
+ o.x -= 1
623
+
624
+ def purge_obstacles():
625
+ while obstacles and obstacles[0].x < -1:
626
+ obstacles.pop(0)
627
+
628
+ def render():
629
+ border = "+" + ("-" * WIDTH) + "+"
630
+ print("\n" + border)
631
+ for y in range(HEIGHT):
632
+ row = []
633
+ for x in range(WIDTH):
634
+ ch = " "
635
+ if x == BIRD_X and y == bird_y:
636
+ ch = "@"
637
+ else:
638
+ for o in obstacles:
639
+ if x == o.x and not (o.gap_start <= y < o.gap_start + GAP_SIZE):
640
+ ch = "#"
641
+ break
642
+ row.append(ch)
643
+ print("|" + "".join(row) + "|")
644
+ print(border)
645
+ print(f"Score: {score} (j=jump, Enter=fall, q=quit)")
646
+
647
+ # One-time instructions
648
+ print("\nJumping Bird: pass through the gaps!")
649
+ print("Controls: j = jump, Enter = fall, q = quit\n")
650
+
651
+ while True:
652
+ render()
653
+ cmd = _safe_input("> ").strip().lower()
654
+ if cmd == 'q':
655
+ print("Exited game. Returning to interactive menu.\n")
656
+ break
657
+ if cmd == 'j':
658
+ vel = JUMP_VEL
659
+ else:
660
+ vel += GRAVITY
661
+
662
+ bird_y += vel
663
+
664
+ move_obstacles()
665
+ if need_new():
666
+ new_obstacle()
667
+ purge_obstacles()
668
+
669
+ # Scoring: mark a pillar once it moves left of bird
670
+ for o in obstacles:
671
+ if not o.scored and o.x < BIRD_X:
672
+ o.scored = True
673
+ score += 1
674
+
675
+ tick += 1
676
+ if collision():
677
+ render()
678
+ print(f"Game Over! Final score: {score}\n")
679
+ break
680
+
681
+ # -------------------------------------------------------
682
+
683
+ # --------- NEW: Resize only the plotting frame (axes), keep canvas (figure) size fixed ----------
684
+ def resize_plot_frame():
685
+ return _ui_resize_plot_frame(fig, ax, y_data_list, label_text_objects, args, update_labels)
686
+
687
+ def resize_canvas():
688
+ return _ui_resize_canvas(fig, ax)
689
+ # -------------------------------------------------
690
+
691
+ # ---- Tick / label visibility state ----
692
+ # New model: separate tick marks vs tick labels per side
693
+ # Keys:
694
+ # b_ticks, b_labels, t_ticks, t_labels, l_ticks, l_labels, r_ticks, r_labels
695
+ # Minor ticks remain: mbx, mtx, mly, mry
696
+ # Back-compat: also maintain synthetic bx/tx/ly/ry (mapped to *_ticks) for helpers.
697
+ saved_ts = getattr(ax, '_saved_tick_state', None)
698
+ def _make_default_tick_state():
699
+ return {
700
+ # Major ticks vs labels (defaults: bottom/left on, top/right off)
701
+ 'b_ticks': True, 'b_labels': True,
702
+ 't_ticks': False, 't_labels': False,
703
+ 'l_ticks': True, 'l_labels': True,
704
+ 'r_ticks': False, 'r_labels': False,
705
+ # Minor ticks
706
+ 'mbx': False, 'mtx': False, 'mly': False, 'mry': False,
707
+ # Legacy mirrors (filled by _sync_legacy_tick_keys)
708
+ 'bx': True, 'tx': False, 'ly': True, 'ry': False,
709
+ }
710
+
711
+ def _from_legacy(legacy: dict):
712
+ ts = _make_default_tick_state()
713
+ bx = bool(legacy.get('bx', ts['bx']))
714
+ tx = bool(legacy.get('tx', ts['tx']))
715
+ ly = bool(legacy.get('ly', ts['ly']))
716
+ ry = bool(legacy.get('ry', ts['ry']))
717
+ ts.update({
718
+ 'b_ticks': bx, 'b_labels': bx,
719
+ 't_ticks': tx, 't_labels': tx,
720
+ 'l_ticks': ly, 'l_labels': ly,
721
+ 'r_ticks': ry, 'r_labels': ry,
722
+ 'mbx': bool(legacy.get('mbx', False)),
723
+ 'mtx': bool(legacy.get('mtx', False)),
724
+ 'mly': bool(legacy.get('mly', False)),
725
+ 'mry': bool(legacy.get('mry', False)),
726
+ })
727
+ return ts
728
+
729
+ def _sync_legacy_tick_keys():
730
+ # Mirror current *_ticks into legacy bx/tx/ly/ry keys for code that reads them
731
+ tick_state['bx'] = bool(tick_state.get('b_ticks', True))
732
+ tick_state['tx'] = bool(tick_state.get('t_ticks', False))
733
+ tick_state['ly'] = bool(tick_state.get('l_ticks', True))
734
+ tick_state['ry'] = bool(tick_state.get('r_ticks', False))
735
+
736
+ if isinstance(saved_ts, dict):
737
+ if any(k in saved_ts for k in ('b_ticks','t_ticks','l_ticks','r_ticks')):
738
+ # Already new-format; start from defaults then overlay
739
+ tick_state = _make_default_tick_state()
740
+ for k,v in saved_ts.items():
741
+ if k in tick_state:
742
+ tick_state[k] = v
743
+ else:
744
+ tick_state = _from_legacy(saved_ts)
745
+ else:
746
+ tick_state = _make_default_tick_state()
747
+ _sync_legacy_tick_keys()
748
+
749
+ if hasattr(ax, '_saved_tick_state'):
750
+ try:
751
+ delattr(ax, '_saved_tick_state')
752
+ except Exception:
753
+ pass
754
+
755
+ # NEW: dynamic margin adjustment for top/right ticks
756
+ # Flag to preserve a manual/initial interactive top margin override
757
+ if not hasattr(fig, '_interactive_top_locked'):
758
+ fig._interactive_top_locked = False
759
+
760
+ def adjust_margins():
761
+ """Lightweight margin tweak based on tick visibility.
762
+
763
+ Unlike the old version this DOES NOT try to aggressively reallocate
764
+ space or change apparent plot size; it only adds a small padding on
765
+ sides that show ticks so labels have breathing room. Intended to be
766
+ idempotent and minimally invasive. Called during initial setup & some
767
+ style operations, but not on every tick toggle anymore.
768
+ """
769
+ sp = fig.subplotpars
770
+ # Start from current to avoid jumping
771
+ left, right, bottom, top = sp.left, sp.right, sp.bottom, sp.top
772
+ pad = 0.01 # modest expansion per active side
773
+ max_pad = 0.10
774
+ # Expand outward (shrinks axes) only if room
775
+ if tick_state['ly'] and left < 0.25:
776
+ left = min(left + pad, 0.40)
777
+ if tick_state['ry'] and (1 - right) < 0.25:
778
+ right = max(right - pad, 0.60)
779
+ if tick_state['bx'] and bottom < 0.25:
780
+ bottom = min(bottom + pad, 0.40)
781
+ if tick_state['tx'] and (1 - top) < 0.25:
782
+ top = max(top - pad, 0.60)
783
+
784
+ # Keep minimum plot span
785
+ if right - left < 0.25:
786
+ # Undo horizontal change proportionally
787
+ mid = (left + right) / 2
788
+ left = mid - 0.125
789
+ right = mid + 0.125
790
+ if top - bottom < 0.25:
791
+ mid = (bottom + top) / 2
792
+ bottom = mid - 0.125
793
+ top = mid + 0.125
794
+
795
+ fig.subplots_adjust(left=left, right=right, bottom=bottom, top=top)
796
+
797
+ def ensure_text_visibility(max_iterations=4, check_only=False):
798
+ return _ui_ensure_text_visibility(fig, ax, label_text_objects, max_iterations, check_only)
799
+
800
+ def update_tick_visibility():
801
+ # Apply major ticks and labels independently per side
802
+ ax.tick_params(axis='x',
803
+ bottom=bool(tick_state['b_ticks']), labelbottom=bool(tick_state['b_labels']),
804
+ top=bool(tick_state['t_ticks']), labeltop=bool(tick_state['t_labels']))
805
+ ax.tick_params(axis='y',
806
+ left=bool(tick_state['l_ticks']), labelleft=bool(tick_state['l_labels']),
807
+ right=bool(tick_state['r_ticks']), labelright=bool(tick_state['r_labels']))
808
+
809
+ if tick_state['mbx'] or tick_state['mtx']:
810
+ ax.xaxis.set_minor_locator(AutoMinorLocator())
811
+ ax.xaxis.set_minor_formatter(NullFormatter())
812
+ ax.tick_params(axis='x', which='minor',
813
+ bottom=tick_state['mbx'],
814
+ top=tick_state['mtx'],
815
+ labelbottom=False, labeltop=False)
816
+ else:
817
+ # Clear minor locator if no minor ticks are enabled
818
+ ax.xaxis.set_minor_locator(NullLocator())
819
+ ax.xaxis.set_minor_formatter(NullFormatter())
820
+ ax.tick_params(axis='x', which='minor',
821
+ bottom=False, top=False,
822
+ labelbottom=False, labeltop=False)
823
+
824
+ if tick_state['mly'] or tick_state['mry']:
825
+ ax.yaxis.set_minor_locator(AutoMinorLocator())
826
+ ax.yaxis.set_minor_formatter(NullFormatter())
827
+ ax.tick_params(axis='y', which='minor',
828
+ left=tick_state['mly'],
829
+ right=tick_state['mry'],
830
+ labelleft=False, labelright=False)
831
+ else:
832
+ # Clear minor locator if no minor ticks are enabled
833
+ ax.yaxis.set_minor_locator(NullLocator())
834
+ ax.yaxis.set_minor_formatter(NullFormatter())
835
+ ax.tick_params(axis='y', which='minor',
836
+ left=False, right=False,
837
+ labelleft=False, labelright=False)
838
+
839
+ # NOTE: We keep margins stable (no auto-adjust on every toggle)
840
+ if getattr(fig, '_skip_initial_text_visibility', False):
841
+ try:
842
+ delattr(fig, '_skip_initial_text_visibility')
843
+ except Exception:
844
+ pass
845
+ else:
846
+ ensure_text_visibility()
847
+ fig.canvas.draw_idle()
848
+
849
+ # NEW helper (was referenced in 'h' menu but not defined previously)
850
+ def print_tick_state():
851
+ def onoff(v):
852
+ return 'ON ' if bool(v) else 'off'
853
+ summary = []
854
+ sides = (
855
+ ('bottom',
856
+ get_spine_visible('bottom'),
857
+ tick_state.get('b_ticks', True),
858
+ tick_state.get('mbx', False),
859
+ tick_state.get('b_labels', True),
860
+ bool(ax.get_xlabel())),
861
+ ('top',
862
+ get_spine_visible('top'),
863
+ tick_state.get('t_ticks', False),
864
+ tick_state.get('mtx', False),
865
+ tick_state.get('t_labels', False),
866
+ bool(getattr(ax, '_top_xlabel_on', False))),
867
+ ('left',
868
+ get_spine_visible('left'),
869
+ tick_state.get('l_ticks', True),
870
+ tick_state.get('mly', False),
871
+ tick_state.get('l_labels', True),
872
+ bool(ax.get_ylabel())),
873
+ ('right',
874
+ get_spine_visible('right'),
875
+ tick_state.get('r_ticks', False),
876
+ tick_state.get('mry', False),
877
+ tick_state.get('r_labels', False),
878
+ bool(getattr(ax, '_right_ylabel_on', False))),
879
+ )
880
+ print(colorize_inline_commands("State (per side: spine, major, minor, labels, title):"))
881
+ for name, spine, mj, mn, lbl, title in sides:
882
+ print(colorize_inline_commands(f" {name:<6}: spine={onoff(spine)} major={onoff(mj)} minor={onoff(mn)} labels={onoff(lbl)} title={onoff(title)}"))
883
+
884
+ # NEW: style / diagnostics printer (clean version)
885
+ def print_style_info():
886
+ cts = getattr(_bp, 'cif_tick_series', None) if _bp is not None else None
887
+ show_hkl = bool(getattr(_bp, 'show_cif_hkl', False)) if _bp is not None else None
888
+ return _bp_print_style_info(
889
+ fig, ax,
890
+ y_data_list, labels,
891
+ offsets_list,
892
+ x_full_list, raw_y_full_list,
893
+ args, delta,
894
+ label_text_objects,
895
+ tick_state,
896
+ cts,
897
+ show_hkl,
898
+ )
899
+
900
+ # NEW: export current style to .bpcfg
901
+ def export_style_config(filename, base_path=None, overwrite_path=None):
902
+ cts = getattr(_bp, 'cif_tick_series', None) if _bp is not None else None
903
+ show_titles = bool(getattr(_bp, 'show_cif_titles', True)) if _bp is not None else True
904
+ from .style import export_style_config as _export_style_config
905
+ return _export_style_config(
906
+ filename,
907
+ fig,
908
+ ax,
909
+ y_data_list,
910
+ labels,
911
+ delta,
912
+ args,
913
+ tick_state,
914
+ offsets_list,
915
+ cts,
916
+ label_text_objects,
917
+ base_path,
918
+ show_cif_titles=show_titles,
919
+ overwrite_path=overwrite_path,
920
+ )
921
+
922
+ # NEW: apply imported style config (restricted application)
923
+ def apply_style_config(filename):
924
+ cts = getattr(_bp, 'cif_tick_series', None) if _bp is not None else None
925
+ hkl_map = getattr(_bp, 'cif_hkl_label_map', None) if _bp is not None else None
926
+ res = _bp_apply_style_config(
927
+ filename,
928
+ fig,
929
+ ax,
930
+ x_data_list,
931
+ y_data_list,
932
+ orig_y,
933
+ offsets_list,
934
+ label_text_objects,
935
+ args,
936
+ tick_state,
937
+ labels,
938
+ update_labels,
939
+ cts,
940
+ hkl_map,
941
+ adjust_margins,
942
+ )
943
+ # Sync top/right tick label2 fonts with current rcParams after style import
944
+ try:
945
+ fam_chain = plt.rcParams.get('font.sans-serif')
946
+ fam0 = fam_chain[0] if isinstance(fam_chain, list) and fam_chain else None
947
+ size0 = plt.rcParams.get('font.size', None)
948
+ if fam0 or size0 is not None:
949
+ for t in ax.xaxis.get_major_ticks():
950
+ if hasattr(t, 'label2'):
951
+ if size0 is not None: t.label2.set_size(size0)
952
+ if fam0: t.label2.set_family(fam0)
953
+ for t in ax.yaxis.get_major_ticks():
954
+ if hasattr(t, 'label2'):
955
+ if size0 is not None: t.label2.set_size(size0)
956
+ if fam0: t.label2.set_family(fam0)
957
+ except Exception:
958
+ pass
959
+ return res
960
+
961
+ # Initialize with current defaults
962
+ update_tick_visibility()
963
+
964
+ # --- Crosshair state & toggle function (UPDATED) ---
965
+ # Get wavelength info from cif_globals if available
966
+ file_wavelength_info = cif_globals.get('file_wavelength_info', []) if cif_globals else []
967
+
968
+ crosshair = {
969
+ 'active': False,
970
+ 'hline': None,
971
+ 'vline': None,
972
+ 'text': None,
973
+ 'cid_motion': None,
974
+ 'wavelength': None # only used when axis is 2theta (fallback if no file info)
975
+ }
976
+
977
+ def toggle_crosshair():
978
+ if not crosshair['active']:
979
+ # Only ask for wavelength if it's diffraction data, not using Q, and no file wavelength info
980
+ if is_diffraction and not use_Q and not file_wavelength_info:
981
+ try:
982
+ wl_in = _safe_input("Enter wavelength in Å for Q,d display (blank=skip, q=cancel): ").strip()
983
+ if wl_in.lower() == 'q':
984
+ print("Canceled.")
985
+ return
986
+ if wl_in:
987
+ crosshair['wavelength'] = float(wl_in)
988
+ else:
989
+ crosshair['wavelength'] = None
990
+ except ValueError:
991
+ print("Invalid wavelength. Skipping Q,d calculation.")
992
+ crosshair['wavelength'] = None
993
+ vline = ax.axvline(x=ax.get_xlim()[0], color='0.35', ls='--', lw=0.8, alpha=0.85, zorder=9999)
994
+ hline = ax.axhline(y=ax.get_ylim()[0], color='0.35', ls='--', lw=0.8, alpha=0.85, zorder=9999)
995
+ txt = ax.text(1.0, 1.0, "",
996
+ ha='right', va='bottom',
997
+ transform=ax.transAxes,
998
+ fontsize=max(9, int(0.6 * plt.rcParams.get('font.size', 16))),
999
+ color='0.15',
1000
+ bbox=dict(boxstyle='round,pad=0.25', fc='white', ec='0.7', alpha=0.8))
1001
+
1002
+ def on_move(event):
1003
+ if event.inaxes != ax or event.xdata is None or event.ydata is None:
1004
+ return
1005
+ x = float(event.xdata)
1006
+ y = float(event.ydata)
1007
+ vline.set_xdata([x, x])
1008
+ hline.set_ydata([y, y])
1009
+
1010
+ # For diffraction data, show Q/d calculations
1011
+ if is_diffraction:
1012
+ if use_Q:
1013
+ Q = x
1014
+ if Q != 0:
1015
+ d = 2 * np.pi / Q
1016
+ txt.set_text(f"Q={Q:.6g}\nd={d:.6g} Å\ny={y:.6g}")
1017
+ else:
1018
+ txt.set_text(f"Q={Q:.6g}\nd=∞\ny={y:.6g}")
1019
+ elif use_r:
1020
+ txt.set_text(f"r={x:.6g} Å\ny={y:.6g}")
1021
+ else:
1022
+ # 2θ mode
1023
+ # Check if we have file wavelength info (dual wavelength conversion)
1024
+ wl_info = file_wavelength_info[0] if file_wavelength_info else None
1025
+ if wl_info and wl_info.get('original_wl') is not None and wl_info.get('conversion_wl') is not None:
1026
+ # Dual wavelength: show original 2theta and current 2theta
1027
+ orig_wl = wl_info['original_wl']
1028
+ conv_wl = wl_info['conversion_wl']
1029
+ # Current 2theta is x
1030
+ # Calculate original 2theta: current 2theta -> Q -> original 2theta
1031
+ theta_rad = np.radians(x / 2.0)
1032
+ Q = 4 * np.pi * np.sin(theta_rad) / conv_wl
1033
+ # Convert Q back to original 2theta
1034
+ sin_theta_orig = Q * orig_wl / (4 * np.pi)
1035
+ sin_theta_orig = np.clip(sin_theta_orig, -1.0, 1.0)
1036
+ theta_orig_rad = np.arcsin(sin_theta_orig)
1037
+ orig_2theta = np.degrees(2 * theta_orig_rad)
1038
+ if Q != 0:
1039
+ d = 2 * np.pi / Q
1040
+ txt.set_text(f"2θ={x:.6g}° (λ₂={conv_wl:.5f})\n2θ₀={orig_2theta:.6g}° (λ₁={orig_wl:.5f})\nQ={Q:.6g}\nd={d:.6g} Å\ny={y:.6g}")
1041
+ else:
1042
+ txt.set_text(f"2θ={x:.6g}° (λ₂={conv_wl:.5f})\n2θ₀={orig_2theta:.6g}° (λ₁={orig_wl:.5f})\nQ=0\nd=∞\ny={y:.6g}")
1043
+ elif crosshair['wavelength'] is not None:
1044
+ lam = crosshair['wavelength']
1045
+ theta_rad = np.radians(x / 2.0)
1046
+ Q = 4 * np.pi * np.sin(theta_rad) / lam
1047
+ if Q != 0:
1048
+ d = 2 * np.pi / Q
1049
+ txt.set_text(f"2θ={x:.6g}°\nQ={Q:.6g}\nd={d:.6g} Å\ny={y:.6g}")
1050
+ else:
1051
+ txt.set_text(f"2θ={x:.6g}°\nQ=0\nd=∞\ny={y:.6g}")
1052
+ else:
1053
+ txt.set_text(f"2θ={x:.6g}°\ny={y:.6g}")
1054
+ else:
1055
+ # For non-diffraction data, just show x and y values
1056
+ txt.set_text(f"x={x:.6g}\ny={y:.6g}")
1057
+
1058
+ fig.canvas.draw_idle()
1059
+
1060
+ cid = fig.canvas.mpl_connect('motion_notify_event', on_move)
1061
+ crosshair.update({'active': True, 'hline': hline, 'vline': vline,
1062
+ 'text': txt, 'cid_motion': cid})
1063
+ print("Crosshair ON. Move mouse over axes. Press 'n' again to turn off.")
1064
+ else:
1065
+ if crosshair['cid_motion'] is not None:
1066
+ fig.canvas.mpl_disconnect(crosshair['cid_motion'])
1067
+ for k in ('hline', 'vline', 'text'):
1068
+ art = crosshair[k]
1069
+ if art is not None:
1070
+ try:
1071
+ art.remove()
1072
+ except Exception:
1073
+ pass
1074
+ crosshair.update({'active': False, 'hline': None, 'vline': None,
1075
+ 'text': None, 'cid_motion': None})
1076
+ fig.canvas.draw_idle()
1077
+ print("Crosshair OFF.")
1078
+ # --- End crosshair additions (UPDATED) ---
1079
+
1080
+ # -------- Session helper now provided by batplot.session (dump only here) --------
1081
+
1082
+
1083
+ # history management:
1084
+ state_history = []
1085
+
1086
+ def push_state(note=""):
1087
+ """Snapshot current editable state (before a modifying action)."""
1088
+ try:
1089
+ # Helper to capture a representative tick line width
1090
+ def _tick_width(axis_obj, which):
1091
+ try:
1092
+ tick_kw = axis_obj._major_tick_kw if which == 'major' else axis_obj._minor_tick_kw
1093
+ width = tick_kw.get('width')
1094
+ if width is None:
1095
+ axis_name = getattr(axis_obj, 'axis_name', 'x')
1096
+ rc_key = f"{axis_name}tick.{which}.width"
1097
+ width = plt.rcParams.get(rc_key)
1098
+ if width is not None:
1099
+ return float(width)
1100
+ except Exception:
1101
+ return None
1102
+ return None
1103
+ snap = {
1104
+ "note": note,
1105
+ "xlim": ax.get_xlim(),
1106
+ "ylim": ax.get_ylim(),
1107
+ "tick_state": tick_state.copy(),
1108
+ "font_size": plt.rcParams.get('font.size'),
1109
+ "font_chain": list(plt.rcParams.get('font.sans-serif', [])),
1110
+ "labels": list(labels),
1111
+ "delta": delta,
1112
+ "lines": [],
1113
+ "fig_size": list(fig.get_size_inches()),
1114
+ "fig_dpi": fig.dpi,
1115
+ "axes_bbox": [float(v) for v in ax.get_position().bounds], # x0,y0,w,h
1116
+ "axis_labels": {"xlabel": ax.get_xlabel(), "ylabel": ax.get_ylabel()},
1117
+ "axis_titles": {"top_x": bool(getattr(ax, '_top_xlabel_on', False)),
1118
+ "right_y": bool(getattr(ax, '_right_ylabel_on', False))},
1119
+ "title_offsets": {
1120
+ "top_y": float(getattr(ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0),
1121
+ "top_x": float(getattr(ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0),
1122
+ "bottom_y": float(getattr(ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0),
1123
+ "left_x": float(getattr(ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0),
1124
+ "right_x": float(getattr(ax, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0),
1125
+ "right_y": float(getattr(ax, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0),
1126
+ },
1127
+ "spines": {name: {"lw": sp.get_linewidth(), "color": sp.get_edgecolor(), "visible": sp.get_visible()} for name, sp in ax.spines.items()},
1128
+ "tick_widths": {
1129
+ "x_major": _tick_width(ax.xaxis, 'major'),
1130
+ "x_minor": _tick_width(ax.xaxis, 'minor'),
1131
+ "y_major": _tick_width(ax.yaxis, 'major'),
1132
+ "y_minor": _tick_width(ax.yaxis, 'minor')
1133
+ },
1134
+ "tick_lengths": dict(getattr(fig, '_tick_lengths', {'major': None, 'minor': None})),
1135
+ "tick_direction": getattr(fig, '_tick_direction', 'out'),
1136
+ "cif_tick_series": (list(getattr(_bp, 'cif_tick_series')) if (_bp is not None and hasattr(_bp, 'cif_tick_series')) else None),
1137
+ "show_cif_hkl": (bool(getattr(_bp, 'show_cif_hkl')) if _bp is not None and hasattr(_bp, 'show_cif_hkl') else False),
1138
+ "show_cif_titles": (bool(getattr(_bp, 'show_cif_titles')) if _bp is not None and hasattr(_bp, 'show_cif_titles') else True),
1139
+ "rotation_angle": getattr(ax, '_rotation_angle', 0),
1140
+ "stack_label_at_bottom": getattr(fig, '_stack_label_at_bottom', False),
1141
+ "label_anchor_left": getattr(fig, '_label_anchor_left', False),
1142
+ "grid": ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else False
1143
+ }
1144
+ # Line + data arrays
1145
+ for i, ln in enumerate(ax.lines):
1146
+ snap["lines"].append({
1147
+ "index": i,
1148
+ "x": np.array(ln.get_xdata(), copy=True),
1149
+ "y": np.array(ln.get_ydata(), copy=True),
1150
+ "color": ln.get_color(),
1151
+ "lw": ln.get_linewidth(),
1152
+ "ls": ln.get_linestyle(),
1153
+ "marker": ln.get_marker(),
1154
+ "markersize": getattr(ln, 'get_markersize', lambda: None)(),
1155
+ "mfc": getattr(ln, 'get_markerfacecolor', lambda: None)(),
1156
+ "mec": getattr(ln, 'get_markeredgecolor', lambda: None)(),
1157
+ "alpha": ln.get_alpha()
1158
+ })
1159
+ # Data lists
1160
+ snap["x_data_list"] = [np.array(a, copy=True) for a in x_data_list]
1161
+ snap["y_data_list"] = [np.array(a, copy=True) for a in y_data_list]
1162
+ snap["orig_y"] = [np.array(a, copy=True) for a in orig_y]
1163
+ snap["offsets"] = list(offsets_list)
1164
+ # Label text content
1165
+ snap["label_texts"] = [t.get_text() for t in label_text_objects]
1166
+ state_history.append(snap)
1167
+ if len(state_history) > 40:
1168
+ state_history.pop(0)
1169
+ except Exception as e:
1170
+ print(f"Warning: could not snapshot state: {e}")
1171
+
1172
+ def restore_state():
1173
+ nonlocal delta
1174
+ if not state_history:
1175
+ print("No undo history.")
1176
+ return
1177
+ snap = state_history.pop()
1178
+ try:
1179
+ # Basic numeric state
1180
+ ax.set_xlim(*snap["xlim"])
1181
+ ax.set_ylim(*snap["ylim"])
1182
+ # Tick state
1183
+ snap_ts = snap.get("tick_state", {})
1184
+ for k, v in snap_ts.items():
1185
+ if k in tick_state:
1186
+ tick_state[k] = v
1187
+ # If snapshot was legacy-only, map bx/tx/ly/ry into new keys
1188
+ if not any(k in snap_ts for k in ('b_ticks','t_ticks','l_ticks','r_ticks')):
1189
+ if 'bx' in snap_ts:
1190
+ tick_state['b_ticks'] = bool(snap_ts.get('bx', tick_state['bx']))
1191
+ tick_state['b_labels'] = bool(snap_ts.get('bx', tick_state['bx']))
1192
+ if 'tx' in snap_ts:
1193
+ tick_state['t_ticks'] = bool(snap_ts.get('tx', tick_state['tx']))
1194
+ tick_state['t_labels'] = bool(snap_ts.get('tx', tick_state['tx']))
1195
+ if 'ly' in snap_ts:
1196
+ tick_state['l_ticks'] = bool(snap_ts.get('ly', tick_state['ly']))
1197
+ tick_state['l_labels'] = bool(snap_ts.get('ly', tick_state['ly']))
1198
+ if 'ry' in snap_ts:
1199
+ tick_state['r_ticks'] = bool(snap_ts.get('ry', tick_state['ry']))
1200
+ tick_state['r_labels'] = bool(snap_ts.get('ry', tick_state['ry']))
1201
+ _sync_legacy_tick_keys()
1202
+ update_tick_visibility()
1203
+
1204
+ # Fonts
1205
+ if snap["font_chain"]:
1206
+ plt.rcParams['font.family'] = 'sans-serif'
1207
+ plt.rcParams['font.sans-serif'] = snap["font_chain"]
1208
+ if snap["font_size"]:
1209
+ try:
1210
+ plt.rcParams['font.size'] = snap["font_size"]
1211
+ except Exception:
1212
+ pass
1213
+
1214
+ # Figure size & dpi
1215
+ if snap.get("fig_size") and isinstance(snap["fig_size"], (list, tuple)) and len(snap["fig_size"])==2:
1216
+ if not (getattr(_bp, 'keep_canvas_fixed', True) if _bp is not None else True):
1217
+ try:
1218
+ fig.set_size_inches(snap["fig_size"][0], snap["fig_size"][1], forward=True)
1219
+ except Exception:
1220
+ pass
1221
+ else:
1222
+ print("(Canvas fixed) Ignoring undo figure size restore.")
1223
+ # Don't restore DPI from undo - use system default to avoid display-dependent issues
1224
+
1225
+ # Restore axes (plot frame) via stored bbox if present
1226
+ if snap.get("axes_bbox") and isinstance(snap["axes_bbox"], (list, tuple)) and len(snap["axes_bbox"])==4:
1227
+ try:
1228
+ x0,y0,w,h = snap["axes_bbox"]
1229
+ left = x0; bottom = y0; right = x0 + w; top = y0 + h
1230
+ if 0 < left < right <=1 and 0 < bottom < top <=1:
1231
+ fig.subplots_adjust(left=left, right=right, bottom=bottom, top=top)
1232
+ except Exception:
1233
+ pass
1234
+
1235
+ # Axis labels (use low-level API to avoid layout recalculation)
1236
+ axis_labels = snap.get("axis_labels", {})
1237
+ if axis_labels.get("xlabel") is not None:
1238
+ ax.xaxis.label.set_text(axis_labels["xlabel"])
1239
+ if axis_labels.get("ylabel") is not None:
1240
+ ax.yaxis.label.set_text(axis_labels["ylabel"])
1241
+ # Manual offsets for all titles - support both old and new format
1242
+ title_offsets = snap.get("title_offsets", {})
1243
+ try:
1244
+ if 'top_y' in title_offsets:
1245
+ ax._top_xlabel_manual_offset_y_pts = float(title_offsets.get('top_y', 0.0) or 0.0)
1246
+ else:
1247
+ # Backward compatibility: old format used 'top' for y-offset
1248
+ ax._top_xlabel_manual_offset_y_pts = float(title_offsets.get('top', 0.0) or 0.0)
1249
+ except Exception:
1250
+ ax._top_xlabel_manual_offset_y_pts = 0.0
1251
+ try:
1252
+ ax._top_xlabel_manual_offset_x_pts = float(title_offsets.get('top_x', 0.0) or 0.0)
1253
+ except Exception:
1254
+ ax._top_xlabel_manual_offset_x_pts = 0.0
1255
+ try:
1256
+ ax._bottom_xlabel_manual_offset_y_pts = float(title_offsets.get('bottom_y', 0.0) or 0.0)
1257
+ except Exception:
1258
+ ax._bottom_xlabel_manual_offset_y_pts = 0.0
1259
+ try:
1260
+ ax._left_ylabel_manual_offset_x_pts = float(title_offsets.get('left_x', 0.0) or 0.0)
1261
+ except Exception:
1262
+ ax._left_ylabel_manual_offset_x_pts = 0.0
1263
+ try:
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
+ except Exception:
1270
+ ax._right_ylabel_manual_offset_x_pts = 0.0
1271
+ try:
1272
+ ax._right_ylabel_manual_offset_y_pts = float(title_offsets.get('right_y', 0.0) or 0.0)
1273
+ except Exception:
1274
+ ax._right_ylabel_manual_offset_y_pts = 0.0
1275
+
1276
+ # Axis title duplicates (top X / right Y)
1277
+ at = snap.get("axis_titles", {})
1278
+ # Top X
1279
+ try:
1280
+ ax._top_xlabel_on = bool(at.get('top_x', False))
1281
+ position_top_xlabel()
1282
+ except Exception:
1283
+ pass
1284
+ # Right Y
1285
+ try:
1286
+ ax._right_ylabel_on = bool(at.get('right_y', False))
1287
+ position_right_ylabel()
1288
+ except Exception:
1289
+ pass
1290
+ # Also reposition bottom/left titles to consume pending pads and match tick label visibility
1291
+ try:
1292
+ position_bottom_xlabel()
1293
+ except Exception:
1294
+ pass
1295
+ try:
1296
+ position_left_ylabel()
1297
+ except Exception:
1298
+ pass
1299
+
1300
+ # Spines (linewidth, color, visibility)
1301
+ for name, spec in snap.get("spines", {}).items():
1302
+ sp_obj = ax.spines.get(name)
1303
+ if sp_obj is None:
1304
+ continue
1305
+ try:
1306
+ if "lw" in spec:
1307
+ sp_obj.set_linewidth(spec["lw"])
1308
+ if "color" in spec and spec["color"] is not None:
1309
+ sp_obj.set_edgecolor(spec["color"])
1310
+ if name in ('top', 'bottom'):
1311
+ ax.tick_params(axis='x', which='both', colors=spec['color'])
1312
+ ax.xaxis.label.set_color(spec['color'])
1313
+ else:
1314
+ ax.tick_params(axis='y', which='both', colors=spec['color'])
1315
+ ax.yaxis.label.set_color(spec['color'])
1316
+ if "visible" in spec:
1317
+ try:
1318
+ sp_obj.set_visible(bool(spec["visible"]))
1319
+ except Exception:
1320
+ pass
1321
+ except Exception:
1322
+ pass
1323
+
1324
+ # Tick widths
1325
+ tw = snap.get("tick_widths", {})
1326
+ try:
1327
+ if tw.get("x_major") is not None:
1328
+ ax.tick_params(axis='x', which='major', width=tw["x_major"])
1329
+ if tw.get("x_minor") is not None:
1330
+ ax.tick_params(axis='x', which='minor', width=tw["x_minor"])
1331
+ if tw.get("y_major") is not None:
1332
+ ax.tick_params(axis='y', which='major', width=tw["y_major"])
1333
+ if tw.get("y_minor") is not None:
1334
+ ax.tick_params(axis='y', which='minor', width=tw["y_minor"])
1335
+ except Exception:
1336
+ pass
1337
+
1338
+ # Tick lengths
1339
+ tl = snap.get("tick_lengths", {})
1340
+ try:
1341
+ if tl.get("major") is not None:
1342
+ ax.tick_params(axis='both', which='major', length=tl["major"])
1343
+ if tl.get("minor") is not None:
1344
+ ax.tick_params(axis='both', which='minor', length=tl["minor"])
1345
+ if tl:
1346
+ fig._tick_lengths = dict(tl)
1347
+ except Exception:
1348
+ pass
1349
+
1350
+ # Tick direction
1351
+ try:
1352
+ tick_dir = snap.get("tick_direction", 'out')
1353
+ ax.tick_params(axis='both', which='both', direction=tick_dir)
1354
+ fig._tick_direction = tick_dir
1355
+ except Exception:
1356
+ pass
1357
+
1358
+ # Labels list
1359
+ labels[:] = snap["labels"]
1360
+
1361
+ # Data & lines
1362
+ if len(snap["lines"]) == len(ax.lines):
1363
+ for item in snap["lines"]:
1364
+ i = item["index"]
1365
+ ln = ax.lines[i]
1366
+ ln.set_data(item["x"], item["y"])
1367
+ ln.set_color(item["color"])
1368
+ ln.set_linewidth(item["lw"])
1369
+ ln.set_linestyle(item["ls"])
1370
+ if item["marker"] is not None:
1371
+ ln.set_marker(item["marker"])
1372
+ if item.get("markersize") is not None:
1373
+ try: ln.set_markersize(item["markersize"])
1374
+ except Exception: pass
1375
+ if item.get("mfc") is not None:
1376
+ try: ln.set_markerfacecolor(item["mfc"])
1377
+ except Exception: pass
1378
+ if item.get("mec") is not None:
1379
+ try: ln.set_markeredgecolor(item["mec"])
1380
+ except Exception: pass
1381
+ if item["alpha"] is not None:
1382
+ ln.set_alpha(item["alpha"])
1383
+
1384
+ # Replace lists
1385
+ x_data_list[:] = [np.array(a, copy=True) for a in snap["x_data_list"]]
1386
+ y_data_list[:] = [np.array(a, copy=True) for a in snap["y_data_list"]]
1387
+ orig_y[:] = [np.array(a, copy=True) for a in snap["orig_y"]]
1388
+ offsets_list[:] = list(snap["offsets"])
1389
+ delta = snap.get("delta", delta)
1390
+
1391
+ # Recalculate y_data_list from orig_y and offsets_list to ensure consistency
1392
+ for i in range(len(orig_y)):
1393
+ if i < len(offsets_list):
1394
+ y_data_list[i] = orig_y[i] + offsets_list[i]
1395
+ else:
1396
+ y_data_list[i] = orig_y[i].copy()
1397
+
1398
+ # Update line data with restored values
1399
+ for i in range(min(len(ax.lines), len(x_data_list), len(y_data_list))):
1400
+ try:
1401
+ ax.lines[i].set_data(x_data_list[i], y_data_list[i])
1402
+ except Exception:
1403
+ pass
1404
+
1405
+ # Restore rotation angle
1406
+ if 'rotation_angle' in snap:
1407
+ ax._rotation_angle = snap['rotation_angle']
1408
+
1409
+ # Restore legend position (stack_label_at_bottom)
1410
+ if 'stack_label_at_bottom' in snap:
1411
+ fig._stack_label_at_bottom = bool(snap['stack_label_at_bottom'])
1412
+ if 'label_anchor_left' in snap:
1413
+ fig._label_anchor_left = bool(snap['label_anchor_left'])
1414
+
1415
+ # Restore grid state
1416
+ if 'grid' in snap:
1417
+ try:
1418
+ if snap['grid']:
1419
+ ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
1420
+ else:
1421
+ ax.grid(False)
1422
+ except Exception:
1423
+ pass
1424
+
1425
+ # CIF tick sets & label visibility (write back to batplot module globals)
1426
+ if _bp is not None and snap.get("cif_tick_series") is not None and hasattr(_bp, 'cif_tick_series'):
1427
+ try:
1428
+ _bp.cif_tick_series[:] = [tuple(t) for t in snap["cif_tick_series"]]
1429
+ except Exception:
1430
+ pass
1431
+ if _bp is not None and 'show_cif_hkl' in snap:
1432
+ try:
1433
+ setattr(_bp, 'show_cif_hkl', bool(snap['show_cif_hkl']))
1434
+ except Exception:
1435
+ pass
1436
+ if _bp is not None and 'show_cif_titles' in snap:
1437
+ try:
1438
+ new_state = bool(snap['show_cif_titles'])
1439
+ setattr(_bp, 'show_cif_titles', new_state)
1440
+ # Also update figure attribute and __main__ module
1441
+ fig._bp_show_cif_titles = new_state
1442
+ try:
1443
+ _bp_module = sys.modules.get('__main__')
1444
+ if _bp_module is not None:
1445
+ setattr(_bp_module, 'show_cif_titles', new_state)
1446
+ except Exception:
1447
+ pass
1448
+ except Exception:
1449
+ pass
1450
+ # Redraw CIF ticks after restoration if available
1451
+ if hasattr(ax, '_cif_draw_func'):
1452
+ try:
1453
+ ax._cif_draw_func()
1454
+ except Exception:
1455
+ pass
1456
+
1457
+ # Restore label texts (keep numbering style)
1458
+ for i, txt in enumerate(label_text_objects):
1459
+ base = labels[i] if i < len(labels) else ""
1460
+ txt.set_text(f"{i+1}: {base}")
1461
+
1462
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
1463
+ try:
1464
+ globals()['tick_state'] = tick_state
1465
+ except Exception:
1466
+ pass
1467
+ try:
1468
+ fig.canvas.draw()
1469
+ except Exception:
1470
+ try: fig.canvas.draw_idle()
1471
+ except Exception: pass
1472
+ print("Undo: restored previous state.")
1473
+ except Exception as e:
1474
+ print(f"Error restoring state: {e}")
1475
+
1476
+
1477
+ while True:
1478
+ try:
1479
+ print_main_menu()
1480
+ key = _safe_input("Press a key: ").strip().lower()
1481
+ except (KeyboardInterrupt, EOFError):
1482
+ print("\n\nExiting interactive menu...")
1483
+ break
1484
+
1485
+ if not key:
1486
+ continue
1487
+
1488
+ # NEW: disable 'y' and 'd' in stack mode
1489
+ if args.stack and key in ('y', 'd'):
1490
+ print("Option disabled in --stack mode.")
1491
+ continue
1492
+
1493
+ if key == 'q':
1494
+ try:
1495
+ confirm = _safe_input(colorize_prompt("Quit interactive? Remember to save (e=export, s=save). Quit now? (y/n): ")).strip().lower()
1496
+ except (KeyboardInterrupt, EOFError):
1497
+ print("\nExiting interactive menu...")
1498
+ break
1499
+ if confirm == 'y':
1500
+ break
1501
+ else:
1502
+ continue
1503
+ elif key == 'z': # toggle hkl labels on CIF ticks (non-blocking)
1504
+ # Check if CIF files exist before allowing this command
1505
+ has_cif = False
1506
+ try:
1507
+ has_cif = any(f.split(':')[0].lower().endswith('.cif') for f in args.files)
1508
+ if not has_cif and _bp is not None:
1509
+ has_cif = bool(getattr(_bp, 'cif_tick_series', None))
1510
+ except Exception:
1511
+ pass
1512
+ if not has_cif:
1513
+ print("Unknown option.")
1514
+ continue
1515
+ try:
1516
+ # Flip visibility flag in batplot module
1517
+ cur = bool(getattr(_bp, 'show_cif_hkl', False)) if _bp is not None else False
1518
+ if _bp is not None:
1519
+ setattr(_bp, 'show_cif_hkl', not cur)
1520
+ # Avoid re-entrant extension while redrawing
1521
+ prev_ext = bool(getattr(_bp, 'cif_extend_suspended', False)) if _bp is not None else False
1522
+ if _bp is not None:
1523
+ setattr(_bp, 'cif_extend_suspended', True)
1524
+ if hasattr(ax, '_cif_draw_func'):
1525
+ ax._cif_draw_func()
1526
+ if _bp is not None:
1527
+ setattr(_bp, 'cif_extend_suspended', prev_ext)
1528
+ # Count visible labels
1529
+ n_labels = 0
1530
+ if bool(getattr(_bp, 'show_cif_hkl', False)) and hasattr(ax, '_cif_tick_art'):
1531
+ for art in getattr(ax, '_cif_tick_art'):
1532
+ try:
1533
+ if hasattr(art, 'get_text') and '(' in art.get_text():
1534
+ n_labels += 1
1535
+ except Exception:
1536
+ pass
1537
+ print(f"CIF hkl labels {'ON' if bool(getattr(_bp,'show_cif_hkl', False)) else 'OFF'} (visible labels: {n_labels}).")
1538
+ except Exception as e:
1539
+ print(f"Error toggling hkl labels: {e}")
1540
+ continue
1541
+ elif key == 'h': # legend submenu
1542
+ try:
1543
+ while True:
1544
+ print("\n\033[1mLegend submenu:\033[0m")
1545
+ print(f" {colorize_menu('v: show/hide curve names')}")
1546
+ current_pos = _current_label_position()
1547
+ print(f" {colorize_menu(f's: legend position (current: {current_pos})')}")
1548
+ print(f" {colorize_menu('q: back to main menu')}")
1549
+ sub_key = _safe_input("Choose: ").strip().lower()
1550
+
1551
+ if sub_key == 'q':
1552
+ break
1553
+ elif sub_key == 'v':
1554
+ # Toggle curve name labels visibility
1555
+ push_state("legend-visibility")
1556
+ first_visible = label_text_objects[0].get_visible() if label_text_objects else True
1557
+ new_state = not first_visible
1558
+ for lbl in label_text_objects:
1559
+ lbl.set_visible(new_state)
1560
+ fig._curve_names_visible = new_state
1561
+ stack_label_bottom = getattr(fig, '_stack_label_at_bottom', False)
1562
+ update_labels(ax, y_data_list, label_text_objects, args.stack, stack_label_bottom)
1563
+ fig.canvas.draw_idle()
1564
+ print(f"Curve name labels {'ON' if new_state else 'OFF'}.")
1565
+ elif sub_key == 's':
1566
+ print("\nChoose legend position:")
1567
+ print(" 1: top-right")
1568
+ print(" 2: top-left")
1569
+ print(" 3: bottom-right")
1570
+ print(" 4: bottom-left")
1571
+ choice = _safe_input("Position (1-4, q=cancel): ").strip().lower()
1572
+ options = {
1573
+ '1': (False, False),
1574
+ '2': (False, True),
1575
+ '3': (True, False),
1576
+ '4': (True, True),
1577
+ }
1578
+ if not choice or choice == 'q':
1579
+ continue
1580
+ if choice in options:
1581
+ push_state("legend-position")
1582
+ bottom, left = options[choice]
1583
+ _apply_legend_position(bottom, left)
1584
+ new_pos = f"{'bottom' if bottom else 'top'}-{'left' if left else 'right'}"
1585
+ print(f"Legend position changed to {new_pos}.")
1586
+ else:
1587
+ print("Unknown option.")
1588
+ else:
1589
+ print("Unknown option.")
1590
+ except Exception as e:
1591
+ print(f"Error in legend submenu: {e}")
1592
+ continue
1593
+ elif key == 'j': # toggle CIF title labels (filename labels)
1594
+ # Check if CIF files exist before allowing this command
1595
+ has_cif = False
1596
+ try:
1597
+ has_cif = any(f.split(':')[0].lower().endswith('.cif') for f in args.files)
1598
+ if not has_cif and _bp is not None:
1599
+ has_cif = bool(getattr(_bp, 'cif_tick_series', None))
1600
+ except Exception:
1601
+ pass
1602
+ if not has_cif:
1603
+ print("Unknown option.")
1604
+ continue
1605
+ try:
1606
+ # Preserve both x and y-axis limits to prevent movement
1607
+ prev_xlim = ax.get_xlim()
1608
+ prev_ylim = ax.get_ylim()
1609
+ # Flip visibility flag for CIF titles
1610
+ cur = bool(getattr(_bp, 'show_cif_titles', True)) if _bp is not None else True
1611
+ new_state = not cur
1612
+ if _bp is not None:
1613
+ setattr(_bp, 'show_cif_titles', new_state)
1614
+ # Also store on figure for draw_cif_ticks to access
1615
+ fig._bp_show_cif_titles = new_state
1616
+ # Also update __main__ module for backward compatibility
1617
+ try:
1618
+ _bp_module = sys.modules.get('__main__')
1619
+ if _bp_module is not None:
1620
+ setattr(_bp_module, 'show_cif_titles', new_state)
1621
+ except Exception:
1622
+ pass
1623
+ # Avoid re-entrant extension while redrawing
1624
+ prev_ext = bool(getattr(_bp, 'cif_extend_suspended', False)) if _bp is not None else False
1625
+ if _bp is not None:
1626
+ setattr(_bp, 'cif_extend_suspended', True)
1627
+ if hasattr(ax, '_cif_draw_func'):
1628
+ ax._cif_draw_func()
1629
+ if _bp is not None:
1630
+ setattr(_bp, 'cif_extend_suspended', prev_ext)
1631
+ print(f"CIF title labels {'ON' if new_state else 'OFF'}.")
1632
+ # Push state for undo
1633
+ push_state("toggle-cif-titles")
1634
+ except Exception as e:
1635
+ print(f"Error toggling CIF titles: {e}")
1636
+ continue
1637
+ elif key == 'b': # <-- UNDO
1638
+ restore_state()
1639
+ continue
1640
+ elif key == 'n':
1641
+ try:
1642
+ toggle_crosshair()
1643
+ except Exception as e:
1644
+ print(f"Error toggling crosshair: {e}")
1645
+ continue
1646
+ elif key == 's':
1647
+ # Save current interactive session with numbered overwrite picker
1648
+ try:
1649
+ folder = choose_save_path(source_file_paths, purpose="project save")
1650
+ if not folder:
1651
+ print("Save canceled.")
1652
+ continue
1653
+ print(f"\nChosen path: {folder}")
1654
+ files = []
1655
+ try:
1656
+ files = sorted([f for f in os.listdir(folder) if f.lower().endswith('.pkl')])
1657
+ except Exception:
1658
+ files = []
1659
+ if files:
1660
+ print("Existing .pkl files:")
1661
+ for i, f in enumerate(files, 1):
1662
+ filepath = os.path.join(folder, f)
1663
+ timestamp = format_file_timestamp(filepath)
1664
+ if timestamp:
1665
+ print(f" {i}: {f} ({timestamp})")
1666
+ else:
1667
+ print(f" {i}: {f}")
1668
+ last_session_path = getattr(fig, '_last_session_save_path', None)
1669
+ if last_session_path:
1670
+ prompt = "Enter new filename (no ext needed), number to overwrite, or o to overwrite last (q=cancel): "
1671
+ else:
1672
+ prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
1673
+ choice = _safe_input(prompt).strip()
1674
+ if not choice or choice.lower() == 'q':
1675
+ print("Canceled.")
1676
+ continue
1677
+ if choice.lower() == 'o':
1678
+ # Overwrite last saved session
1679
+ if not last_session_path:
1680
+ print("No previous save found.")
1681
+ continue
1682
+ if not os.path.exists(last_session_path):
1683
+ print(f"Previous save file not found: {last_session_path}")
1684
+ continue
1685
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
1686
+ if yn != 'y':
1687
+ continue
1688
+ _bp_dump_session(
1689
+ last_session_path,
1690
+ fig=fig,
1691
+ ax=ax,
1692
+ x_data_list=x_data_list,
1693
+ y_data_list=y_data_list,
1694
+ orig_y=orig_y,
1695
+ offsets_list=offsets_list,
1696
+ labels=labels,
1697
+ delta=delta,
1698
+ args=args,
1699
+ tick_state=tick_state,
1700
+ cif_tick_series=(getattr(_bp, 'cif_tick_series', None) if _bp is not None else None),
1701
+ cif_hkl_map=(getattr(_bp, 'cif_hkl_map', None) if _bp is not None else None),
1702
+ cif_hkl_label_map=(getattr(_bp, 'cif_hkl_label_map', None) if _bp is not None else None),
1703
+ show_cif_hkl=(bool(getattr(_bp,'show_cif_hkl', False)) if _bp is not None else False),
1704
+ show_cif_titles=(bool(getattr(_bp,'show_cif_titles', True)) if _bp is not None else True),
1705
+ skip_confirm=True,
1706
+ )
1707
+ print(f"Overwritten session to {last_session_path}")
1708
+ continue
1709
+ target_path = None
1710
+ # Overwrite by number
1711
+ if choice.isdigit() and files:
1712
+ idx = int(choice)
1713
+ if 1 <= idx <= len(files):
1714
+ name = files[idx-1]
1715
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
1716
+ if yn != 'y':
1717
+ print("Canceled.")
1718
+ continue
1719
+ target_path = os.path.join(folder, name)
1720
+ skip_confirm = True # Already confirmed above
1721
+ _bp_dump_session(
1722
+ target_path,
1723
+ fig=fig,
1724
+ ax=ax,
1725
+ x_data_list=x_data_list,
1726
+ y_data_list=y_data_list,
1727
+ orig_y=orig_y,
1728
+ offsets_list=offsets_list,
1729
+ labels=labels,
1730
+ delta=delta,
1731
+ args=args,
1732
+ tick_state=tick_state,
1733
+ cif_tick_series=(getattr(_bp, 'cif_tick_series', None) if _bp is not None else None),
1734
+ cif_hkl_map=(getattr(_bp, 'cif_hkl_map', None) if _bp is not None else None),
1735
+ cif_hkl_label_map=(getattr(_bp, 'cif_hkl_label_map', None) if _bp is not None else None),
1736
+ show_cif_hkl=(bool(getattr(_bp,'show_cif_hkl', False)) if _bp is not None else False),
1737
+ show_cif_titles=(bool(getattr(_bp,'show_cif_titles', True)) if _bp is not None else True),
1738
+ skip_confirm=skip_confirm,
1739
+ )
1740
+ print(f"Saved session to {target_path}")
1741
+ fig._last_session_save_path = target_path
1742
+ continue
1743
+ else:
1744
+ print("Invalid number.")
1745
+ continue
1746
+ if choice.lower() != 'o':
1747
+ # New name, allow relative or absolute
1748
+ name = choice
1749
+ root, ext = os.path.splitext(name)
1750
+ if ext == '':
1751
+ name = name + '.pkl'
1752
+ target_path = name if os.path.isabs(name) else os.path.join(folder, name)
1753
+ skip_confirm = False # Let dump_session ask
1754
+ if os.path.exists(target_path):
1755
+ yn = _safe_input(f"'{os.path.basename(target_path)}' exists. Overwrite? (y/n): ").strip().lower()
1756
+ if yn != 'y':
1757
+ print("Canceled.")
1758
+ continue
1759
+ skip_confirm = True # Already confirmed
1760
+ # Delegate to session dumper
1761
+ _bp_dump_session(
1762
+ target_path,
1763
+ fig=fig,
1764
+ ax=ax,
1765
+ x_data_list=x_data_list,
1766
+ y_data_list=y_data_list,
1767
+ orig_y=orig_y,
1768
+ offsets_list=offsets_list,
1769
+ labels=labels,
1770
+ delta=delta,
1771
+ args=args,
1772
+ tick_state=tick_state,
1773
+ cif_tick_series=(getattr(_bp, 'cif_tick_series', None) if _bp is not None else None),
1774
+ cif_hkl_map=(getattr(_bp, 'cif_hkl_map', None) if _bp is not None else None),
1775
+ cif_hkl_label_map=(getattr(_bp, 'cif_hkl_label_map', None) if _bp is not None else None),
1776
+ show_cif_hkl=(bool(getattr(_bp,'show_cif_hkl', False)) if _bp is not None else False),
1777
+ show_cif_titles=(bool(getattr(_bp,'show_cif_titles', True)) if _bp is not None else True),
1778
+ skip_confirm=skip_confirm,
1779
+ )
1780
+ print(f"Saved session to {target_path}")
1781
+ fig._last_session_save_path = target_path
1782
+ except Exception as e:
1783
+ print(f"Error saving session: {e}")
1784
+ continue
1785
+ elif key == 'w': # hidden game remains on 'i'
1786
+ play_jump_game(); continue
1787
+ elif key == 'c':
1788
+ try:
1789
+ has_cif = False
1790
+ try:
1791
+ # Check for CIF files in args.files (handle colon syntax like file.cif:0.25448)
1792
+ has_cif = any(f.split(':')[0].lower().endswith('.cif') for f in args.files)
1793
+ # Also check if CIF tick series exists (more reliable)
1794
+ if not has_cif and _bp is not None:
1795
+ has_cif = bool(getattr(_bp, 'cif_tick_series', None))
1796
+ except Exception:
1797
+ pass
1798
+ while True:
1799
+ print("\033[1mColor menu:\033[0m")
1800
+ print(f" {colorize_menu('m : set curve colors (e.g., 1 red 2:u3 or 1:red 2:#00B006)')}")
1801
+ print(f" {colorize_menu('p : apply colormap palette to a range (e.g., 1-3 viridis)')}")
1802
+ print(f" {colorize_menu('s : spine/tick colors (e.g., w red a u3 or w:red a:#4561F7)')}")
1803
+ if has_cif and (_bp is not None and getattr(_bp, 'cif_tick_series', None)):
1804
+ print(f" {colorize_menu('t : change CIF tick set color (e.g., 1:red 2:#888888)')}")
1805
+ print(f" {colorize_menu('u : manage saved colors (use in m/p via number or u#)')}")
1806
+ print(f" {colorize_menu('q : return to main menu')}")
1807
+ sub = _safe_input(colorize_prompt("Choose (m/p/s/t/u/q): ")).strip().lower()
1808
+ if sub == 'q':
1809
+ break
1810
+ if sub == '':
1811
+ continue
1812
+ if sub == 'm':
1813
+ print("Current curves (q to cancel):")
1814
+ for idx, label in enumerate(labels):
1815
+ try:
1816
+ current_color = ax.lines[idx].get_color()
1817
+ except Exception:
1818
+ current_color = None
1819
+ print(f"{idx+1}: {color_block(current_color)} {label} ({current_color})")
1820
+ user_colors = get_user_color_list(fig)
1821
+ if user_colors:
1822
+ print("\nSaved colors (refer as number or u#):")
1823
+ for idx, color in enumerate(user_colors, 1):
1824
+ print(f" {idx}: {color_block(color)} {color}")
1825
+ color_input = _safe_input("Enter curve+color pairs (e.g., 1 red 2:u3) or q: ").strip()
1826
+ if not color_input or color_input.lower() == 'q':
1827
+ print("Canceled.")
1828
+ else:
1829
+ push_state("color-manual")
1830
+ entries = color_input.split()
1831
+ def _apply_manual_entries(tokens):
1832
+ idx_color_pairs = []
1833
+ i = 0
1834
+ while i < len(tokens):
1835
+ tok = tokens[i]
1836
+ if ':' in tok:
1837
+ idx_str, color = tok.split(':', 1)
1838
+ else:
1839
+ if i + 1 >= len(tokens):
1840
+ print(f"Skip incomplete entry: {tok}")
1841
+ break
1842
+ idx_str = tok
1843
+ color = tokens[i + 1]
1844
+ i += 1
1845
+ idx_color_pairs.append((idx_str, color))
1846
+ i += 1
1847
+ for idx_str, color in idx_color_pairs:
1848
+ try:
1849
+ line_idx = int(idx_str) - 1
1850
+ except ValueError:
1851
+ print(f"Bad index: {idx_str}")
1852
+ continue
1853
+ if not (0 <= line_idx < len(ax.lines)):
1854
+ print(f"Index out of range: {idx_str}")
1855
+ continue
1856
+ resolved = resolve_color_token(color, fig)
1857
+ ax.lines[line_idx].set_color(resolved)
1858
+ _apply_manual_entries(entries)
1859
+ # Update label colors to match new curve colors
1860
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
1861
+ # Manual edits override any palette history
1862
+ try:
1863
+ fig._curve_palette_history = []
1864
+ except Exception:
1865
+ pass
1866
+ fig.canvas.draw()
1867
+ elif sub == 'u':
1868
+ manage_user_colors(fig)
1869
+ continue
1870
+ elif sub == 's':
1871
+ print("Set spine/tick colors (w=top, a=left, s=bottom, d=right).")
1872
+ print(colorize_inline_commands("Example: w red a u3 OR w:red a:#4561F7"))
1873
+ user_colors = get_user_color_list(fig)
1874
+ if user_colors:
1875
+ print("\nSaved colors (enter number or u# in place of a color):")
1876
+ for idx, color in enumerate(user_colors, 1):
1877
+ print(f" {idx}: {color_block(color)} {color}")
1878
+ print("Type 'u' to edit saved colors.")
1879
+ line = _safe_input("Enter mappings (e.g., w red a u3) or q: ").strip()
1880
+ if line.lower() == 'u':
1881
+ manage_user_colors(fig)
1882
+ continue
1883
+ if not line or line.lower() == 'q':
1884
+ print("Canceled.")
1885
+ else:
1886
+ push_state("color-spine")
1887
+ key_to_spine = {'w': 'top', 'a': 'left', 's': 'bottom', 'd': 'right'}
1888
+ tokens = line.split()
1889
+ pairs = []
1890
+ i = 0
1891
+ while i < len(tokens):
1892
+ tok = tokens[i]
1893
+ if ':' in tok:
1894
+ key_part, color = tok.split(':', 1)
1895
+ else:
1896
+ if i + 1 >= len(tokens):
1897
+ print(f"Skip incomplete entry: {tok}")
1898
+ break
1899
+ key_part = tok
1900
+ color = tokens[i + 1]
1901
+ i += 1
1902
+ pairs.append((key_part.lower(), color))
1903
+ i += 1
1904
+ for key_part, color in pairs:
1905
+ key_part = key_part.lower()
1906
+ if key_part not in key_to_spine:
1907
+ print(f"Unknown key: {key_part} (use w/a/s/d)")
1908
+ continue
1909
+ spine_name = key_to_spine[key_part]
1910
+ if spine_name not in ax.spines:
1911
+ print(f"Spine '{spine_name}' not found.")
1912
+ continue
1913
+ try:
1914
+ resolved = resolve_color_token(color, fig)
1915
+ ax.spines[spine_name].set_edgecolor(resolved)
1916
+ if spine_name in ('top', 'bottom'):
1917
+ ax.tick_params(axis='x', which='both', colors=resolved)
1918
+ ax.xaxis.label.set_color(resolved)
1919
+ else:
1920
+ ax.tick_params(axis='y', which='both', colors=resolved)
1921
+ ax.yaxis.label.set_color(resolved)
1922
+ print(f"Set {spine_name} spine to {color_block(resolved)} {resolved}")
1923
+ if spine_name == 'top':
1924
+ position_top_xlabel()
1925
+ elif spine_name == 'right':
1926
+ position_right_ylabel()
1927
+ except Exception as e:
1928
+ print(f"Error setting {spine_name} color: {e}")
1929
+ fig.canvas.draw()
1930
+ elif sub == 't' and has_cif and (_bp is not None and getattr(_bp, 'cif_tick_series', None)):
1931
+ cts = getattr(_bp, 'cif_tick_series', [])
1932
+ print("Current CIF tick sets:")
1933
+ for i,(lab, fname, *_rest) in enumerate(cts):
1934
+ print(f" {i+1}: {lab} ({os.path.basename(fname)})")
1935
+ line = _safe_input("Enter mappings (e.g., 1:red 2:#555555) or q: ").strip()
1936
+ if not line or line.lower()=='q':
1937
+ print("Canceled.")
1938
+ else:
1939
+ mappings = line.split()
1940
+ for token in mappings:
1941
+ if ':' not in token:
1942
+ print(f"Skip malformed token: {token}")
1943
+ continue
1944
+ idx_s, col = token.split(':',1)
1945
+ try:
1946
+ idx_i = int(idx_s)-1
1947
+ if 0 <= idx_i < len(cts):
1948
+ lab,fname,peaksQ,wl,qmax_sim,_c = cts[idx_i]
1949
+ cts[idx_i] = (lab,fname,peaksQ,wl,qmax_sim,col)
1950
+ else:
1951
+ print(f"Index out of range: {idx_s}")
1952
+ except ValueError:
1953
+ print(f"Bad index: {idx_s}")
1954
+ setattr(_bp, 'cif_tick_series', cts)
1955
+ if hasattr(ax,'_cif_draw_func'):
1956
+ ax._cif_draw_func()
1957
+ fig.canvas.draw()
1958
+ elif sub == 'p':
1959
+ # Show current palette if one is applied
1960
+ history = getattr(fig, '_curve_palette_history', [])
1961
+ current_palette = history[-1]['palette'] if history else None
1962
+ if current_palette:
1963
+ print(f"Current palette: {current_palette}")
1964
+ base_palettes = ['viridis', 'cividis', 'plasma', 'inferno', 'magma', 'batlow']
1965
+ extras = []
1966
+ def _palette_available(name: str) -> bool:
1967
+ if name in plt.colormaps():
1968
+ return True
1969
+ lower = name.lower()
1970
+ if lower.startswith('batlow'):
1971
+ return ensure_colormap(name)
1972
+ return False
1973
+ if 'turbo' in plt.colormaps():
1974
+ extras.append('turbo')
1975
+ for extra in ('batlowK', 'batlowW'):
1976
+ if _palette_available(extra):
1977
+ extras.append(extra)
1978
+ palette_options = base_palettes + extras[:3]
1979
+ desc_map = {
1980
+ 'viridis': 'Perceptually uniform (blue→yellow)',
1981
+ 'cividis': 'Perceptually uniform (blue→olive)',
1982
+ 'plasma': 'Perceptually uniform (purple→yellow)',
1983
+ 'inferno': 'High-contrast (dark→bright)',
1984
+ 'magma': 'Soft dark-to-light purple',
1985
+ 'batlow': 'Colorblind-friendly sequential',
1986
+ 'turbo': 'Vibrant rainbow (Google Turbo)',
1987
+ 'batlowK': 'Dark-to-light variant of batlow',
1988
+ 'batlowW': 'Warm variant of batlow',
1989
+ }
1990
+ palette_index = {str(i): name for i, name in enumerate(palette_options, 1)}
1991
+ print("Common perceptually uniform palettes (numbers optional):")
1992
+ for idx, name in enumerate(palette_options, 1):
1993
+ bar = palette_preview(name)
1994
+ desc = desc_map.get(name, '')
1995
+ extra = f" - {desc}" if desc else ''
1996
+ print(f" {idx}. {name}{extra}")
1997
+ if bar:
1998
+ print(f" {bar}")
1999
+ print(colorize_inline_commands("Example: 1-4 viridis or: all magma_r or: 1-3,5 plasma, _r for reverse"))
2000
+ line = _safe_input("Enter range(s) and palette (number or name, e.g., '1-3 2' or 'all 1_r') or q: ").strip()
2001
+ if not line or line.lower() == 'q':
2002
+ print("Canceled.")
2003
+ else:
2004
+ parts = line.split()
2005
+ if len(parts) < 2:
2006
+ print("Need range(s) and palette.")
2007
+ else:
2008
+ palette_name = parts[-1]
2009
+ def _resolve_palette_token(token: str) -> str:
2010
+ suffix = ''
2011
+ base = token
2012
+ if token.lower().endswith('_r'):
2013
+ suffix = '_r'
2014
+ base = token[:-2]
2015
+ if base in palette_index:
2016
+ return palette_index[base] + suffix
2017
+ return token
2018
+ palette_name = _resolve_palette_token(palette_name)
2019
+ range_part = " ".join(parts[:-1]).replace(" ", "")
2020
+ def parse_ranges(spec, total):
2021
+ spec = spec.lower()
2022
+ if spec == 'all':
2023
+ return list(range(total))
2024
+ result = set()
2025
+ tokens = spec.split(',')
2026
+ for tok in tokens:
2027
+ if not tok:
2028
+ continue
2029
+ if '-' in tok:
2030
+ try:
2031
+ a, b = tok.split('-', 1)
2032
+ start = int(a) - 1
2033
+ end = int(b) - 1
2034
+ if start > end:
2035
+ start, end = end, start
2036
+ for i in range(start, end + 1):
2037
+ if 0 <= i < total:
2038
+ result.add(i)
2039
+ except ValueError:
2040
+ print(f"Bad range token: {tok}")
2041
+ else:
2042
+ try:
2043
+ i = int(tok) - 1
2044
+ if 0 <= i < total:
2045
+ result.add(i)
2046
+ else:
2047
+ print(f"Index out of range: {tok}")
2048
+ except ValueError:
2049
+ print(f"Bad index token: {tok}")
2050
+ return sorted(result)
2051
+ indices = parse_ranges(range_part, len(ax.lines))
2052
+ if not indices:
2053
+ print("No valid indices parsed.")
2054
+ else:
2055
+ # ====================================================================
2056
+ # APPLY COLOR PALETTE TO MULTIPLE CURVES
2057
+ # ====================================================================
2058
+ # This section applies a colormap (like 'viridis') to selected curves,
2059
+ # assigning each curve a different color that smoothly transitions
2060
+ # across the colormap.
2061
+ #
2062
+ # HOW IT WORKS:
2063
+ # 1. Get the continuous colormap (e.g., 'viridis')
2064
+ # 2. Sample colors at evenly spaced positions along the colormap
2065
+ # 3. Assign each sampled color to a different curve
2066
+ #
2067
+ # Example with 5 curves and 'viridis':
2068
+ # Curve 1 → position 0.08 → dark purple
2069
+ # Curve 2 → position 0.27 → blue-purple
2070
+ # Curve 3 → position 0.46 → green
2071
+ # Curve 4 → position 0.65 → yellow-green
2072
+ # Curve 5 → position 0.85 → bright yellow
2073
+ #
2074
+ # WHY CLIP THE RANGE (0.08 to 0.85)?
2075
+ # The very start (0.0) and end (1.0) of colormaps are often too dark
2076
+ # or too bright. Clipping to 0.08-0.85 gives better visual contrast
2077
+ # and ensures all colors are visible.
2078
+ # ====================================================================
2079
+
2080
+ # Ensure colormap is registered (makes it available for use)
2081
+ ensure_colormap(palette_name)
2082
+ cmap = None
2083
+
2084
+ # STEP 1: Try to get colormap from matplotlib's built-in colormaps
2085
+ # This handles standard colormaps like 'viridis', 'plasma', 'inferno', etc.
2086
+ try:
2087
+ import matplotlib.cm as cm
2088
+ # Get the continuous colormap (without specifying N)
2089
+ # This allows us to sample directly from the continuous colormap
2090
+ # without quantization issues
2091
+ cmap = cm.get_cmap(palette_name)
2092
+ except (ValueError, Exception):
2093
+ pass
2094
+
2095
+ # STEP 2: Fallback - try cmcrameri package for scientific colormaps
2096
+ # cmcrameri provides colorblind-friendly colormaps like 'batlow', 'batlowk', etc.
2097
+ if cmap is None and palette_name.lower().startswith("batlow"):
2098
+ try:
2099
+ import importlib
2100
+ cmc = importlib.import_module('cmcrameri.cm')
2101
+ attr = palette_name.lower()
2102
+ if hasattr(cmc, attr):
2103
+ cmap = getattr(cmc, attr)
2104
+ elif hasattr(cmc, 'batlow'):
2105
+ cmap = getattr(cmc, 'batlow')
2106
+ except Exception:
2107
+ pass
2108
+
2109
+ # STEP 3: Final fallback - create from custom colormaps defined in color_utils
2110
+ if cmap is None:
2111
+ base_name = palette_name.lower()
2112
+ # Handle reversed colormaps (remove '_r' suffix)
2113
+ if base_name.endswith('_r'):
2114
+ base_name = base_name[:-2]
2115
+ custom_colors = _CUSTOM_CMAPS.get(base_name)
2116
+ if custom_colors:
2117
+ from matplotlib.colors import LinearSegmentedColormap
2118
+ # Create a continuous colormap by interpolating between custom colors
2119
+ # N=256 means create 256 intermediate colors for smooth gradient
2120
+ cmap = LinearSegmentedColormap.from_list(base_name, custom_colors, N=256)
2121
+ # If user requested reversed version, reverse it now
2122
+ if palette_name.lower().endswith('_r'):
2123
+ cmap = cmap.reversed()
2124
+
2125
+ # Check if we successfully got a colormap
2126
+ if cmap is None:
2127
+ print(f"Unknown colormap '{palette_name}'.")
2128
+ else:
2129
+ # Save current state for undo functionality
2130
+ push_state("color-palette")
2131
+
2132
+ # Get number of selected curves
2133
+ nsel = len(indices)
2134
+
2135
+ # Define color sampling range (clipped to avoid too dark/bright extremes)
2136
+ low_clip = 0.08 # Start sampling at 8% into colormap (avoids very dark colors)
2137
+ high_clip = 0.85 # End sampling at 85% into colormap (avoids very bright colors)
2138
+
2139
+ # STEP 4: Sample colors from colormap at evenly spaced positions
2140
+ if nsel == 1:
2141
+ # Single curve: use middle of colormap (good visibility)
2142
+ colors = [cmap(0.55)]
2143
+ elif nsel == 2:
2144
+ # Two curves: use clipped range endpoints (maximum contrast)
2145
+ colors = [cmap(low_clip), cmap(high_clip)]
2146
+ else:
2147
+ # Multiple curves: sample evenly across clipped range
2148
+ # np.linspace creates evenly spaced positions from low_clip to high_clip
2149
+ # Example with 5 curves: [0.08, 0.27, 0.46, 0.65, 0.85]
2150
+ positions = np.linspace(low_clip, high_clip, nsel)
2151
+ # Sample color at each position
2152
+ # cmap(position) returns an RGBA color tuple for that position
2153
+ colors = [cmap(p) for p in positions]
2154
+
2155
+ # STEP 5: Apply colors to the selected curves
2156
+ # Loop through selected curve indices and assign corresponding color
2157
+ for c_idx, line_idx in enumerate(indices):
2158
+ # c_idx = index in colors array (0, 1, 2, ...)
2159
+ # line_idx = index of line in ax.lines (the actual matplotlib line object)
2160
+ ax.lines[line_idx].set_color(colors[c_idx])
2161
+ # Update label colors to match new curve colors
2162
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
2163
+ fig.canvas.draw()
2164
+ try:
2165
+ applied_preview = color_bar([mcolors.to_hex(c) for c in colors])
2166
+ except Exception:
2167
+ applied_preview = ""
2168
+ print(f"Applied '{palette_name}' to curves: " +
2169
+ ", ".join(str(i+1) for i in indices))
2170
+ if applied_preview:
2171
+ print(f" {applied_preview}")
2172
+ # Record palette usage for style export
2173
+ try:
2174
+ history = list(getattr(fig, '_curve_palette_history', []))
2175
+ except Exception:
2176
+ history = []
2177
+ entry = {
2178
+ 'palette': palette_name,
2179
+ 'indices': [i + 1 for i in indices],
2180
+ 'low_clip': low_clip,
2181
+ 'high_clip': high_clip,
2182
+ }
2183
+ history.append(entry)
2184
+ fig._curve_palette_history = history
2185
+ else:
2186
+ print("Unknown color submenu option.")
2187
+ except Exception as e:
2188
+ print(f"Error in color menu: {e}")
2189
+ elif key == 'r':
2190
+ try:
2191
+ has_cif = False
2192
+ try:
2193
+ # Check for CIF files in args.files (handle colon syntax like file.cif:0.25448)
2194
+ has_cif = any(f.split(':')[0].lower().endswith('.cif') for f in args.files)
2195
+ # Also check if CIF tick series exists (more reliable)
2196
+ if not has_cif and _bp is not None:
2197
+ has_cif = bool(getattr(_bp, 'cif_tick_series', None))
2198
+ except Exception:
2199
+ pass
2200
+ while True:
2201
+ rename_opts = "c=curve"
2202
+ if has_cif:
2203
+ rename_opts += ", t=cif tick label"
2204
+ rename_opts += ", x=x-axis, y=y-axis, q=return"
2205
+ mode = _safe_input(f"Rename ({rename_opts}): ").strip().lower()
2206
+ if mode == 'q':
2207
+ break
2208
+ if mode == '':
2209
+ continue
2210
+ if mode == 'c':
2211
+ print("Tip: Use LaTeX/mathtext for special characters:")
2212
+ print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
2213
+ print(" Bullet: $\\bullet$ → • | Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
2214
+ print(" Shortcuts: g{super(-1)} → g$^{\\mathrm{-1}}$ | Li{sub(2)}O → Li$_{\\mathrm{2}}$O")
2215
+ idx_in = _safe_input("Curve number to rename (q=cancel): ").strip()
2216
+ if not idx_in or idx_in.lower() == 'q':
2217
+ print("Canceled.")
2218
+ continue
2219
+ try:
2220
+ idx = int(idx_in) - 1
2221
+ except ValueError:
2222
+ print("Invalid index.")
2223
+ continue
2224
+ if not (0 <= idx < len(labels)):
2225
+ print("Invalid index.")
2226
+ continue
2227
+ new_label = _safe_input("New curve label (q=cancel): ")
2228
+ if not new_label or new_label.lower() == 'q':
2229
+ print("Canceled.")
2230
+ continue
2231
+ new_label = convert_label_shortcuts(new_label)
2232
+ push_state("rename-curve")
2233
+ labels[idx] = new_label
2234
+ label_text_objects[idx].set_text(f"{idx+1}: {new_label}")
2235
+ fig.canvas.draw()
2236
+ elif mode == 't':
2237
+ cts = getattr(_bp, 'cif_tick_series', None) if _bp is not None else None
2238
+ if not cts:
2239
+ print("No CIF tick sets to rename.")
2240
+ continue
2241
+ for i,(lab, fname, *_rest) in enumerate(cts):
2242
+ print(f" {i+1}: {lab} ({os.path.basename(fname)})")
2243
+ s = _safe_input("CIF tick number to rename (q=cancel): ").strip()
2244
+ if not s or s.lower()=='q':
2245
+ print("Canceled."); continue
2246
+ try:
2247
+ idx = int(s)-1
2248
+ if not (0 <= idx < len(cts)):
2249
+ print("Index out of range."); continue
2250
+ except ValueError:
2251
+ print("Bad index."); continue
2252
+ print("Tip: Use LaTeX/mathtext for special characters:")
2253
+ print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
2254
+ print(" Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
2255
+ print(" Shortcuts: g{super(-1)} → g$^{\\mathrm{-1}}$ | Li{sub(2)}O → Li$_{\\mathrm{2}}$O")
2256
+ new_name = _safe_input("New CIF tick label (q=cancel): ")
2257
+ if not new_name or new_name.lower()=='q':
2258
+ print("Canceled."); continue
2259
+ new_name = convert_label_shortcuts(new_name)
2260
+ lab,fname,peaksQ,wl,qmax_sim,color = cts[idx]
2261
+ # Suspend extension while updating label
2262
+ if _bp is not None:
2263
+ setattr(_bp, 'cif_extend_suspended', True)
2264
+ if hasattr(ax, '_cif_tick_art'):
2265
+ try:
2266
+ for art in list(getattr(ax, '_cif_tick_art', [])):
2267
+ try:
2268
+ art.remove()
2269
+ except Exception:
2270
+ pass
2271
+ ax._cif_tick_art = []
2272
+ except Exception:
2273
+ pass
2274
+ cts[idx] = (new_name, fname, peaksQ, wl, qmax_sim, color)
2275
+ setattr(_bp, 'cif_tick_series', cts)
2276
+ if hasattr(ax,'_cif_draw_func'): ax._cif_draw_func()
2277
+ fig.canvas.draw()
2278
+ if _bp is not None:
2279
+ setattr(_bp, 'cif_extend_suspended', False)
2280
+ elif mode in ('x','y'):
2281
+ print("Enter new axis label (q=cancel).")
2282
+ print("Tip: Use LaTeX/mathtext for special characters:")
2283
+ print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
2284
+ print(" Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
2285
+ print(" Shortcuts: g{super(-1)} → g$^{\\mathrm{-1}}$ | Li{sub(2)}O → Li$_{\\mathrm{2}}$O")
2286
+ new_axis = _safe_input("New axis label: ")
2287
+ if not new_axis or new_axis.lower() == 'q':
2288
+ print("Canceled.")
2289
+ continue
2290
+ new_axis = convert_label_shortcuts(new_axis)
2291
+ new_axis = normalize_label_text(new_axis)
2292
+ push_state("rename-axis")
2293
+ # Freeze layout and preserve current pad via one-shot pending to avoid drift
2294
+ try:
2295
+ fig.set_layout_engine('none')
2296
+ except Exception:
2297
+ try:
2298
+ fig.set_tight_layout(False)
2299
+ except Exception:
2300
+ pass
2301
+ try:
2302
+ fig.set_constrained_layout(False)
2303
+ except Exception:
2304
+ pass
2305
+ if mode == 'x':
2306
+ # Preserve current pad exactly once after rename
2307
+ try:
2308
+ ax._pending_xlabelpad = getattr(ax.xaxis, 'labelpad', None)
2309
+ except Exception:
2310
+ pass
2311
+ ax.xaxis.label.set_text(new_axis)
2312
+ position_top_xlabel()
2313
+ position_bottom_xlabel()
2314
+ else:
2315
+ try:
2316
+ ax._pending_ylabelpad = getattr(ax.yaxis, 'labelpad', None)
2317
+ except Exception:
2318
+ pass
2319
+ ax.yaxis.label.set_text(new_axis)
2320
+ position_right_ylabel()
2321
+ position_left_ylabel()
2322
+ sync_fonts()
2323
+ fig.canvas.draw()
2324
+ else:
2325
+ print("Invalid choice.")
2326
+ # loop continues until q
2327
+ except Exception as e:
2328
+ print(f"Error: {e}")
2329
+ elif key == 'a':
2330
+ try:
2331
+ if not args.stack:
2332
+ print('Be careful, changing the arrangement may lead to a mess! If you want to rearrange the curves, use "--stack".')
2333
+ print("Current curve order:")
2334
+ for idx, label in enumerate(labels):
2335
+ print(f"{idx+1}: {label}")
2336
+ new_order_str = _safe_input("Enter new order (space-separated indices, q=cancel): ").strip()
2337
+ if not new_order_str or new_order_str.lower() == 'q':
2338
+ print("Canceled.")
2339
+ continue
2340
+ new_order = [int(i)-1 for i in new_order_str.strip().split()]
2341
+ if len(new_order) != len(labels):
2342
+ print("Error: Number of indices does not match number of curves.")
2343
+ continue
2344
+ if any(i < 0 or i >= len(labels) for i in new_order):
2345
+ print("Error: Invalid index in order list.")
2346
+ continue
2347
+
2348
+ push_state("rearrange")
2349
+
2350
+ original_styles = []
2351
+ for ln in ax.lines:
2352
+ original_styles.append({
2353
+ "color": ln.get_color(),
2354
+ "linewidth": ln.get_linewidth(),
2355
+ "linestyle": ln.get_linestyle(),
2356
+ "alpha": ln.get_alpha(),
2357
+ "marker": ln.get_marker(),
2358
+ "markersize": ln.get_markersize(),
2359
+ "markerfacecolor": ln.get_markerfacecolor(),
2360
+ "markeredgecolor": ln.get_markeredgecolor()
2361
+ })
2362
+ reordered_styles = [original_styles[i] for i in new_order]
2363
+ xlim_current = ax.get_xlim()
2364
+
2365
+ x_data_list[:] = [x_data_list[i] for i in new_order]
2366
+ orig_y[:] = [orig_y[i] for i in new_order]
2367
+ y_data_list[:] = [y_data_list[i] for i in new_order]
2368
+ labels[:] = [labels[i] for i in new_order]
2369
+ label_text_objects[:] = [label_text_objects[i] for i in new_order]
2370
+ x_full_list[:] = [x_full_list[i] for i in new_order]
2371
+ raw_y_full_list[:] = [raw_y_full_list[i] for i in new_order]
2372
+ offsets_list[:] = [offsets_list[i] for i in new_order]
2373
+
2374
+ if args.stack:
2375
+ offset_local = 0.0
2376
+ for i, (x_plot, y_norm, style) in enumerate(zip(x_data_list, orig_y, reordered_styles)):
2377
+ y_plot_offset = y_norm + offset_local
2378
+ y_data_list[i] = y_plot_offset
2379
+ offsets_list[i] = offset_local
2380
+ ln = ax.lines[i]
2381
+ ln.set_data(x_plot, y_plot_offset)
2382
+ ln.set_color(style["color"])
2383
+ ln.set_linewidth(style["linewidth"])
2384
+ ln.set_linestyle(style["linestyle"])
2385
+ ln.set_alpha(style["alpha"])
2386
+ ln.set_marker(style["marker"])
2387
+ ln.set_markersize(style["markersize"])
2388
+ ln.set_markerfacecolor(style["markerfacecolor"])
2389
+ ln.set_markeredgecolor(style["markeredgecolor"])
2390
+ y_range = (y_norm.max() - y_norm.min()) if y_norm.size else 0.0
2391
+ gap = y_range + (delta * (y_range if args.autoscale else 1.0))
2392
+ offset_local -= gap
2393
+ else:
2394
+ offset_local = 0.0
2395
+ for i, (x_plot, y_norm, style) in enumerate(zip(x_data_list, orig_y, reordered_styles)):
2396
+ y_plot_offset = y_norm + offset_local
2397
+ y_data_list[i] = y_plot_offset
2398
+ offsets_list[i] = offset_local
2399
+ ln = ax.lines[i]
2400
+ ln.set_data(x_plot, y_plot_offset)
2401
+ ln.set_color(style["color"])
2402
+ ln.set_linewidth(style["linewidth"])
2403
+ ln.set_linestyle(style["linestyle"])
2404
+ ln.set_alpha(style["alpha"])
2405
+ ln.set_marker(style["marker"])
2406
+ ln.set_markersize(style["markersize"])
2407
+ ln.set_markerfacecolor(style["markerfacecolor"])
2408
+ ln.set_markeredgecolor(style["markeredgecolor"])
2409
+ increment = (y_norm.max() - y_norm.min()) * delta if (args.autoscale and y_norm.size) else delta
2410
+ offset_local += increment
2411
+
2412
+ for i, (txt, lab) in enumerate(zip(label_text_objects, labels)):
2413
+ txt.set_text(f"{i+1}: {lab}")
2414
+ # Preserve current axis titles (respect 't' menu toggles like bt/lt)
2415
+ ax.set_xlim(xlim_current)
2416
+ # Do not reset xlabel/ylabel here; rearrange should not change title visibility
2417
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
2418
+ fig.canvas.draw()
2419
+ except Exception as e:
2420
+ print(f"Error rearranging curves: {e}")
2421
+ elif key == 'x':
2422
+ while True:
2423
+ try:
2424
+ current_xlim = ax.get_xlim()
2425
+ print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
2426
+ rng = _safe_input("Enter new X range (min max), w=upper only, s=lower only, 'full', or 'a'=auto (restore original) (q=back): ").strip()
2427
+ if not rng or rng.lower() == 'q':
2428
+ break
2429
+ if rng.lower() == 'w':
2430
+ # Upper only: change upper limit, fix lower - stay in loop
2431
+ while True:
2432
+ current_xlim = ax.get_xlim()
2433
+ print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
2434
+ val = _safe_input(f"Enter new upper X limit (current lower: {current_xlim[0]:.6g}, q=back): ").strip()
2435
+ if not val or val.lower() == 'q':
2436
+ break
2437
+ try:
2438
+ new_upper = float(val)
2439
+ except (ValueError, KeyboardInterrupt):
2440
+ print("Invalid value, ignored.")
2441
+ continue
2442
+ push_state("xrange")
2443
+ ax.set_xlim(current_xlim[0], new_upper)
2444
+ ax.relim()
2445
+ ax.autoscale_view(scalex=False, scaley=True)
2446
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
2447
+ try:
2448
+ if hasattr(ax, '_cif_extend_func'):
2449
+ ax._cif_extend_func(ax.get_xlim()[1])
2450
+ except Exception:
2451
+ pass
2452
+ try:
2453
+ if hasattr(ax, '_cif_draw_func'):
2454
+ ax._cif_draw_func()
2455
+ except Exception:
2456
+ pass
2457
+ fig.canvas.draw()
2458
+ print(f"X range updated: {ax.get_xlim()[0]:.6g} to {ax.get_xlim()[1]:.6g}")
2459
+ continue
2460
+ if rng.lower() == 's':
2461
+ # Lower only: change lower limit, fix upper - stay in loop
2462
+ while True:
2463
+ current_xlim = ax.get_xlim()
2464
+ print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
2465
+ val = _safe_input(f"Enter new lower X limit (current upper: {current_xlim[1]:.6g}, q=back): ").strip()
2466
+ if not val or val.lower() == 'q':
2467
+ break
2468
+ try:
2469
+ new_lower = float(val)
2470
+ except (ValueError, KeyboardInterrupt):
2471
+ print("Invalid value, ignored.")
2472
+ continue
2473
+ push_state("xrange")
2474
+ ax.set_xlim(new_lower, current_xlim[1])
2475
+ ax.relim()
2476
+ ax.autoscale_view(scalex=False, scaley=True)
2477
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
2478
+ try:
2479
+ if hasattr(ax, '_cif_extend_func'):
2480
+ ax._cif_extend_func(ax.get_xlim()[1])
2481
+ except Exception:
2482
+ pass
2483
+ try:
2484
+ if hasattr(ax, '_cif_draw_func'):
2485
+ ax._cif_draw_func()
2486
+ except Exception:
2487
+ pass
2488
+ fig.canvas.draw()
2489
+ print(f"X range updated: {ax.get_xlim()[0]:.6g} to {ax.get_xlim()[1]:.6g}")
2490
+ continue
2491
+ if rng.lower() == 'a':
2492
+ # Auto: restore original range from x_full_list
2493
+ push_state("xrange-auto")
2494
+ if x_full_list:
2495
+ new_min = min(xf.min() for xf in x_full_list if xf.size)
2496
+ new_max = max(xf.max() for xf in x_full_list if xf.size)
2497
+ else:
2498
+ print("No original data available.")
2499
+ continue
2500
+ # Restore all data
2501
+ for i in range(len(labels)):
2502
+ xf = x_full_list[i]; yf_raw = raw_y_full_list[i]
2503
+ mask = (xf>=new_min) & (xf<=new_max)
2504
+ x_sub = xf[mask]; y_sub_raw = yf_raw[mask]
2505
+ if x_sub.size == 0:
2506
+ ax.lines[i].set_data([], [])
2507
+ y_data_list[i] = np.array([]); orig_y[i] = np.array([]); continue
2508
+ should_normalize = args.stack or getattr(args, 'norm', False)
2509
+ if should_normalize:
2510
+ if y_sub_raw.size:
2511
+ y_min = float(y_sub_raw.min())
2512
+ y_max = float(y_sub_raw.max())
2513
+ span = y_max - y_min
2514
+ if span > 0:
2515
+ y_sub_norm = (y_sub_raw - y_min) / span
2516
+ else:
2517
+ y_sub_norm = np.zeros_like(y_sub_raw)
2518
+ else:
2519
+ y_sub_norm = y_sub_raw
2520
+ else:
2521
+ y_sub_norm = y_sub_raw
2522
+ offset_val = offsets_list[i]
2523
+ y_with_offset = y_sub_norm + offset_val
2524
+ ax.lines[i].set_data(x_sub, y_with_offset)
2525
+ x_data_list[i] = x_sub
2526
+ y_data_list[i] = y_with_offset
2527
+ orig_y[i] = y_sub_norm
2528
+ ax.set_xlim(new_min, new_max)
2529
+ ax.relim(); ax.autoscale_view(scalex=False, scaley=True)
2530
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
2531
+ try:
2532
+ if hasattr(ax, '_cif_extend_func'):
2533
+ ax._cif_extend_func(ax.get_xlim()[1])
2534
+ except Exception:
2535
+ pass
2536
+ try:
2537
+ if hasattr(ax, '_cif_draw_func'):
2538
+ ax._cif_draw_func()
2539
+ except Exception:
2540
+ pass
2541
+ fig.canvas.draw()
2542
+ print(f"X range restored to original: {ax.get_xlim()[0]:.6g} to {ax.get_xlim()[1]:.6g}")
2543
+ continue
2544
+ push_state("xrange")
2545
+ if rng.lower() == 'full':
2546
+ new_min = min(xf.min() for xf in x_full_list if xf.size)
2547
+ new_max = max(xf.max() for xf in x_full_list if xf.size)
2548
+ else:
2549
+ new_min, new_max = map(float, rng.split())
2550
+ ax.set_xlim(new_min, new_max)
2551
+ for i in range(len(labels)):
2552
+ xf = x_full_list[i]; yf_raw = raw_y_full_list[i]
2553
+ mask = (xf>=new_min) & (xf<=new_max)
2554
+ x_sub = xf[mask]; y_sub_raw = yf_raw[mask]
2555
+ if x_sub.size == 0:
2556
+ ax.lines[i].set_data([], [])
2557
+ y_data_list[i] = np.array([]); orig_y[i] = np.array([]); continue
2558
+ # Auto-normalize for --stack mode, or explicit --norm flag
2559
+ should_normalize = args.stack or getattr(args, 'norm', False)
2560
+ if should_normalize:
2561
+ if y_sub_raw.size:
2562
+ y_min = float(y_sub_raw.min())
2563
+ y_max = float(y_sub_raw.max())
2564
+ span = y_max - y_min
2565
+ if span > 0:
2566
+ y_sub_norm = (y_sub_raw - y_min) / span
2567
+ else:
2568
+ y_sub_norm = np.zeros_like(y_sub_raw)
2569
+ else:
2570
+ y_sub_norm = y_sub_raw
2571
+ else:
2572
+ y_sub_norm = y_sub_raw
2573
+ offset_val = offsets_list[i]
2574
+ y_with_offset = y_sub_norm + offset_val
2575
+ ax.lines[i].set_data(x_sub, y_with_offset)
2576
+ x_data_list[i] = x_sub
2577
+ y_data_list[i] = y_with_offset
2578
+ orig_y[i] = y_sub_norm
2579
+ ax.relim(); ax.autoscale_view(scalex=False, scaley=True)
2580
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
2581
+ # Extend CIF ticks after x-range change
2582
+ try:
2583
+ if hasattr(ax, '_cif_extend_func'):
2584
+ ax._cif_extend_func(ax.get_xlim()[1])
2585
+ except Exception:
2586
+ pass
2587
+ try:
2588
+ if hasattr(ax, '_cif_draw_func'):
2589
+ ax._cif_draw_func()
2590
+ except Exception:
2591
+ pass
2592
+ fig.canvas.draw()
2593
+ except Exception as e:
2594
+ print(f"Error setting X-axis range: {e}")
2595
+ elif key == 'y': # <-- Y-RANGE HANDLER (now only reachable if not args.stack)
2596
+ while True:
2597
+ try:
2598
+ current_ylim = ax.get_ylim()
2599
+ print(f"Current Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
2600
+ rng = _safe_input("Enter new Y range (min max), w=upper only, s=lower only, 'auto', 'a'=auto (restore original), or 'full' (q=back): ").strip().lower()
2601
+ if not rng or rng == 'q':
2602
+ break
2603
+ if rng == 'w':
2604
+ # Upper only: change upper limit, fix lower - stay in loop
2605
+ while True:
2606
+ current_ylim = ax.get_ylim()
2607
+ print(f"Current Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
2608
+ val = _safe_input(f"Enter new upper Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
2609
+ if not val or val.lower() == 'q':
2610
+ break
2611
+ try:
2612
+ new_upper = float(val)
2613
+ except (ValueError, KeyboardInterrupt):
2614
+ print("Invalid value, ignored.")
2615
+ continue
2616
+ push_state("yrange")
2617
+ ax.set_ylim(current_ylim[0], new_upper)
2618
+ ax.relim()
2619
+ ax.autoscale_view(scalex=False, scaley=True)
2620
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
2621
+ fig.canvas.draw_idle()
2622
+ print(f"Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
2623
+ if rng == 'w':
2624
+ continue
2625
+ if rng == 's':
2626
+ # Lower only: change lower limit, fix upper - stay in loop
2627
+ while True:
2628
+ current_ylim = ax.get_ylim()
2629
+ print(f"Current Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
2630
+ val = _safe_input(f"Enter new lower Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
2631
+ if not val or val.lower() == 'q':
2632
+ break
2633
+ try:
2634
+ new_lower = float(val)
2635
+ except (ValueError, KeyboardInterrupt):
2636
+ print("Invalid value, ignored.")
2637
+ continue
2638
+ push_state("yrange")
2639
+ ax.set_ylim(new_lower, current_ylim[1])
2640
+ ax.relim()
2641
+ ax.autoscale_view(scalex=False, scaley=True)
2642
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
2643
+ fig.canvas.draw_idle()
2644
+ print(f"Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
2645
+ if rng == 's':
2646
+ continue
2647
+ if rng == 'a':
2648
+ # Auto: restore original range from y_data_list
2649
+ push_state("yrange-auto")
2650
+ if y_data_list:
2651
+ all_min = None
2652
+ all_max = None
2653
+ for arr in y_data_list:
2654
+ if arr.size:
2655
+ mn = float(arr.min())
2656
+ mx = float(arr.max())
2657
+ all_min = mn if all_min is None else min(all_min, mn)
2658
+ all_max = mx if all_max is None else max(all_max, mx)
2659
+ if all_min is None or all_max is None:
2660
+ print("No original data available.")
2661
+ continue
2662
+ ax.set_ylim(all_min, all_max)
2663
+ ax.relim()
2664
+ ax.autoscale_view(scalex=False, scaley=True)
2665
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
2666
+ fig.canvas.draw_idle()
2667
+ print(f"Y range restored to original: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
2668
+ else:
2669
+ print("No original data available.")
2670
+ continue
2671
+ push_state("yrange")
2672
+ if rng == 'auto':
2673
+ ax.relim()
2674
+ ax.autoscale_view(scalex=False, scaley=True)
2675
+ else:
2676
+ if rng == 'full':
2677
+ all_min = None
2678
+ all_max = None
2679
+ for arr in y_data_list:
2680
+ if arr.size:
2681
+ mn = float(arr.min())
2682
+ mx = float(arr.max())
2683
+ all_min = mn if all_min is None else min(all_min, mn)
2684
+ all_max = mx if all_max is None else max(all_max, mx)
2685
+ if all_min is None or all_max is None:
2686
+ print("No data to compute full Y range.")
2687
+ continue
2688
+ y_min, y_max = all_min, all_max
2689
+ else:
2690
+ parts = rng.split()
2691
+ if len(parts) != 2:
2692
+ print("Need exactly two numbers for Y range.")
2693
+ continue
2694
+ y_min, y_max = map(float, parts)
2695
+ if y_min == y_max:
2696
+ print("Warning: min == max; expanding slightly.")
2697
+ eps = abs(y_min)*1e-6 if y_min != 0 else 1e-6
2698
+ y_min -= eps
2699
+ y_max += eps
2700
+ ax.set_ylim(y_min, y_max)
2701
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
2702
+ fig.canvas.draw_idle()
2703
+ print(f"Y range set to {ax.get_ylim()}")
2704
+ except Exception as e:
2705
+ print(f"Error setting Y-axis range: {e}")
2706
+ elif key == 'd': # <-- DELTA / OFFSET HANDLER (now only reachable if not args.stack)
2707
+ print("\n\033[1mOffset adjustment menu:\033[0m")
2708
+ print(f" {colorize_menu('1-{}: adjust individual curve offset'.format(len(labels)))}")
2709
+ print(f" {colorize_menu('a: set spacing between curves')}")
2710
+ print(f" {colorize_menu('r: reset all offsets to 0')}")
2711
+ print(f" {colorize_menu('d: change delta spacing (original behavior)')}")
2712
+ print(f" {colorize_menu('q: back to main menu')}")
2713
+
2714
+ while True:
2715
+ offset_cmd = _safe_input("Offset> ").strip().lower()
2716
+
2717
+ if offset_cmd == 'q' or offset_cmd == '':
2718
+ break
2719
+
2720
+ elif offset_cmd == 'r':
2721
+ # Reset all offsets to 0
2722
+ try:
2723
+ push_state("reset-offsets")
2724
+ for i in range(len(labels)):
2725
+ if i >= len(ax.lines):
2726
+ continue
2727
+ # Get current x-data from the line
2728
+ current_x = np.asarray(ax.lines[i].get_xdata(), dtype=float)
2729
+ # Reset to normalized data without any offset
2730
+ y_norm = orig_y[i]
2731
+ y_data_list[i] = y_norm.copy()
2732
+ offsets_list[i] = 0.0
2733
+ # Update x_data_list to match current line data
2734
+ x_data_list[i] = current_x.copy()
2735
+ ax.lines[i].set_data(current_x, y_norm)
2736
+
2737
+ ax.relim()
2738
+ ax.autoscale_view(scalex=False, scaley=True)
2739
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
2740
+ fig.canvas.draw()
2741
+ print("All offsets reset to 0")
2742
+ except Exception as e:
2743
+ print(f"Error resetting offsets: {e}")
2744
+
2745
+ elif offset_cmd == 'a':
2746
+ # Set spacing between curves (separates all curves)
2747
+ try:
2748
+ if len(labels) <= 1:
2749
+ print("Warning: Only one curve loaded; spacing cannot be applied.")
2750
+ continue
2751
+
2752
+ # Calculate current spacing (average difference between consecutive offsets)
2753
+ current_spacing = 0.0
2754
+ if len(offsets_list) > 1:
2755
+ spacing_diffs = []
2756
+ sorted_indices = sorted(range(len(offsets_list)), key=lambda i: offsets_list[i] if i < len(offsets_list) else 0.0)
2757
+ for j in range(len(sorted_indices) - 1):
2758
+ idx1, idx2 = sorted_indices[j], sorted_indices[j + 1]
2759
+ off1 = offsets_list[idx1] if idx1 < len(offsets_list) else 0.0
2760
+ off2 = offsets_list[idx2] if idx2 < len(offsets_list) else 0.0
2761
+ spacing_diffs.append(abs(off2 - off1))
2762
+ if spacing_diffs:
2763
+ current_spacing = sum(spacing_diffs) / len(spacing_diffs)
2764
+
2765
+ spacing_input = _safe_input("Enter spacing value between curves (current avg: {:.4g}): ".format(current_spacing)).strip()
2766
+ if not spacing_input:
2767
+ print("Canceled.")
2768
+ continue
2769
+
2770
+ spacing_value = float(spacing_input)
2771
+ push_state("curve-spacing")
2772
+
2773
+ # Apply spacing to separate all curves
2774
+ # Find the minimum current offset to use as baseline
2775
+ min_offset = min(offsets_list) if offsets_list else 0.0
2776
+
2777
+ # Sort curves by their current offset to maintain order
2778
+ curve_order = sorted(range(len(labels)), key=lambda i: offsets_list[i] if i < len(offsets_list) else 0.0)
2779
+
2780
+ # Apply cumulative spacing starting from the minimum offset
2781
+ current_offset = min_offset
2782
+ for i, curve_idx in enumerate(curve_order):
2783
+ if curve_idx >= len(ax.lines):
2784
+ continue
2785
+ # Get current x-data from the line
2786
+ current_x = np.asarray(ax.lines[curve_idx].get_xdata(), dtype=float)
2787
+ y_norm = orig_y[curve_idx]
2788
+
2789
+ # Set new offset with spacing
2790
+ offsets_list[curve_idx] = current_offset
2791
+ y_with_offset = y_norm + current_offset
2792
+ y_data_list[curve_idx] = y_with_offset
2793
+ x_data_list[curve_idx] = current_x.copy()
2794
+ ax.lines[curve_idx].set_data(current_x, y_with_offset)
2795
+
2796
+ # Calculate spacing for next curve based on current curve's range
2797
+ if i < len(curve_order) - 1: # Not the last curve
2798
+ y_range = (y_norm.max() - y_norm.min()) if y_norm.size else 0.0
2799
+ if args.stack:
2800
+ # In stack mode, spacing is relative to curve range
2801
+ gap = y_range + (spacing_value * (y_range if args.autoscale else 1.0))
2802
+ current_offset -= gap
2803
+ else:
2804
+ # In normal mode, spacing is absolute or relative
2805
+ increment = (y_range * spacing_value) if (args.autoscale and y_norm.size) else spacing_value
2806
+ current_offset += increment
2807
+
2808
+ ax.relim()
2809
+ ax.autoscale_view(scalex=False, scaley=True)
2810
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
2811
+ fig.canvas.draw()
2812
+ print("Spacing of {:.4g} applied to separate all curves".format(spacing_value))
2813
+
2814
+ except ValueError:
2815
+ print("Invalid spacing value")
2816
+ except Exception as e:
2817
+ print(f"Error applying spacing: {e}")
2818
+
2819
+ elif offset_cmd == 'd':
2820
+ # Original delta spacing behavior
2821
+ if len(labels) <= 1:
2822
+ print("Warning: Only one curve loaded; applying an offset is not recommended.")
2823
+ try:
2824
+ new_delta_str = _safe_input(f"Enter new offset spacing (current={delta}): ").strip()
2825
+ if not new_delta_str:
2826
+ print("Canceled.")
2827
+ continue
2828
+ new_delta = float(new_delta_str)
2829
+ push_state("delta-spacing")
2830
+ delta = new_delta
2831
+ offsets_list[:] = []
2832
+ if args.stack:
2833
+ current_offset = 0.0
2834
+ for i, y_norm in enumerate(orig_y):
2835
+ if i >= len(ax.lines):
2836
+ continue
2837
+ # Get current x-data from the line
2838
+ current_x = np.asarray(ax.lines[i].get_xdata(), dtype=float)
2839
+ y_with_offset = y_norm + current_offset
2840
+ y_data_list[i] = y_with_offset
2841
+ offsets_list.append(current_offset)
2842
+ # Update x_data_list to match current line data
2843
+ x_data_list[i] = current_x.copy()
2844
+ ax.lines[i].set_data(current_x, y_with_offset)
2845
+ y_range = (y_norm.max() - y_norm.min()) if y_norm.size else 0.0
2846
+ gap = y_range + (delta * (y_range if args.autoscale else 1.0))
2847
+ current_offset -= gap
2848
+ else:
2849
+ current_offset = 0.0
2850
+ for i, y_norm in enumerate(orig_y):
2851
+ if i >= len(ax.lines):
2852
+ continue
2853
+ # Get current x-data from the line
2854
+ current_x = np.asarray(ax.lines[i].get_xdata(), dtype=float)
2855
+ y_with_offset = y_norm + current_offset
2856
+ y_data_list[i] = y_with_offset
2857
+ offsets_list.append(current_offset)
2858
+ # Update x_data_list to match current line data
2859
+ x_data_list[i] = current_x.copy()
2860
+ ax.lines[i].set_data(current_x, y_with_offset)
2861
+ increment = (y_norm.max() - y_norm.min()) * delta if (args.autoscale and y_norm.size) else delta
2862
+ current_offset += increment
2863
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
2864
+ ax.relim(); ax.autoscale_view(scalex=False, scaley=True)
2865
+ fig.canvas.draw()
2866
+ print(f"Offsets updated with delta={delta}")
2867
+ except ValueError:
2868
+ print("Invalid delta value")
2869
+ except Exception as e:
2870
+ print(f"Error updating offsets: {e}")
2871
+
2872
+ elif offset_cmd.isdigit():
2873
+ # Adjust individual curve offset
2874
+ try:
2875
+ curve_num = int(offset_cmd)
2876
+ if curve_num < 1 or curve_num > len(labels):
2877
+ print("Invalid curve number (1-{})".format(len(labels)))
2878
+ continue
2879
+
2880
+ idx = curve_num - 1
2881
+ if idx >= len(ax.lines):
2882
+ print("Invalid curve number.")
2883
+ continue
2884
+
2885
+ current_offset = offsets_list[idx] if idx < len(offsets_list) else 0.0
2886
+
2887
+ individual_offset_input = _safe_input("Enter offset for curve {} (current: {:.4g}): ".format(
2888
+ curve_num, current_offset)).strip()
2889
+ if not individual_offset_input:
2890
+ print("Canceled.")
2891
+ continue
2892
+
2893
+ individual_offset = float(individual_offset_input)
2894
+ push_state("curve-{}-offset".format(curve_num))
2895
+
2896
+ # Get current x-data from the line to ensure we're working with actual displayed data
2897
+ current_x = np.asarray(ax.lines[idx].get_xdata(), dtype=float)
2898
+ # Apply individual offset to this curve
2899
+ y_norm = orig_y[idx]
2900
+ offsets_list[idx] = individual_offset
2901
+ y_with_offset = y_norm + individual_offset
2902
+ y_data_list[idx] = y_with_offset
2903
+ # Update x_data_list to match current line data
2904
+ x_data_list[idx] = current_x.copy()
2905
+ ax.lines[idx].set_data(current_x, y_with_offset)
2906
+
2907
+ ax.relim()
2908
+ ax.autoscale_view(scalex=False, scaley=True)
2909
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
2910
+ fig.canvas.draw()
2911
+ print("Curve {} offset set to: {:.4g}".format(curve_num, individual_offset))
2912
+
2913
+ except ValueError:
2914
+ print("Invalid offset value")
2915
+ except Exception as e:
2916
+ print(f"Error setting curve offset: {e}")
2917
+ else:
2918
+ print("Unknown command. Use 1-{}, a, r, d, or q".format(len(labels)))
2919
+ elif key == 'l':
2920
+ try:
2921
+ def _select_lines(ax_obj, prompt_text):
2922
+ total = len(ax_obj.lines)
2923
+ if total == 0:
2924
+ print("No curves to modify.")
2925
+ return []
2926
+ print(f"Total curves available: {total}")
2927
+ raw = _safe_input(prompt_text + " ").strip().lower()
2928
+ if not raw or raw in ('all', '*'):
2929
+ return list(range(total))
2930
+ import re as _re
2931
+ tokens = [tok for tok in _re.split(r'[,\s]+', raw) if tok]
2932
+ selected = []
2933
+ for tok in tokens:
2934
+ try:
2935
+ idx = int(tok) - 1
2936
+ if 0 <= idx < total:
2937
+ if idx not in selected:
2938
+ selected.append(idx)
2939
+ else:
2940
+ print(f"Index out of range: {tok}")
2941
+ except ValueError:
2942
+ print(f"Skipping invalid token: {tok}")
2943
+ return selected
2944
+
2945
+ def _prompt_float(prompt_text):
2946
+ raw = _safe_input(prompt_text).strip()
2947
+ if not raw:
2948
+ return None
2949
+ if raw.lower() == 'q':
2950
+ return None
2951
+ try:
2952
+ return float(raw)
2953
+ except ValueError:
2954
+ print("Invalid number, using default.")
2955
+ return None
2956
+
2957
+ def _prompt_dash_pattern(kind='dash'):
2958
+ if kind == 'dashdot':
2959
+ raw = _safe_input("Dash-dot pattern 'dash gap dot gap' (blank=6 3 1 3, q=cancel): ").strip().lower()
2960
+ default = (6.0, 3.0, 1.0, 3.0)
2961
+ else:
2962
+ raw = _safe_input("Dash pattern 'length gap' (blank=6 3, q=cancel): ").strip().lower()
2963
+ default = (6.0, 3.0)
2964
+ if not raw:
2965
+ return default
2966
+ if raw == 'q':
2967
+ print("Canceled.")
2968
+ return None
2969
+ import re as _re
2970
+ tokens = [tok for tok in _re.split(r'[,\s]+', raw) if tok]
2971
+ try:
2972
+ if kind == 'dashdot':
2973
+ if len(tokens) == 2:
2974
+ dash = float(tokens[0]); gap = float(tokens[1])
2975
+ dot = min(dash * 0.2, 2.0)
2976
+ return (dash, gap, dot, gap)
2977
+ elif len(tokens) >= 4:
2978
+ return tuple(float(tokens[i]) for i in range(4))
2979
+ else:
2980
+ if len(tokens) == 1:
2981
+ val = float(tokens[0])
2982
+ return (val, val)
2983
+ elif len(tokens) >= 2:
2984
+ return (float(tokens[0]), float(tokens[1]))
2985
+ except ValueError:
2986
+ print("Invalid dash pattern.")
2987
+ return None
2988
+ print("Invalid dash pattern.")
2989
+ return None
2990
+
2991
+ while True:
2992
+ print("\033[1mLine submenu:\033[0m")
2993
+ print(f" {colorize_menu('c : change curve line widths')}")
2994
+ print(f" {colorize_menu('f : change frame (axes spines) and tick widths')}")
2995
+ print(f" {colorize_menu('g : toggle grid lines')}")
2996
+ print(f" {colorize_menu('l : show only lines (no markers) for selected curves')}")
2997
+ print(f" {colorize_menu('ld : show line and dots for selected curves')}")
2998
+ print(f" {colorize_menu('d : show only dots for selected curves')}")
2999
+ print(f" {colorize_menu('da : dashed line for selected curves')}")
3000
+ print(f" {colorize_menu('dd : dashed line + dots for selected curves')}")
3001
+ print(f" {colorize_menu('q : return')}")
3002
+ sub = _safe_input(colorize_prompt("Choose (c/f/g/l/ld/d/da/dd/q): ")).strip().lower()
3003
+ if sub == 'q':
3004
+ break
3005
+ if sub == '':
3006
+ continue
3007
+ if sub == 'c':
3008
+ spec = _safe_input("Curve widths (single value OR mappings like '1:1.2 3:2', q=cancel): ").strip()
3009
+ if not spec or spec.lower() == 'q':
3010
+ print("Canceled.")
3011
+ else:
3012
+ push_state("linewidth")
3013
+ if ":" in spec:
3014
+ parts = spec.split()
3015
+ for p in parts:
3016
+ if ":" not in p:
3017
+ print(f"Skip malformed token: {p}")
3018
+ continue
3019
+ idx_str, lw_str = p.split(":", 1)
3020
+ try:
3021
+ idx = int(idx_str) - 1
3022
+ lw = float(lw_str)
3023
+ if 0 <= idx < len(ax.lines):
3024
+ ax.lines[idx].set_linewidth(lw)
3025
+ else:
3026
+ print(f"Index out of range: {idx+1}")
3027
+ except ValueError:
3028
+ print(f"Bad token: {p}")
3029
+ else:
3030
+ try:
3031
+ lw = float(spec)
3032
+ for ln in ax.lines:
3033
+ ln.set_linewidth(lw)
3034
+ except ValueError:
3035
+ print("Invalid width value.")
3036
+ fig.canvas.draw()
3037
+ elif sub == 'f':
3038
+ fw_in = _safe_input("Enter frame/tick width (e.g., 1.5) or 'm M' (major minor) or q: ").strip()
3039
+ if not fw_in or fw_in.lower() == 'q':
3040
+ print("Canceled.")
3041
+ else:
3042
+ push_state("framewidth")
3043
+ parts = fw_in.split()
3044
+ try:
3045
+ if len(parts) == 1:
3046
+ frame_w = float(parts[0])
3047
+ tick_major = frame_w
3048
+ tick_minor = frame_w * 0.6
3049
+ else:
3050
+ frame_w = float(parts[0])
3051
+ tick_major = float(parts[1])
3052
+ tick_minor = float(tick_major) * 0.7
3053
+ for sp in ax.spines.values():
3054
+ sp.set_linewidth(frame_w)
3055
+ ax.tick_params(which='major', width=tick_major)
3056
+ ax.tick_params(which='minor', width=tick_minor)
3057
+ fig.canvas.draw()
3058
+ print(f"Set frame width={frame_w}, major tick width={tick_major}, minor tick width={tick_minor}")
3059
+ except ValueError:
3060
+ print("Invalid numeric value(s).")
3061
+ elif sub == 'g':
3062
+ push_state("grid")
3063
+ # Toggle grid state - check if any gridlines are visible
3064
+ current_grid = False
3065
+ try:
3066
+ # Check if grid is currently on by looking at gridline visibility
3067
+ for line in ax.get_xgridlines() + ax.get_ygridlines():
3068
+ if line.get_visible():
3069
+ current_grid = True
3070
+ break
3071
+ except Exception:
3072
+ current_grid = ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else False
3073
+
3074
+ new_grid_state = not current_grid
3075
+ if new_grid_state:
3076
+ # Enable grid with light styling
3077
+ ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
3078
+ else:
3079
+ # Disable grid (no style parameters when disabling)
3080
+ ax.grid(False)
3081
+ fig.canvas.draw()
3082
+ print(f"Grid {'enabled' if new_grid_state else 'disabled'}.")
3083
+ elif sub == 'l':
3084
+ targets = _select_lines(ax, "line-only targets (numbers or 'all'):")
3085
+ if not targets:
3086
+ continue
3087
+ push_state("line-only")
3088
+ for idx in targets:
3089
+ ln = ax.lines[idx]
3090
+ ln.set_linestyle('-')
3091
+ ln.set_marker('None')
3092
+ fig.canvas.draw()
3093
+ print(f"Applied line-only style to curves: {', '.join(str(i+1) for i in targets)}")
3094
+ elif sub == 'ld':
3095
+ targets = _select_lines(ax, "line+dots targets (numbers or 'all'):")
3096
+ if not targets:
3097
+ continue
3098
+ push_state("line+dots")
3099
+ custom_msize = _prompt_float("Marker size (blank=auto ~3*lw): ")
3100
+ for idx in targets:
3101
+ ln = ax.lines[idx]
3102
+ lw = ln.get_linewidth() or 1.0
3103
+ ln.set_linestyle('-')
3104
+ ln.set_marker('o')
3105
+ msize = custom_msize if custom_msize is not None else max(3.0, lw * 3.0)
3106
+ ln.set_markersize(msize)
3107
+ col = ln.get_color()
3108
+ try:
3109
+ ln.set_markerfacecolor(col)
3110
+ ln.set_markeredgecolor(col)
3111
+ except Exception:
3112
+ pass
3113
+ fig.canvas.draw()
3114
+ print(f"Applied line+dots style to curves: {', '.join(str(i+1) for i in targets)}")
3115
+ elif sub == 'd':
3116
+ targets = _select_lines(ax, "dots-only targets (numbers or 'all'):")
3117
+ if not targets:
3118
+ continue
3119
+ push_state("dots-only")
3120
+ custom_msize = _prompt_float("Marker size (blank=auto ~3*lw): ")
3121
+ for idx in targets:
3122
+ ln = ax.lines[idx]
3123
+ lw = ln.get_linewidth() or 1.0
3124
+ ln.set_linestyle('None')
3125
+ ln.set_marker('o')
3126
+ msize = custom_msize if custom_msize is not None else max(3.0, lw * 3.0)
3127
+ ln.set_markersize(msize)
3128
+ col = ln.get_color()
3129
+ try:
3130
+ ln.set_markerfacecolor(col)
3131
+ ln.set_markeredgecolor(col)
3132
+ except Exception:
3133
+ pass
3134
+ fig.canvas.draw()
3135
+ print(f"Applied dots-only style to curves: {', '.join(str(i+1) for i in targets)}")
3136
+ elif sub == 'da':
3137
+ targets = _select_lines(ax, "dashed-line targets (numbers or 'all'):")
3138
+ if not targets:
3139
+ continue
3140
+ dash_vals = _prompt_dash_pattern()
3141
+ if dash_vals is None:
3142
+ continue
3143
+ dash_len, gap_len = dash_vals
3144
+ push_state("dashed-line")
3145
+ for idx in targets:
3146
+ ln = ax.lines[idx]
3147
+ ln.set_marker('None')
3148
+ ln.set_linestyle((0, (dash_len, gap_len)))
3149
+ fig.canvas.draw()
3150
+ print(f"Applied dashed lines to curves: {', '.join(str(i+1) for i in targets)}")
3151
+ elif sub == 'dd':
3152
+ targets = _select_lines(ax, "dash-dot targets (numbers or 'all'):")
3153
+ if not targets:
3154
+ continue
3155
+ dash_vals = _prompt_dash_pattern(kind='dashdot')
3156
+ if dash_vals is None:
3157
+ continue
3158
+ push_state("dash-dot")
3159
+ for idx in targets:
3160
+ ln = ax.lines[idx]
3161
+ ln.set_marker('None')
3162
+ ln.set_linestyle((0, dash_vals))
3163
+ fig.canvas.draw()
3164
+ print(f"Applied dash-dot style to curves: {', '.join(str(i+1) for i in targets)}")
3165
+ else:
3166
+ print("Unknown submenu option.")
3167
+ except Exception as e:
3168
+ print(f"Error setting widths: {e}")
3169
+ elif key == 'f':
3170
+ cur_family = plt.rcParams.get('font.sans-serif', [''])[0]
3171
+ cur_size = plt.rcParams.get('font.size', None)
3172
+ while True:
3173
+ subkey = _safe_input(colorize_prompt(f"Font submenu (current: family='{cur_family}', size={cur_size}) - s=size, f=family, q=return: ")).strip().lower()
3174
+ if subkey == 'q':
3175
+ break
3176
+ if subkey == '':
3177
+ continue
3178
+ if subkey == 's':
3179
+ try:
3180
+ cur_size = plt.rcParams.get('font.size', None)
3181
+ fs = _safe_input(f"Enter new font size (current: {cur_size}, q=cancel): ").strip()
3182
+ if not fs or fs.lower() == 'q':
3183
+ print("Canceled.")
3184
+ else:
3185
+ push_state("font-change")
3186
+ fs_val = float(fs)
3187
+ apply_font_changes(new_size=fs_val)
3188
+ # Reposition top/right labels to match new tick label sizes
3189
+ position_top_xlabel()
3190
+ position_right_ylabel()
3191
+ fig.canvas.draw()
3192
+ except Exception as e:
3193
+ print(f"Error changing font size: {e}")
3194
+ elif subkey == 'f':
3195
+ try:
3196
+ cur_family = plt.rcParams.get('font.sans-serif', [''])[0]
3197
+ print("Common publication fonts:")
3198
+ print(" 1) Arial")
3199
+ print(" 2) Helvetica")
3200
+ print(" 3) Times New Roman")
3201
+ print(" 4) STIXGeneral")
3202
+ print(" 5) DejaVu Sans")
3203
+ ft_raw = _safe_input(f"Enter font number or family name (current: '{cur_family}', q=cancel): ").strip()
3204
+ if not ft_raw or ft_raw.lower() == 'q':
3205
+ print("Canceled.")
3206
+ else:
3207
+ font_map = {
3208
+ '1': 'Arial',
3209
+ '2': 'Helvetica',
3210
+ '3': 'Times New Roman',
3211
+ '4': 'STIXGeneral',
3212
+ '5': 'DejaVu Sans'
3213
+ }
3214
+ ft = font_map.get(ft_raw, ft_raw)
3215
+ push_state("font-change")
3216
+ print(f"Setting font family to: {ft}")
3217
+ apply_font_changes(new_family=ft)
3218
+ # Reposition top/right labels to match new tick label sizes
3219
+ position_top_xlabel()
3220
+ position_right_ylabel()
3221
+ fig.canvas.draw()
3222
+ except Exception as e:
3223
+ print(f"Error changing font family: {e}")
3224
+ else:
3225
+ print("Invalid font submenu option.")
3226
+ elif key == 'g':
3227
+ try:
3228
+ while True:
3229
+ choice = _safe_input(colorize_prompt("Resize submenu: (p=plot frame, c=canvas, q=cancel): ")).strip().lower()
3230
+ if not choice:
3231
+ continue
3232
+ if choice == 'q':
3233
+ break
3234
+ if choice == 'p':
3235
+ push_state("resize-frame")
3236
+ resize_plot_frame()
3237
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
3238
+ elif choice == 'c':
3239
+ push_state("resize-canvas")
3240
+ resize_canvas()
3241
+ else:
3242
+ print("Unknown option.")
3243
+ except Exception as e:
3244
+ print(f"Error in resize submenu: {e}")
3245
+ elif key == 'h':
3246
+ # Legend submenu
3247
+ try:
3248
+ while True:
3249
+ print("\nLegend submenu:")
3250
+ print(" v: show/hide curve names")
3251
+ current_pos = "bottom-right" if getattr(fig, '_stack_label_at_bottom', False) else "top-right"
3252
+ print(f" s: legend position (current: {current_pos})")
3253
+ print(" q: back to main menu")
3254
+ sub_key = _safe_input("Choose: ").strip().lower()
3255
+
3256
+ if sub_key == 'q':
3257
+ break
3258
+ elif sub_key == 'v':
3259
+ push_state("curve-names")
3260
+ # Check current visibility from first label
3261
+ current_visible = True
3262
+ if label_text_objects and len(label_text_objects) > 0:
3263
+ try:
3264
+ current_visible = label_text_objects[0].get_visible()
3265
+ except Exception:
3266
+ current_visible = True
3267
+
3268
+ # Toggle all labels
3269
+ new_visible = not current_visible
3270
+ for txt in label_text_objects:
3271
+ try:
3272
+ txt.set_visible(new_visible)
3273
+ except Exception:
3274
+ pass
3275
+
3276
+ # Store state on figure for persistence
3277
+ fig._curve_names_visible = new_visible
3278
+
3279
+ status = "shown" if new_visible else "hidden"
3280
+ print(f"Curve names {status}")
3281
+ stack_label_bottom = getattr(fig, '_stack_label_at_bottom', False)
3282
+ update_labels(ax, y_data_list, label_text_objects, args.stack, stack_label_bottom)
3283
+ try:
3284
+ fig.canvas.draw()
3285
+ except Exception:
3286
+ fig.canvas.draw_idle()
3287
+ elif sub_key == 's':
3288
+ push_state("label-position")
3289
+ # Toggle label position between top-right and bottom-right
3290
+ current_bottom = getattr(fig, '_stack_label_at_bottom', False)
3291
+ fig._stack_label_at_bottom = not current_bottom
3292
+ new_pos = "bottom-right" if fig._stack_label_at_bottom else "top-right"
3293
+ update_labels(ax, y_data_list, label_text_objects, args.stack, fig._stack_label_at_bottom)
3294
+ print(f"Legend position changed to {new_pos}.")
3295
+ try:
3296
+ fig.canvas.draw()
3297
+ except Exception:
3298
+ fig.canvas.draw_idle()
3299
+ else:
3300
+ print("Unknown option.")
3301
+ except Exception as e:
3302
+ print(f"Error in legend submenu: {e}")
3303
+ elif key == 't':
3304
+ try:
3305
+ while True:
3306
+ print("\033[1mToggle help:\033[0m")
3307
+ print(colorize_inline_commands(" wasd choose side: w=top, a=left, s=bottom, d=right"))
3308
+ print(colorize_inline_commands(" 1..5 choose what: 1=spine line, 2=major ticks, 3=minor ticks, 4=labels, 5=axis title"))
3309
+ print(colorize_inline_commands(" Combine letter+number to toggle, e.g. 's2 w5 a4' (case-insensitive)"))
3310
+ print(colorize_inline_commands(" i = invert tick direction, l = change tick length, list = show state, q = return"))
3311
+ print(colorize_inline_commands(" p = adjust title offsets (w=top, s=bottom, a=left, d=right)"))
3312
+ cmd = _safe_input(colorize_prompt("Enter code(s): ")).strip().lower()
3313
+ if not cmd:
3314
+ continue
3315
+ if cmd == 'q':
3316
+ break
3317
+ if cmd == 'i':
3318
+ # Invert tick direction (toggle between 'out' and 'in')
3319
+ push_state("tick-direction")
3320
+ current_dir = getattr(fig, '_tick_direction', 'out')
3321
+ new_dir = 'in' if current_dir == 'out' else 'out'
3322
+ setattr(fig, '_tick_direction', new_dir)
3323
+ ax.tick_params(axis='both', which='both', direction=new_dir)
3324
+ print(f"Tick direction: {new_dir}")
3325
+ try:
3326
+ fig.canvas.draw()
3327
+ except Exception:
3328
+ fig.canvas.draw_idle()
3329
+ continue
3330
+ if cmd == 'p':
3331
+ _title_offset_menu()
3332
+ continue
3333
+ if cmd == 'l':
3334
+ # Change tick length (major and minor automatically set to 70%)
3335
+ try:
3336
+ # Get current major tick length from axes
3337
+ current_major = ax.xaxis.get_major_ticks()[0].tick1line.get_markersize() if ax.xaxis.get_major_ticks() else 4.0
3338
+ print(f"Current major tick length: {current_major}")
3339
+ new_length_str = _safe_input("Enter new major tick length (e.g., 6.0): ").strip()
3340
+ if not new_length_str:
3341
+ continue
3342
+ new_major = float(new_length_str)
3343
+ if new_major <= 0:
3344
+ print("Length must be positive.")
3345
+ continue
3346
+ new_minor = new_major * 0.7 # Auto-set minor to 70%
3347
+ push_state("tick-length")
3348
+ # Apply to all four axes
3349
+ ax.tick_params(axis='both', which='major', length=new_major)
3350
+ ax.tick_params(axis='both', which='minor', length=new_minor)
3351
+ # Store for persistence
3352
+ if not hasattr(fig, '_tick_lengths'):
3353
+ fig._tick_lengths = {}
3354
+ fig._tick_lengths.update({'major': new_major, 'minor': new_minor})
3355
+ print(f"Set major tick length: {new_major}, minor: {new_minor:.2f}")
3356
+ try:
3357
+ fig.canvas.draw()
3358
+ except Exception:
3359
+ fig.canvas.draw_idle()
3360
+ except ValueError:
3361
+ print("Invalid number.")
3362
+ except Exception as e:
3363
+ print(f"Error setting tick length: {e}")
3364
+ continue
3365
+ parts = cmd.split()
3366
+ if parts == ['list']:
3367
+ print_tick_state()
3368
+ continue
3369
+ push_state("tick-toggle")
3370
+ # Track which sides need re-positioning of axis titles
3371
+ need_pos = {
3372
+ 'bottom': False, # bottom X title spacing
3373
+ 'top': False, # top X duplicate title
3374
+ 'left': False, # left Y title spacing
3375
+ 'right': False, # right Y duplicate title
3376
+ }
3377
+ # New key aliases -> legacy/internal codes
3378
+ alias_map = {
3379
+ # Spines
3380
+ 's1':'bl', 'w1':'tl', 'a1':'ll', 'd1':'rl',
3381
+ # Major tick marks
3382
+ 's2':'btcs', 'w2':'ttcs', 'a2':'ltcs', 'd2':'rtcs',
3383
+ # Minor ticks
3384
+ 's3':'mbx', 'w3':'mtx', 'a3':'mly', 'd3':'mry',
3385
+ # Labels
3386
+ 's4':'blb', 'w4':'tlb', 'a4':'llb', 'd4':'rlb',
3387
+ # Axis titles
3388
+ 's5':'bt', 'w5':'tt', 'a5':'lt', 'd5':'rt',
3389
+ # Small typo tolerance
3390
+ 'tics':'ttcs',
3391
+ }
3392
+ for p in parts:
3393
+ if p in alias_map:
3394
+ p = alias_map[p]
3395
+ # Axis title toggles
3396
+ if p in ('bt','tt','lt','rt'):
3397
+ if p == 'bt':
3398
+ # Use visibility toggle to avoid layout recalculation
3399
+ label_obj = ax.xaxis.label
3400
+ if label_obj.get_visible():
3401
+ # Store text before hiding
3402
+ if not hasattr(ax, '_stored_xlabel'):
3403
+ ax._stored_xlabel = label_obj.get_text()
3404
+ # Store current labelpad to restore later
3405
+ try:
3406
+ ax._stored_xlabelpad = getattr(ax.xaxis, 'labelpad', None)
3407
+ except Exception:
3408
+ pass
3409
+ label_obj.set_visible(False)
3410
+ print("Hid bottom X axis title")
3411
+ else:
3412
+ # Restore text if needed before showing
3413
+ if hasattr(ax, '_stored_xlabel') and ax._stored_xlabel:
3414
+ label_obj.set_text(ax._stored_xlabel)
3415
+ label_obj.set_visible(True)
3416
+ # Freeze any automatic layout to prevent margin reflow on toggle
3417
+ try:
3418
+ fig.set_layout_engine('none')
3419
+ except Exception:
3420
+ try:
3421
+ fig.set_tight_layout(False)
3422
+ except Exception:
3423
+ pass
3424
+ try:
3425
+ # On some MPL versions this exists; harmless otherwise
3426
+ fig.set_constrained_layout(False)
3427
+ except Exception:
3428
+ pass
3429
+ # Reapply a deterministic pad based on current bottom label visibility
3430
+ try:
3431
+ # Prefer exact stored pad if available; else compute from tick visibility
3432
+ if hasattr(ax, '_stored_xlabelpad') and ax._stored_xlabelpad is not None:
3433
+ desired_pad = ax._stored_xlabelpad
3434
+ # Set a one-shot pending pad for ui.position_bottom_xlabel to consume
3435
+ ax._pending_xlabelpad = desired_pad
3436
+ else:
3437
+ desired_pad = 14 if bool(tick_state.get('b_labels', tick_state.get('bx', False))) else 6
3438
+ ax.xaxis.labelpad = desired_pad
3439
+ except Exception:
3440
+ pass
3441
+ print("Shown bottom X axis title")
3442
+ need_pos['bottom'] = True
3443
+ elif p == 'tt':
3444
+ vis = getattr(ax, '_top_xlabel_on', False)
3445
+ if not vis:
3446
+ # Just set the flag and let position_top_xlabel() create/update the artist
3447
+ ax._top_xlabel_on = True
3448
+ need_pos['top'] = True
3449
+ print("Shown duplicate top X axis title")
3450
+ else:
3451
+ if hasattr(ax,'_top_xlabel_artist') and ax._top_xlabel_artist is not None:
3452
+ ax._top_xlabel_artist.set_visible(False)
3453
+ ax._top_xlabel_on = False
3454
+ need_pos['top'] = True
3455
+ print("Hid top X axis title duplicate")
3456
+ elif p == 'lt':
3457
+ # Use visibility toggle to avoid layout recalculation
3458
+ label_obj = ax.yaxis.label
3459
+ if label_obj.get_visible():
3460
+ # Store text before hiding
3461
+ if not hasattr(ax, '_stored_ylabel'):
3462
+ ax._stored_ylabel = label_obj.get_text()
3463
+ # Store current labelpad to restore later
3464
+ try:
3465
+ ax._stored_ylabelpad = getattr(ax.yaxis, 'labelpad', None)
3466
+ except Exception:
3467
+ pass
3468
+ label_obj.set_visible(False)
3469
+ print("Hid left Y axis title")
3470
+ else:
3471
+ # Restore text if needed before showing
3472
+ if hasattr(ax, '_stored_ylabel') and ax._stored_ylabel:
3473
+ label_obj.set_text(ax._stored_ylabel)
3474
+ label_obj.set_visible(True)
3475
+ # Freeze auto layout and restore exact pad if available
3476
+ try:
3477
+ fig.set_layout_engine('none')
3478
+ except Exception:
3479
+ try:
3480
+ fig.set_tight_layout(False)
3481
+ except Exception:
3482
+ pass
3483
+ try:
3484
+ fig.set_constrained_layout(False)
3485
+ except Exception:
3486
+ pass
3487
+ try:
3488
+ if hasattr(ax, '_stored_ylabelpad') and ax._stored_ylabelpad is not None:
3489
+ ax.yaxis.labelpad = ax._stored_ylabelpad
3490
+ # Set a one-shot pending pad for ui.position_left_ylabel to consume
3491
+ ax._pending_ylabelpad = ax._stored_ylabelpad
3492
+ else:
3493
+ desired_pad = 14 if bool(tick_state.get('l_labels', tick_state.get('ly', False))) else 6
3494
+ ax.yaxis.labelpad = desired_pad
3495
+ except Exception:
3496
+ pass
3497
+ print("Shown left Y axis title")
3498
+ need_pos['left'] = True
3499
+ elif p == 'rt':
3500
+ vis = getattr(ax, '_right_ylabel_on', False)
3501
+ if not vis:
3502
+ # Just set the flag and let position_right_ylabel() create/update the artist
3503
+ ax._right_ylabel_on = True
3504
+ need_pos['right'] = True
3505
+ print("Shown duplicate right Y axis title")
3506
+ else:
3507
+ if hasattr(ax,'_right_ylabel_artist') and ax._right_ylabel_artist is not None:
3508
+ try:
3509
+ ax._right_ylabel_artist.set_visible(False)
3510
+ except Exception:
3511
+ pass
3512
+ ax._right_ylabel_on = False
3513
+ need_pos['right'] = True
3514
+ print("Hid right Y axis title")
3515
+ continue
3516
+ # Plot frame (spine) toggles
3517
+ if p in ('bl','tl','ll','rl'):
3518
+ spine_map = {'bl':'bottom','tl':'top','ll':'left','rl':'right'}
3519
+ spine = spine_map[p]
3520
+ vis = get_spine_visible(spine)
3521
+ set_spine_visible(spine, not vis)
3522
+ print(f"Toggled {spine} spine -> {'ON' if not vis else 'off'}")
3523
+ continue
3524
+ # New granular tick/label toggles
3525
+ if p in ('btcs','blb','ttcs','tlb','ltcs','llb','rtcs','rlb'):
3526
+ if p == 'btcs':
3527
+ tick_state['b_ticks'] = not tick_state['b_ticks']
3528
+ print(f"Toggled bottom ticks -> {'ON' if tick_state['b_ticks'] else 'off'}")
3529
+ elif p == 'blb':
3530
+ tick_state['b_labels'] = not tick_state['b_labels']
3531
+ print(f"Toggled bottom labels -> {'ON' if tick_state['b_labels'] else 'off'}")
3532
+ need_pos['bottom'] = True
3533
+ elif p == 'ttcs':
3534
+ tick_state['t_ticks'] = not tick_state['t_ticks']
3535
+ print(f"Toggled top ticks -> {'ON' if tick_state['t_ticks'] else 'off'}")
3536
+ elif p == 'tlb':
3537
+ tick_state['t_labels'] = not tick_state['t_labels']
3538
+ print(f"Toggled top labels -> {'ON' if tick_state['t_labels'] else 'off'}")
3539
+ need_pos['top'] = True
3540
+ elif p == 'ltcs':
3541
+ tick_state['l_ticks'] = not tick_state['l_ticks']
3542
+ print(f"Toggled left ticks -> {'ON' if tick_state['l_ticks'] else 'off'}")
3543
+ elif p == 'llb':
3544
+ tick_state['l_labels'] = not tick_state['l_labels']
3545
+ print(f"Toggled left labels -> {'ON' if tick_state['l_labels'] else 'off'}")
3546
+ need_pos['left'] = True
3547
+ elif p == 'rtcs':
3548
+ tick_state['r_ticks'] = not tick_state['r_ticks']
3549
+ print(f"Toggled right ticks -> {'ON' if tick_state['r_ticks'] else 'off'}")
3550
+ elif p == 'rlb':
3551
+ tick_state['r_labels'] = not tick_state['r_labels']
3552
+ print(f"Toggled right labels -> {'ON' if tick_state['r_labels'] else 'off'}")
3553
+ need_pos['right'] = True
3554
+ _sync_legacy_tick_keys()
3555
+ continue
3556
+ # Minor tick toggles
3557
+ if p in ('mbx','mtx','mly','mry'):
3558
+ tick_state[p] = not tick_state[p]
3559
+ print(f"Toggled {p} -> {'ON' if tick_state[p] else 'off'}")
3560
+ continue
3561
+ # Legacy combined toggles
3562
+ if p in ('bx','tx','ly','ry'):
3563
+ if p == 'bx':
3564
+ newv = not (tick_state['b_ticks'] or tick_state['b_labels'])
3565
+ tick_state['b_ticks'] = newv; tick_state['b_labels'] = newv
3566
+ print(f"Toggled bottom (ticks+labels) -> {'ON' if newv else 'off'}")
3567
+ need_pos['bottom'] = True
3568
+ elif p == 'tx':
3569
+ newv = not (tick_state['t_ticks'] or tick_state['t_labels'])
3570
+ tick_state['t_ticks'] = newv; tick_state['t_labels'] = newv
3571
+ print(f"Toggled top (ticks+labels) -> {'ON' if newv else 'off'}")
3572
+ need_pos['top'] = True
3573
+ elif p == 'ly':
3574
+ newv = not (tick_state['l_ticks'] or tick_state['l_labels'])
3575
+ tick_state['l_ticks'] = newv; tick_state['l_labels'] = newv
3576
+ print(f"Toggled left (ticks+labels) -> {'ON' if newv else 'off'}")
3577
+ need_pos['left'] = True
3578
+ elif p == 'ry':
3579
+ newv = not (tick_state['r_ticks'] or tick_state['r_labels'])
3580
+ tick_state['r_ticks'] = newv; tick_state['r_labels'] = newv
3581
+ print(f"Toggled right (ticks+labels) -> {'ON' if newv else 'off'}")
3582
+ need_pos['right'] = True
3583
+ _sync_legacy_tick_keys()
3584
+ continue
3585
+ # Unknown code
3586
+ print(f"Unknown code: {p}")
3587
+ # After tick toggles, update visibility and reposition ALL axis labels for independence
3588
+ update_tick_visibility()
3589
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
3590
+ sync_fonts()
3591
+ # Only reposition sides that were actually affected by the toggles
3592
+ if need_pos['bottom']:
3593
+ position_bottom_xlabel()
3594
+ if need_pos['left']:
3595
+ position_left_ylabel()
3596
+ if need_pos['top']:
3597
+ position_top_xlabel()
3598
+ if need_pos['right']:
3599
+ position_right_ylabel()
3600
+ # Single draw at the end after all positioning is complete
3601
+ fig.canvas.draw_idle()
3602
+ except Exception as e:
3603
+ print(f"Error in tick visibility menu: {e}")
3604
+ elif key == 'p':
3605
+ try:
3606
+ style_menu_active = True
3607
+ while style_menu_active:
3608
+ print_style_info()
3609
+ # List available style files (.bps, .bpsg, .bpcfg) in Styles/ subdirectory
3610
+ style_file_list = list_files_in_subdirectory(('.bps', '.bpsg', '.bpcfg'), 'style')
3611
+ _bpcfg_files = [f[0] for f in style_file_list]
3612
+ if _bpcfg_files:
3613
+ print("Existing style files in Styles/ (.bps/.bpsg):")
3614
+ for _i, (fname, fpath) in enumerate(style_file_list, 1):
3615
+ timestamp = format_file_timestamp(fpath)
3616
+ if timestamp:
3617
+ print(f" {_i}: {fname} ({timestamp})")
3618
+ else:
3619
+ print(f" {_i}: {fname}")
3620
+ last_style_path = getattr(fig, '_last_style_export_path', None)
3621
+ if last_style_path:
3622
+ sub = _safe_input(colorize_prompt("Style submenu: (e=export, o=overwrite last, q=return, r=refresh): ")).strip().lower()
3623
+ else:
3624
+ sub = _safe_input(colorize_prompt("Style submenu: (e=export, q=return, r=refresh): ")).strip().lower()
3625
+ if sub == 'q':
3626
+ break
3627
+ if sub == 'r' or sub == '':
3628
+ continue
3629
+ if sub == 'o':
3630
+ # Overwrite last exported style file
3631
+ if not last_style_path:
3632
+ print("No previous export found.")
3633
+ continue
3634
+ if not os.path.exists(last_style_path):
3635
+ print(f"Previous export file not found: {last_style_path}")
3636
+ continue
3637
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
3638
+ if yn != 'y':
3639
+ continue
3640
+ # Call export_style_config with overwrite_path to skip dialog
3641
+ exported_path = export_style_config(None, base_path=None, overwrite_path=last_style_path)
3642
+ if exported_path:
3643
+ fig._last_style_export_path = exported_path
3644
+ style_menu_active = False
3645
+ break
3646
+ if sub == 'e':
3647
+ save_base = choose_save_path(source_file_paths, purpose="style export")
3648
+ if not save_base:
3649
+ print("Style export canceled.")
3650
+ continue
3651
+ print(f"\nChosen path: {save_base}")
3652
+ # Call export_style_config which handles the entire export dialog
3653
+ exported_path = export_style_config(None, base_path=save_base) # filename parameter ignored
3654
+ if exported_path:
3655
+ fig._last_style_export_path = exported_path
3656
+ style_menu_active = False # Exit style submenu and return to main menu
3657
+ break
3658
+ else:
3659
+ print("Unknown choice.")
3660
+ except Exception as e:
3661
+ print(f"Error in style submenu: {e}")
3662
+ elif key == 'i':
3663
+ try:
3664
+ fname = choose_style_file(source_file_paths, purpose="style import")
3665
+ if not fname:
3666
+ print("Style import canceled.")
3667
+ continue
3668
+ push_state("style-import")
3669
+ apply_style_config(fname)
3670
+ except Exception as e:
3671
+ print(f"Error importing style: {e}")
3672
+ elif key == 'e':
3673
+ try:
3674
+ base_path = choose_save_path(source_file_paths, purpose="figure export")
3675
+ if not base_path:
3676
+ print("Export canceled.")
3677
+ continue
3678
+ print(f"\nChosen path: {base_path}")
3679
+ # List existing figure files in Figures/ subdirectory
3680
+ fig_extensions = ('.svg', '.png', '.jpg', '.jpeg', '.pdf', '.eps', '.tif', '.tiff')
3681
+ file_list = list_files_in_subdirectory(fig_extensions, 'figure', base_path=base_path)
3682
+ files = [f[0] for f in file_list]
3683
+ if files:
3684
+ figures_dir = os.path.join(base_path, 'Figures')
3685
+ print(f"Existing figure files in {figures_dir}:")
3686
+ for i, (fname, fpath) in enumerate(file_list, 1):
3687
+ timestamp = format_file_timestamp(fpath)
3688
+ if timestamp:
3689
+ print(f" {i}: {fname} ({timestamp})")
3690
+ else:
3691
+ print(f" {i}: {fname}")
3692
+
3693
+ last_figure_path = getattr(fig, '_last_figure_export_path', None)
3694
+ if last_figure_path:
3695
+ filename = _safe_input("Enter filename (default SVG if no extension), number to overwrite, or o to overwrite last (q=cancel): ").strip()
3696
+ else:
3697
+ filename = _safe_input("Enter filename (default SVG if no extension) or number to overwrite (q=cancel): ").strip()
3698
+ if not filename or filename.lower() == 'q':
3699
+ print("Canceled.")
3700
+ continue
3701
+
3702
+ already_confirmed = False # Initialize for new filename case
3703
+ # Check for 'o' option
3704
+ if filename.lower() == 'o':
3705
+ if not last_figure_path:
3706
+ print("No previous export found.")
3707
+ continue
3708
+ if not os.path.exists(last_figure_path):
3709
+ print(f"Previous export file not found: {last_figure_path}")
3710
+ continue
3711
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_figure_path)}'? (y/n): ").strip().lower()
3712
+ if yn != 'y':
3713
+ print("Canceled.")
3714
+ continue
3715
+ export_target = last_figure_path
3716
+ already_confirmed = True
3717
+ # Check if user selected a number
3718
+ elif filename.isdigit() and files:
3719
+ already_confirmed = False
3720
+ idx = int(filename)
3721
+ if 1 <= idx <= len(files):
3722
+ name = files[idx-1]
3723
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
3724
+ if yn != 'y':
3725
+ print("Canceled.")
3726
+ continue
3727
+ export_target = file_list[idx-1][1] # Full path from list
3728
+ already_confirmed = True
3729
+ else:
3730
+ print("Invalid number.")
3731
+ continue
3732
+ else:
3733
+ if not os.path.splitext(filename)[1]:
3734
+ filename += ".svg"
3735
+ # Use organized path unless it's an absolute path
3736
+ if os.path.isabs(filename):
3737
+ export_target = filename
3738
+ else:
3739
+ export_target = get_organized_path(filename, 'figure', base_path=base_path)
3740
+
3741
+ # Confirm overwrite if file exists (and not already confirmed by number selection)
3742
+ if not already_confirmed:
3743
+ if os.path.exists(export_target):
3744
+ export_target = _confirm_overwrite(export_target)
3745
+
3746
+ if not export_target:
3747
+ print("Export canceled.")
3748
+ else:
3749
+ # Ensure exact case is preserved (important for macOS case-insensitive filesystem)
3750
+ from .utils import ensure_exact_case_filename
3751
+ export_target = ensure_exact_case_filename(export_target)
3752
+
3753
+ # Temporarily remove numbering for export
3754
+ for i, txt in enumerate(label_text_objects):
3755
+ txt.set_text(labels[i])
3756
+ # Transparent background for SVG exports
3757
+ _, _ext = os.path.splitext(export_target)
3758
+ if _ext.lower() == '.svg':
3759
+ try:
3760
+ _fig_fc = fig.get_facecolor()
3761
+ except Exception:
3762
+ _fig_fc = None
3763
+ try:
3764
+ _ax_fc = ax.get_facecolor()
3765
+ except Exception:
3766
+ _ax_fc = None
3767
+ try:
3768
+ if getattr(fig, 'patch', None) is not None:
3769
+ fig.patch.set_alpha(0.0); fig.patch.set_facecolor('none')
3770
+ if getattr(ax, 'patch', None) is not None:
3771
+ ax.patch.set_alpha(0.0); ax.patch.set_facecolor('none')
3772
+ except Exception:
3773
+ pass
3774
+ try:
3775
+ fig.savefig(export_target, dpi=300, transparent=True, facecolor='none', edgecolor='none')
3776
+ finally:
3777
+ try:
3778
+ if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
3779
+ fig.patch.set_alpha(1.0); fig.patch.set_facecolor(_fig_fc)
3780
+ except Exception:
3781
+ pass
3782
+ try:
3783
+ if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
3784
+ ax.patch.set_alpha(1.0); ax.patch.set_facecolor(_ax_fc)
3785
+ except Exception:
3786
+ pass
3787
+ else:
3788
+ fig.savefig(export_target, dpi=300)
3789
+ print(f"Figure saved to {export_target}")
3790
+ fig._last_figure_export_path = export_target
3791
+ for i, txt in enumerate(label_text_objects):
3792
+ txt.set_text(f"{i+1}: {labels[i]}")
3793
+ fig.canvas.draw()
3794
+ except Exception as e:
3795
+ print(f"Error saving figure: {e}")
3796
+ elif key == 'v':
3797
+ while True:
3798
+ try:
3799
+ rng_in = _safe_input("Peak X range (min max, 'current' for axes limits, q=back): ").strip().lower()
3800
+ if not rng_in or rng_in == 'q':
3801
+ break
3802
+ if rng_in == 'current':
3803
+ x_min, x_max = ax.get_xlim()
3804
+ else:
3805
+ parts = rng_in.split()
3806
+ if len(parts) != 2:
3807
+ print("Need exactly two numbers or 'current'.")
3808
+ continue
3809
+ x_min, x_max = map(float, parts)
3810
+ if x_min > x_max:
3811
+ x_min, x_max = x_max, x_min
3812
+
3813
+ frac_in = _safe_input("Min relative peak height (0–1, default 0.1): ").strip()
3814
+ min_frac = float(frac_in) if frac_in else 0.1
3815
+ if min_frac < 0: min_frac = 0.0
3816
+ if min_frac > 1: min_frac = 1.0
3817
+
3818
+ swin = _safe_input("Smoothing window (odd int >=3, blank=none): ").strip()
3819
+ if swin:
3820
+ try:
3821
+ win = int(swin)
3822
+ if win < 3 or win % 2 == 0:
3823
+ print("Invalid window; disabling smoothing.")
3824
+ win = 0
3825
+ else:
3826
+ print(f"Using moving-average smoothing (window={win}).")
3827
+ except ValueError:
3828
+ print("Bad window value; no smoothing.")
3829
+ win = 0
3830
+ else:
3831
+ win = 0
3832
+
3833
+ print("\n--- Peak Report ---")
3834
+ print(f"X range used: {x_min} .. {x_max} (relative height threshold={min_frac})")
3835
+ for i, (x_arr, y_off) in enumerate(zip(x_data_list, y_data_list)):
3836
+ # Recover original curve (remove vertical offset)
3837
+ if i < len(offsets_list):
3838
+ y_arr = y_off - offsets_list[i]
3839
+ else:
3840
+ y_arr = y_off.copy()
3841
+
3842
+ # Restrict to selected window
3843
+ mask = (x_arr >= x_min) & (x_arr <= x_max)
3844
+ x_sel = x_arr[mask]
3845
+ y_sel = y_arr[mask]
3846
+
3847
+ label = labels[i] if i < len(labels) else f"Curve {i+1}"
3848
+ print(f"\nCurve {i+1}: {label}")
3849
+ if x_sel.size < 3:
3850
+ print(" (Insufficient points)")
3851
+ continue
3852
+
3853
+ # Optional smoothing
3854
+ if win >= 3 and x_sel.size >= win:
3855
+ kernel = np.ones(win, dtype=float) / win
3856
+ y_sm = np.convolve(y_sel, kernel, mode='same')
3857
+ else:
3858
+ y_sm = y_sel
3859
+
3860
+ # Determine threshold
3861
+ ymax = float(np.max(y_sm))
3862
+ if ymax <= 0:
3863
+ print(" (Non-positive data)")
3864
+ continue
3865
+ min_height = ymax * min_frac
3866
+
3867
+ # Simple local maxima detection
3868
+ y_prev = y_sm[:-2]
3869
+ y_mid = y_sm[1:-1]
3870
+ y_next = y_sm[2:]
3871
+ core_mask = (y_mid > y_prev) & (y_mid >= y_next) & (y_mid >= min_height)
3872
+ if not np.any(core_mask):
3873
+ print(" (No peaks)")
3874
+ continue
3875
+ peak_indices = np.where(core_mask)[0] + 1 # shift because we looked at 1..n-2
3876
+
3877
+ # Optional refine: keep only distinct peaks (skip adjacent equal plateau)
3878
+ peaks = []
3879
+ last_idx = -10
3880
+ for pi in peak_indices:
3881
+ if pi - last_idx == 1 and y_sm[pi] == y_sm[last_idx]:
3882
+ # same plateau, keep first
3883
+ continue
3884
+ peaks.append(pi)
3885
+ last_idx = pi
3886
+
3887
+ print(" Peaks (x, y):")
3888
+ for pi in peaks:
3889
+ print(f" x={x_sel[pi]:.6g}, y={y_sel[pi]:.6g}")
3890
+ print("\n--- End Peak Report ---\n")
3891
+ except Exception as e:
3892
+ print(f"Error finding peaks: {e}")
3893
+
3894
+ __all__ = ["interactive_menu"]