batplot 1.7.28__py3-none-any.whl → 1.8.1__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.

@@ -18,6 +18,7 @@ from typing import Tuple, Dict, Optional, Any
18
18
  import json
19
19
  import os
20
20
  import time
21
+ import sys
21
22
 
22
23
  import matplotlib.pyplot as plt
23
24
  from matplotlib.colors import LinearSegmentedColormap
@@ -38,7 +39,38 @@ from .color_utils import (
38
39
  resolve_color_token,
39
40
  manage_user_colors,
40
41
  )
41
- from .utils import choose_style_file
42
+ from .utils import choose_style_file, convert_label_shortcuts
43
+
44
+
45
+ class _FilterIMKWarning:
46
+ """Filter that suppresses macOS IMKCFRunLoopWakeUpReliable warnings while preserving other errors."""
47
+ def __init__(self, original_stderr):
48
+ self.original_stderr = original_stderr
49
+
50
+ def write(self, message):
51
+ # Filter out the harmless macOS IMK warning
52
+ if 'IMKCFRunLoopWakeUpReliable' not in message:
53
+ self.original_stderr.write(message)
54
+
55
+ def flush(self):
56
+ self.original_stderr.flush()
57
+
58
+
59
+ def _safe_input(prompt: str = "") -> str:
60
+ """Wrapper around input() that suppresses macOS IMKCFRunLoopWakeUpReliable warnings.
61
+
62
+ This is a harmless macOS system message that appears when using input() in terminals.
63
+ """
64
+ # Filter stderr to hide macOS IMK warnings while preserving other errors
65
+ original_stderr = sys.stderr
66
+ sys.stderr = _FilterIMKWarning(original_stderr)
67
+ try:
68
+ result = input(prompt)
69
+ return result
70
+ except (KeyboardInterrupt, EOFError):
71
+ raise
72
+ finally:
73
+ sys.stderr = original_stderr
42
74
 
43
75
 
44
76
  def _axis_tick_width(axis_obj, which: str = 'major'):
@@ -322,7 +354,7 @@ def _draw_custom_colorbar(cbar_ax, im, label='Intensity', label_mode='normal'):
322
354
 
323
355
  def _update_custom_colorbar(cbar_ax, im=None, label=None, label_mode=None):
324
356
  """Update the custom colorbar when colormap or limits change.
325
-
357
+
326
358
  Args:
327
359
  cbar_ax: Axes object containing the colorbar
328
360
  im: Optional AxesImage object (if None, uses stored reference)
@@ -333,17 +365,82 @@ def _update_custom_colorbar(cbar_ax, im=None, label=None, label_mode=None):
333
365
  im = getattr(cbar_ax, '_colorbar_im', None)
334
366
  if im is None:
335
367
  return
336
-
368
+
337
369
  if label is None:
338
370
  label = getattr(cbar_ax, '_colorbar_label', 'Intensity')
339
-
371
+
340
372
  if label_mode is None:
341
373
  label_mode = getattr(cbar_ax, '_colorbar_label_mode', 'normal')
342
-
374
+
343
375
  # Redraw the colorbar
344
376
  _draw_custom_colorbar(cbar_ax, im, label, label_mode)
345
377
 
346
378
 
379
+ def _safe_set_clim(im, vmin, vmax):
380
+ """Safely set color limits without triggering matplotlib colorbar callbacks.
381
+
382
+ This wrapper around im.set_clim() prevents the NotImplementedError: cannot remove artist
383
+ error when using custom colorbars. The issue occurs because matplotlib's callback system
384
+ tries to update the colorbar when set_clim() is called, but our custom colorbar drawing
385
+ has already cleared the axes, causing the update to fail.
386
+
387
+ This function suppresses the callback traceback by redirecting stderr to a null device
388
+ during the set_clim() call, preventing matplotlib's callback system from printing
389
+ tracebacks to the terminal.
390
+
391
+ Args:
392
+ im: AxesImage object
393
+ vmin: Minimum value for color scale
394
+ vmax: Maximum value for color scale
395
+ """
396
+ import sys
397
+ import os
398
+ from io import StringIO
399
+
400
+ # Create a null device for stderr redirection
401
+ class NullDevice:
402
+ def write(self, s):
403
+ pass
404
+ def flush(self):
405
+ pass
406
+ def close(self):
407
+ pass
408
+
409
+ # Suppress matplotlib's exception printing by redirecting stderr AND excepthook
410
+ old_stderr = sys.stderr
411
+ old_excepthook = sys.excepthook
412
+ null_dev = NullDevice()
413
+
414
+ # Create a no-op excepthook that suppresses all exceptions
415
+ def suppress_excepthook(exc_type, exc_value, exc_traceback):
416
+ # Only suppress if it's the specific error we're looking for
417
+ if exc_type == NotImplementedError and 'cannot remove artist' in str(exc_value).lower():
418
+ return # Suppress this specific exception
419
+ # For any other exception, use the original handler
420
+ old_excepthook(exc_type, exc_value, exc_traceback)
421
+
422
+ sys.stderr = null_dev
423
+ sys.excepthook = suppress_excepthook
424
+
425
+ try:
426
+ # Set the color limits - matplotlib's callback will try to print traceback
427
+ # but both stderr and excepthook are suppressed
428
+ im.set_clim(vmin, vmax)
429
+ # The operation succeeds; any tracebacks are suppressed
430
+ except NotImplementedError:
431
+ # Suppress the specific error - color limits were still updated successfully
432
+ pass
433
+ except Exception:
434
+ # For any other unexpected error, restore handlers and re-raise
435
+ sys.stderr = old_stderr
436
+ sys.excepthook = old_excepthook
437
+ raise
438
+ finally:
439
+ # Always restore both stderr and excepthook
440
+ sys.stderr = old_stderr
441
+ sys.excepthook = old_excepthook
442
+
443
+
347
444
  def _detach_mpl_colorbar_callbacks(cbar, im) -> None:
348
445
  """Detach a Matplotlib Colorbar from its mappable callbacks.
349
446
 
@@ -363,7 +460,7 @@ def _detach_mpl_colorbar_callbacks(cbar, im) -> None:
363
460
  if cax is not None and getattr(cax, '_bp_detached_mpl_colorbar', False):
364
461
  return
365
462
 
366
- # Matplotlib's Colorbar stores the callback id as `_cid` (most versions).
463
+ # APPROACH 1: Try to find and disconnect the callback ID
367
464
  cid = None
368
465
  for attr in ('_cid', '_cid_colorbar', 'cid'):
369
466
  try:
@@ -374,7 +471,6 @@ def _detach_mpl_colorbar_callbacks(cbar, im) -> None:
374
471
  except Exception:
375
472
  pass
376
473
 
377
- # Disconnect from the ScalarMappable callback registry.
378
474
  if cid is not None:
379
475
  try:
380
476
  cbreg = getattr(im, 'callbacksSM', None)
@@ -383,7 +479,38 @@ def _detach_mpl_colorbar_callbacks(cbar, im) -> None:
383
479
  except Exception:
384
480
  pass
385
481
 
386
- # Prevent future built-in updates (best-effort; safe for mock colorbars too).
482
+ # APPROACH 2: Disconnect ALL callbacks from the image (nuclear option)
483
+ try:
484
+ cbreg = getattr(im, 'callbacksSM', None)
485
+ if cbreg is not None:
486
+ # Try to clear all callbacks
487
+ if hasattr(cbreg, 'callbacks'):
488
+ try:
489
+ cbreg.callbacks.clear()
490
+ except Exception:
491
+ pass
492
+ # Also try the _signals dict if it exists
493
+ if hasattr(cbreg, '_signals'):
494
+ try:
495
+ for signal_dict in cbreg._signals.values():
496
+ if hasattr(signal_dict, 'clear'):
497
+ signal_dict.clear()
498
+ except Exception:
499
+ pass
500
+ except Exception:
501
+ pass
502
+
503
+ # APPROACH 3: Monkey-patch the update_normal method to be a no-op
504
+ # This is the most reliable approach for preventing the callback
505
+ try:
506
+ if hasattr(cbar, 'update_normal'):
507
+ def _noop_update(*args, **kwargs):
508
+ pass
509
+ cbar.update_normal = _noop_update
510
+ except Exception:
511
+ pass
512
+
513
+ # APPROACH 4: Prevent future built-in updates by nulling internal state
387
514
  try:
388
515
  if hasattr(cbar, 'mappable'):
389
516
  cbar.mappable = None
@@ -675,7 +802,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
675
802
 
676
803
  if intensity_max > intensity_min:
677
804
  # Update color limits
678
- im.set_clim(intensity_min, intensity_max)
805
+ _safe_set_clim(im, intensity_min, intensity_max)
679
806
 
680
807
  # Update colorbar if available
681
808
  try:
@@ -1049,7 +1176,10 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1049
1176
  clim = im.get_clim()
1050
1177
  except Exception:
1051
1178
  clim = None
1052
- cmap_name = getattr(im.get_cmap(), 'name', None)
1179
+ # Get colormap name: first check if we stored it explicitly, otherwise try to get from colormap object
1180
+ cmap_name = getattr(im, '_operando_cmap_name', None)
1181
+ if cmap_name is None:
1182
+ cmap_name = getattr(im.get_cmap(), 'name', None)
1053
1183
  # EC mode and caches (only if ec_ax exists)
1054
1184
  if ec_ax is not None:
1055
1185
  mode = getattr(ec_ax, '_ec_y_mode', 'time')
@@ -1263,12 +1393,15 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1263
1393
  cbar.solids = None
1264
1394
  except Exception:
1265
1395
  pass
1266
- lo, hi = snap['clim']; im.set_clim(float(lo), float(hi))
1396
+ lo, hi = snap['clim']; _safe_set_clim(im, float(lo), float(hi))
1267
1397
  except Exception:
1268
1398
  pass
1269
1399
  try:
1270
1400
  if snap.get('cmap'):
1271
- im.set_cmap(snap['cmap'])
1401
+ cmap_name = snap['cmap']
1402
+ im.set_cmap(cmap_name)
1403
+ # Store the colormap name explicitly so it can be retrieved reliably when saving
1404
+ setattr(im, '_operando_cmap_name', cmap_name)
1272
1405
  if cbar is not None:
1273
1406
  _update_custom_colorbar(cbar.ax, im)
1274
1407
  except Exception:
@@ -1733,7 +1866,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1733
1866
  print("Crosshair OFF.")
1734
1867
  while True:
1735
1868
  try:
1736
- cmd = input("Press a key: ").strip().lower()
1869
+ cmd = _safe_input("Press a key: ").strip().lower()
1737
1870
  except (KeyboardInterrupt, EOFError):
1738
1871
  print("\n\nExiting interactive menu...")
1739
1872
  break
@@ -1741,7 +1874,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1741
1874
  continue
1742
1875
  if cmd == 'q':
1743
1876
  try:
1744
- ans = input("Quit interactive? Remember to save (e=export, s=save). Quit now? (y/n): ").strip().lower()
1877
+ ans = _safe_input("Quit interactive? Remember to save (e=export, s=save). Quit now? (y/n): ").strip().lower()
1745
1878
  except Exception:
1746
1879
  ans = 'y'
1747
1880
  if ans == 'y':
@@ -1784,9 +1917,9 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1784
1917
 
1785
1918
  last_figure_path = getattr(fig, '_last_figure_export_path', None)
1786
1919
  if last_figure_path:
1787
- fname = input("Export filename (default .svg if no extension), number to overwrite, or o to overwrite last (q=cancel): ").strip()
1920
+ fname = _safe_input("Export filename (default .svg if no extension), number to overwrite, or o to overwrite last (q=cancel): ").strip()
1788
1921
  else:
1789
- fname = input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
1922
+ fname = _safe_input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
1790
1923
  if not fname or fname.lower() == 'q':
1791
1924
  print_menu(); continue
1792
1925
 
@@ -1799,7 +1932,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1799
1932
  if not os.path.exists(last_figure_path):
1800
1933
  print(f"Previous export file not found: {last_figure_path}")
1801
1934
  print_menu(); continue
1802
- yn = input(f"Overwrite '{os.path.basename(last_figure_path)}'? (y/n): ").strip().lower()
1935
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_figure_path)}'? (y/n): ").strip().lower()
1803
1936
  if yn != 'y':
1804
1937
  print_menu(); continue
1805
1938
  target = last_figure_path
@@ -1810,7 +1943,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1810
1943
  idx = int(fname)
1811
1944
  if 1 <= idx <= len(files):
1812
1945
  name = files[idx-1]
1813
- yn = input(f"Overwrite '{name}'? (y/n): ").strip().lower()
1946
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
1814
1947
  if yn != 'y':
1815
1948
  print_menu(); continue
1816
1949
  target = file_list[idx-1][1] # Full path from list
@@ -1889,7 +2022,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1889
2022
  cb_h_offset = getattr(cbar.ax, '_cb_h_offset_in', 0.0)
1890
2023
  ec_h_offset = getattr(ec_ax, '_ec_h_offset_in', 0.0)
1891
2024
  print(f"Toggle: 1=colorbar, 2=EC panel, 3=both, 4=colorbar label mode, 5=colorbar label text, m=move horizontal position (cb:{cb_h_offset:.3f}\", ec:{ec_h_offset:.3f}\"), q=cancel")
1892
- choice = input("v> ").strip().lower()
2025
+ choice = _safe_input("v> ").strip().lower()
1893
2026
  if choice == '1':
1894
2027
  # Toggle colorbar
1895
2028
  cb_vis = cbar.ax.get_visible()
@@ -1923,7 +2056,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1923
2056
  # Change colorbar label text
1924
2057
  current_label = getattr(cbar.ax, '_colorbar_label', 'Intensity')
1925
2058
  print(f"Current colorbar label: {current_label}")
1926
- new_label = input("New colorbar label (blank to keep): ").strip()
2059
+ new_label = _safe_input("New colorbar label (blank to keep): ").strip()
1927
2060
  if new_label:
1928
2061
  cbar.ax._colorbar_label = new_label
1929
2062
  try:
@@ -1944,12 +2077,12 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1944
2077
  if ec_ax is not None:
1945
2078
  print(f" EC panel offset: {ec_h_offset:.3f}\" (positive=right, negative=left)")
1946
2079
  print("Commands: c=colorbar, e=EC panel, q=back")
1947
- sub = input("m> ").strip().lower()
2080
+ sub = _safe_input("m> ").strip().lower()
1948
2081
  if not sub or sub == 'q':
1949
2082
  break
1950
2083
  if sub == 'c':
1951
2084
  try:
1952
- new_offset = input(f"Enter colorbar horizontal offset in inches (current: {cb_h_offset:.3f}\"): ").strip()
2085
+ new_offset = _safe_input(f"Enter colorbar horizontal offset in inches (current: {cb_h_offset:.3f}\"): ").strip()
1953
2086
  if new_offset:
1954
2087
  cb_h_offset = float(new_offset)
1955
2088
  setattr(cbar.ax, '_cb_h_offset_in', cb_h_offset)
@@ -1957,16 +2090,20 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1957
2090
  _apply_group_layout_inches(fig, ax, cbar.ax, ec_ax, ax_w_in, ax_h_in, cb_w_in, cb_gap_in, ec_gap_in, ec_w_in)
1958
2091
  fig.canvas.draw_idle()
1959
2092
  print(f"Colorbar horizontal offset set to {cb_h_offset:.3f}\"")
2093
+ # Continue in loop to show menu again
2094
+ continue
1960
2095
  except ValueError:
1961
2096
  print("Invalid input. Enter a number.")
2097
+ continue
1962
2098
  except Exception as e:
1963
2099
  print(f"Error: {e}")
2100
+ continue
1964
2101
  elif sub == 'e':
1965
2102
  if ec_ax is None:
1966
2103
  print("EC panel not available.")
1967
2104
  continue
1968
2105
  try:
1969
- new_offset = input(f"Enter EC panel horizontal offset in inches (current: {ec_h_offset:.3f}\"): ").strip()
2106
+ new_offset = _safe_input(f"Enter EC panel horizontal offset in inches (current: {ec_h_offset:.3f}\"): ").strip()
1970
2107
  if new_offset:
1971
2108
  ec_h_offset = float(new_offset)
1972
2109
  setattr(ec_ax, '_ec_h_offset_in', ec_h_offset)
@@ -1974,19 +2111,24 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1974
2111
  _apply_group_layout_inches(fig, ax, cbar.ax, ec_ax, ax_w_in, ax_h_in, cb_w_in, cb_gap_in, ec_gap_in, ec_w_in)
1975
2112
  fig.canvas.draw_idle()
1976
2113
  print(f"EC panel horizontal offset set to {ec_h_offset:.3f}\"")
2114
+ # Continue in loop to show menu again
2115
+ continue
1977
2116
  except ValueError:
1978
2117
  print("Invalid input. Enter a number.")
2118
+ continue
1979
2119
  except Exception as e:
1980
2120
  print(f"Error: {e}")
2121
+ continue
1981
2122
  else:
1982
2123
  print("Invalid choice.")
2124
+ continue
1983
2125
  elif choice != 'q':
1984
2126
  print("Invalid choice")
1985
2127
  else:
1986
2128
  # Operando-only mode: toggle colorbar or change label mode
1987
2129
  cb_h_offset = getattr(cbar.ax, '_cb_h_offset_in', 0.0)
1988
2130
  print(f"Toggle: 1=colorbar visibility, 2=colorbar label mode, 3=colorbar label text, m=move horizontal position (cb:{cb_h_offset:.3f}\"), q=cancel")
1989
- choice = input("v> ").strip().lower()
2131
+ choice = _safe_input("v> ").strip().lower()
1990
2132
  if choice == '1':
1991
2133
  cb_vis = cbar.ax.get_visible()
1992
2134
  cbar.ax.set_visible(not cb_vis)
@@ -2006,7 +2148,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2006
2148
  # Change colorbar label text
2007
2149
  current_label = getattr(cbar.ax, '_colorbar_label', 'Intensity')
2008
2150
  print(f"Current colorbar label: {current_label}")
2009
- new_label = input("New colorbar label (blank to keep): ").strip()
2151
+ new_label = _safe_input("New colorbar label (blank to keep): ").strip()
2010
2152
  if new_label:
2011
2153
  cbar.ax._colorbar_label = new_label
2012
2154
  try:
@@ -2024,12 +2166,12 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2024
2166
  print(f"\nHorizontal position (relative to canvas center):")
2025
2167
  print(f" Colorbar offset: {cb_h_offset:.3f}\" (positive=right, negative=left)")
2026
2168
  print("Commands: c=colorbar, q=back")
2027
- sub = input("m> ").strip().lower()
2169
+ sub = _safe_input("m> ").strip().lower()
2028
2170
  if not sub or sub == 'q':
2029
2171
  break
2030
2172
  if sub == 'c':
2031
2173
  try:
2032
- new_offset = input(f"Enter colorbar horizontal offset in inches (current: {cb_h_offset:.3f}\"): ").strip()
2174
+ new_offset = _safe_input(f"Enter colorbar horizontal offset in inches (current: {cb_h_offset:.3f}\"): ").strip()
2033
2175
  if new_offset:
2034
2176
  cb_h_offset = float(new_offset)
2035
2177
  setattr(cbar.ax, '_cb_h_offset_in', cb_h_offset)
@@ -2037,12 +2179,17 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2037
2179
  _apply_group_layout_inches(fig, ax, cbar.ax, ec_ax, ax_w_in, ax_h_in, cb_w_in, cb_gap_in, ec_gap_in, ec_w_in)
2038
2180
  fig.canvas.draw_idle()
2039
2181
  print(f"Colorbar horizontal offset set to {cb_h_offset:.3f}\"")
2182
+ # Continue in loop to show menu again
2183
+ continue
2040
2184
  except ValueError:
2041
2185
  print("Invalid input. Enter a number.")
2186
+ continue
2042
2187
  except Exception as e:
2043
2188
  print(f"Error: {e}")
2189
+ continue
2044
2190
  else:
2045
2191
  print("Invalid choice.")
2192
+ continue
2046
2193
  elif choice != 'q':
2047
2194
  print("Invalid choice")
2048
2195
  fig.canvas.draw_idle()
@@ -2079,7 +2226,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2079
2226
  prompt = "Enter new filename (no ext needed), number to overwrite, or o to overwrite last (q=cancel): "
2080
2227
  else:
2081
2228
  prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
2082
- choice = input(prompt).strip()
2229
+ choice = _safe_input(prompt).strip()
2083
2230
  if not choice or choice.lower() == 'q':
2084
2231
  print_menu(); continue
2085
2232
  if choice.lower() == 'o':
@@ -2090,7 +2237,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2090
2237
  if not os.path.exists(last_session_path):
2091
2238
  print(f"Previous save file not found: {last_session_path}")
2092
2239
  print_menu(); continue
2093
- yn = input(f"Overwrite '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
2240
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
2094
2241
  if yn != 'y':
2095
2242
  print_menu(); continue
2096
2243
  dump_operando_session(last_session_path, fig=fig, ax=ax, im=im, cbar=cbar, ec_ax=ec_ax, skip_confirm=True)
@@ -2100,7 +2247,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2100
2247
  idx = int(choice)
2101
2248
  if 1 <= idx <= len(files):
2102
2249
  name = files[idx-1]
2103
- yn = input(f"Overwrite '{name}'? (y/n): ").strip().lower()
2250
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
2104
2251
  if yn != 'y':
2105
2252
  print_menu(); continue
2106
2253
  target = os.path.join(folder, name)
@@ -2117,7 +2264,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2117
2264
  name = name + '.pkl'
2118
2265
  target = name if os.path.isabs(name) else os.path.join(folder, name)
2119
2266
  if os.path.exists(target):
2120
- yn = input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
2267
+ yn = _safe_input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
2121
2268
  if yn != 'y':
2122
2269
  print_menu(); continue
2123
2270
  dump_operando_session(target, fig=fig, ax=ax, im=im, cbar=cbar, ec_ax=ec_ax, skip_confirm=True)
@@ -2142,7 +2289,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2142
2289
  # Always read fresh value from attribute to avoid stale cached value
2143
2290
  ax_h_in = getattr(ax, '_fixed_ax_h_in', ax_h_in)
2144
2291
  print(f"Current height: {ax_h_in:.2f} in")
2145
- val = input("New height (inches): ").strip()
2292
+ val = _safe_input("New height (inches): ").strip()
2146
2293
  if val:
2147
2294
  _snapshot("height")
2148
2295
  try:
@@ -2182,7 +2329,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2182
2329
  print(f"\nFont submenu (current: family='{cur_family}', size={cur_size})")
2183
2330
  print(" f: change family | s: change size | q: back")
2184
2331
  while True:
2185
- sub = input("Font> ").strip().lower()
2332
+ sub = _safe_input("Font> ").strip().lower()
2186
2333
  if not sub:
2187
2334
  continue
2188
2335
  if sub == 'q':
@@ -2195,7 +2342,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2195
2342
  for i, font in enumerate(fonts, 1):
2196
2343
  print(f" {i}: {font}")
2197
2344
  print("Or enter custom font name directly.")
2198
- choice = input("Font family (number or name): ").strip()
2345
+ choice = _safe_input("Font family (number or name): ").strip()
2199
2346
  if not choice:
2200
2347
  continue
2201
2348
  _snapshot("font-family")
@@ -2215,7 +2362,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2215
2362
  elif sub == 's':
2216
2363
  # Show current size and accept direct input
2217
2364
  cur_size = plt.rcParams.get('font.size', None)
2218
- choice = input(f"Font size (current: {cur_size}): ").strip()
2365
+ choice = _safe_input(f"Font size (current: {cur_size}): ").strip()
2219
2366
  if not choice:
2220
2367
  continue
2221
2368
  _snapshot("font-size")
@@ -2238,7 +2385,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2238
2385
  print(_colorize_inline_commands(" 1.5 2.5 - set frame=1.5, ticks=2.5"))
2239
2386
  print(_colorize_inline_commands(" q - cancel"))
2240
2387
 
2241
- inp = input("Line widths> ").strip().lower()
2388
+ inp = _safe_input("Line widths> ").strip().lower()
2242
2389
  if not inp or inp == 'q':
2243
2390
  print_menu()
2244
2391
  continue
@@ -2301,6 +2448,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2301
2448
  # Unified WASD ticks/labels/spines submenu for either pane
2302
2449
  # Import here to avoid scoping issues with nested functions
2303
2450
  from matplotlib.ticker import AutoMinorLocator, NullFormatter, MaxNLocator, NullLocator
2451
+ # Import UI positioning functions locally to ensure they're accessible in nested functions
2452
+ from .ui import position_top_xlabel as _ui_position_top_xlabel, position_bottom_xlabel as _ui_position_bottom_xlabel, position_left_ylabel as _ui_position_left_ylabel, position_right_ylabel as _ui_position_right_ylabel
2304
2453
 
2305
2454
  def _get_tick_state(a):
2306
2455
  # Unified keys with fallbacks for legacy combined flags
@@ -2386,7 +2535,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2386
2535
  print(_colorize_inline_commands("Choose pane: o=operando, e=ec, q=back"))
2387
2536
  else:
2388
2537
  print(_colorize_inline_commands("Choose pane: o=operando, q=back"))
2389
- pane = input("ot> ").strip().lower()
2538
+ pane = _safe_input("ot> ").strip().lower()
2390
2539
  if not pane:
2391
2540
  continue
2392
2541
  if pane == 'q':
@@ -2605,7 +2754,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2605
2754
  print(_colorize_inline_commands("Type 'i' to invert tick direction, 'l' to change tick length, 'list' for state, 'q' to return."))
2606
2755
  print(_colorize_inline_commands(" p = adjust title offsets (w=top, s=bottom, a=left, d=right)"))
2607
2756
  while True:
2608
- cmd2 = input(_colorize_prompt("Toggle> ")).strip().lower()
2757
+ cmd2 = _safe_input(_colorize_prompt("Toggle> ")).strip().lower()
2609
2758
  if not cmd2:
2610
2759
  continue
2611
2760
  if cmd2 == 'q':
@@ -2630,7 +2779,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2630
2779
  # Get current major tick length from axes
2631
2780
  current_major = ax.xaxis.get_major_ticks()[0].tick1line.get_markersize() if ax.xaxis.get_major_ticks() else 4.0
2632
2781
  print(f"Current major tick length: {current_major}")
2633
- new_length_str = input("Enter new major tick length (e.g., 6.0): ").strip()
2782
+ new_length_str = _safe_input("Enter new major tick length (e.g., 6.0): ").strip()
2634
2783
  if not new_length_str:
2635
2784
  continue
2636
2785
  new_major = float(new_length_str)
@@ -2723,7 +2872,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2723
2872
  current_y_px = _px_value('_top_xlabel_manual_offset_y_pts', target)
2724
2873
  current_x_px = _px_value('_top_xlabel_manual_offset_x_pts', target)
2725
2874
  print(f"Top title offset: Y={current_y_px:+.2f} px (positive=up), X={current_x_px:+.2f} px (positive=right)")
2726
- sub = input(_colorize_prompt("top (w=up, s=down, a=left, d=right, 0=reset, q=back): ")).strip().lower()
2875
+ sub = _safe_input(_colorize_prompt("top (w=up, s=down, a=left, d=right, 0=reset, q=back): ")).strip().lower()
2727
2876
  if not sub:
2728
2877
  continue
2729
2878
  if sub == 'q':
@@ -2760,7 +2909,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2760
2909
  while True:
2761
2910
  current_y_px = _px_value('_bottom_xlabel_manual_offset_y_pts', target)
2762
2911
  print(f"Bottom title offset: Y={current_y_px:+.2f} px (positive=down)")
2763
- sub = input(_colorize_prompt("bottom (s=down, w=up, 0=reset, q=back): ")).strip().lower()
2912
+ sub = _safe_input(_colorize_prompt("bottom (s=down, w=up, 0=reset, q=back): ")).strip().lower()
2764
2913
  if not sub:
2765
2914
  continue
2766
2915
  if sub == 'q':
@@ -2790,7 +2939,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2790
2939
  while True:
2791
2940
  current_x_px = _px_value('_left_ylabel_manual_offset_x_pts', target)
2792
2941
  print(f"Left title offset: X={current_x_px:+.2f} px (positive=left)")
2793
- sub = input(_colorize_prompt("left (a=left, d=right, 0=reset, q=back): ")).strip().lower()
2942
+ sub = _safe_input(_colorize_prompt("left (a=left, d=right, 0=reset, q=back): ")).strip().lower()
2794
2943
  if not sub:
2795
2944
  continue
2796
2945
  if sub == 'q':
@@ -2827,7 +2976,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2827
2976
  current_x_px = _px_value('_right_ylabel_manual_offset_x_pts', target)
2828
2977
  current_y_px = _px_value('_right_ylabel_manual_offset_y_pts', target)
2829
2978
  print(f"Right title offset: X={current_x_px:+.2f} px (positive=right), Y={current_y_px:+.2f} px (positive=up)")
2830
- sub = input(_colorize_prompt("right (d=right, a=left, w=up, s=down, 0=reset, q=back): ")).strip().lower()
2979
+ sub = _safe_input(_colorize_prompt("right (d=right, a=left, w=up, s=down, 0=reset, q=back): ")).strip().lower()
2831
2980
  if not sub:
2832
2981
  continue
2833
2982
  if sub == 'q':
@@ -2866,7 +3015,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2866
3015
  print(" " + _colorize_menu('d : adjust right title (d=right, a=left, w=up, s=down)'))
2867
3016
  print(" " + _colorize_menu('r : reset all offsets'))
2868
3017
  print(" " + _colorize_menu('q : return'))
2869
- choice = input(_colorize_prompt("p> ")).strip().lower()
3018
+ choice = _safe_input(_colorize_prompt("p> ")).strip().lower()
2870
3019
  if not choice:
2871
3020
  continue
2872
3021
  if choice == 'q':
@@ -2982,7 +3131,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2982
3131
  elif cmd == 'ox':
2983
3132
  while True:
2984
3133
  cur = ax.get_xlim(); print(f"Current operando X: {cur[0]:.4g} {cur[1]:.4g}")
2985
- line = input("New X range (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
3134
+ line = _safe_input("New X range (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
2986
3135
  if not line or line.lower() == 'q':
2987
3136
  break
2988
3137
  if line.lower() == 'w':
@@ -2990,7 +3139,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2990
3139
  while True:
2991
3140
  cur = ax.get_xlim()
2992
3141
  print(f"Current operando X: {cur[0]:.4g} {cur[1]:.4g}")
2993
- val = input(f"Enter new upper X limit (current lower: {cur[0]:.4g}, q=back): ").strip()
3142
+ val = _safe_input(f"Enter new upper X limit (current lower: {cur[0]:.4g}, q=back): ").strip()
2994
3143
  if not val or val.lower() == 'q':
2995
3144
  break
2996
3145
  try:
@@ -3009,7 +3158,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3009
3158
  while True:
3010
3159
  cur = ax.get_xlim()
3011
3160
  print(f"Current operando X: {cur[0]:.4g} {cur[1]:.4g}")
3012
- val = input(f"Enter new lower X limit (current upper: {cur[1]:.4g}, q=back): ").strip()
3161
+ val = _safe_input(f"Enter new lower X limit (current upper: {cur[1]:.4g}, q=back): ").strip()
3013
3162
  if not val or val.lower() == 'q':
3014
3163
  break
3015
3164
  try:
@@ -3055,7 +3204,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3055
3204
  elif cmd == 'oy':
3056
3205
  while True:
3057
3206
  cur = ax.get_ylim(); print(f"Current operando Y: {cur[0]:.4g} {cur[1]:.4g}")
3058
- line = input("New Y range (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
3207
+ line = _safe_input("New Y range (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
3059
3208
  if not line or line.lower() == 'q':
3060
3209
  break
3061
3210
  if line.lower() == 'w':
@@ -3063,7 +3212,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3063
3212
  while True:
3064
3213
  cur = ax.get_ylim()
3065
3214
  print(f"Current operando Y: {cur[0]:.4g} {cur[1]:.4g}")
3066
- val = input(f"Enter new upper Y limit (current lower: {cur[0]:.4g}, q=back): ").strip()
3215
+ val = _safe_input(f"Enter new upper Y limit (current lower: {cur[0]:.4g}, q=back): ").strip()
3067
3216
  if not val or val.lower() == 'q':
3068
3217
  break
3069
3218
  try:
@@ -3082,7 +3231,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3082
3231
  while True:
3083
3232
  cur = ax.get_ylim()
3084
3233
  print(f"Current operando Y: {cur[0]:.4g} {cur[1]:.4g}")
3085
- val = input(f"Enter new lower Y limit (current upper: {cur[1]:.4g}, q=back): ").strip()
3234
+ val = _safe_input(f"Enter new lower Y limit (current upper: {cur[1]:.4g}, q=back): ").strip()
3086
3235
  if not val or val.lower() == 'q':
3087
3236
  break
3088
3237
  try:
@@ -3185,9 +3334,9 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3185
3334
  auto_available = False
3186
3335
 
3187
3336
  if auto_available:
3188
- line = input("New intensity range (min max, w=upper only, s=lower only, a=auto-fit to visible, q=back): ").strip()
3337
+ line = _safe_input("New intensity range (min max, w=upper only, s=lower only, a=auto-fit to visible, q=back): ").strip()
3189
3338
  else:
3190
- line = input("New intensity range (min max, w=upper only, s=lower only, q=back): ").strip()
3339
+ line = _safe_input("New intensity range (min max, w=upper only, s=lower only, q=back): ").strip()
3191
3340
 
3192
3341
  if not line or line.lower() == 'q':
3193
3342
  break
@@ -3201,7 +3350,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3201
3350
  except Exception:
3202
3351
  print("Could not retrieve current color scale range")
3203
3352
  break
3204
- val = input(f"Enter new upper intensity limit (current lower: {cur[0]:.4g}, q=back): ").strip()
3353
+ val = _safe_input(f"Enter new upper intensity limit (current lower: {cur[0]:.4g}, q=back): ").strip()
3205
3354
  if not val or val.lower() == 'q':
3206
3355
  break
3207
3356
  try:
@@ -3210,7 +3359,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3210
3359
  print("Invalid value, ignored.")
3211
3360
  continue
3212
3361
  _snapshot("operando-intensity-range")
3213
- im.set_clim(cur[0], new_upper)
3362
+ _safe_set_clim(im, cur[0], new_upper)
3214
3363
  try:
3215
3364
  if cbar is not None:
3216
3365
  _update_custom_colorbar(cbar.ax, im)
@@ -3227,7 +3376,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3227
3376
  except Exception:
3228
3377
  print("Could not retrieve current color scale range")
3229
3378
  break
3230
- val = input(f"Enter new lower intensity limit (current upper: {cur[1]:.4g}, q=back): ").strip()
3379
+ val = _safe_input(f"Enter new lower intensity limit (current upper: {cur[1]:.4g}, q=back): ").strip()
3231
3380
  if not val or val.lower() == 'q':
3232
3381
  break
3233
3382
  try:
@@ -3236,7 +3385,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3236
3385
  print("Invalid value, ignored.")
3237
3386
  continue
3238
3387
  _snapshot("operando-intensity-range")
3239
- im.set_clim(new_lower, cur[1])
3388
+ _safe_set_clim(im, new_lower, cur[1])
3240
3389
  try:
3241
3390
  if cbar is not None:
3242
3391
  _update_custom_colorbar(cbar.ax, im)
@@ -3250,7 +3399,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3250
3399
  if line.lower() == 'a':
3251
3400
  # Apply auto-normalization to visible data
3252
3401
  if auto_available:
3253
- im.set_clim(auto_lo, auto_hi)
3402
+ _safe_set_clim(im, auto_lo, auto_hi)
3254
3403
  try:
3255
3404
  if cbar is not None:
3256
3405
  _update_custom_colorbar(cbar.ax, im)
@@ -3262,7 +3411,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3262
3411
  print("Auto-fit unavailable: no finite data in visible area")
3263
3412
  else:
3264
3413
  lo, hi = map(float, line.split())
3265
- im.set_clim(lo, hi)
3414
+ _safe_set_clim(im, lo, hi)
3266
3415
  try:
3267
3416
  if cbar is not None:
3268
3417
  _update_custom_colorbar(cbar.ax, im)
@@ -3278,7 +3427,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3278
3427
  while True:
3279
3428
  ax_w_in = getattr(ax, '_fixed_ax_w_in', ax_w_in)
3280
3429
  print(f"Current operando width: {ax_w_in:.2f} in")
3281
- val = input("New width (inches, q=back): ").strip()
3430
+ val = _safe_input("New width (inches, q=back): ").strip()
3282
3431
  if not val or val.lower() == 'q':
3283
3432
  break
3284
3433
  _snapshot("operando-width")
@@ -3298,7 +3447,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3298
3447
  while True:
3299
3448
  ec_w_in = getattr(ec_ax, '_fixed_ec_w_in', ec_w_in)
3300
3449
  print(f"Current EC width: {ec_w_in:.2f} in")
3301
- val = input("New EC width (inches, q=back): ").strip()
3450
+ val = _safe_input("New EC width (inches, q=back): ").strip()
3302
3451
  if not val or val.lower() == 'q':
3303
3452
  break
3304
3453
  _snapshot("ec-width")
@@ -3311,6 +3460,15 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3311
3460
  print_menu()
3312
3461
  elif cmd == 'oc':
3313
3462
  # Change operando colormap (perceptually uniform suggestions)
3463
+ # Show current colormap if one is applied
3464
+ try:
3465
+ current_cmap = getattr(im, '_operando_cmap_name', None)
3466
+ if current_cmap is None:
3467
+ current_cmap = getattr(im.get_cmap(), 'name', None)
3468
+ if current_cmap:
3469
+ print(f"Current operando colormap: {current_cmap}")
3470
+ except Exception:
3471
+ pass
3314
3472
  def _refresh_available():
3315
3473
  return set(name.lower() for name in plt.colormaps())
3316
3474
  available = _refresh_available()
@@ -3341,7 +3499,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3341
3499
  if optional:
3342
3500
  print("\nOther available: " + ", ".join(optional))
3343
3501
  print(_colorize_inline_commands("Append _r to reverse (e.g., viridis_r or 1_r). Blank to cancel."))
3344
- choice = input(f"Palette name or number (1-{len(rec_palettes)}): ").strip()
3502
+ choice = _safe_input(f"Palette name or number (1-{len(rec_palettes)}): ").strip()
3345
3503
  if not choice:
3346
3504
  print_menu(); continue
3347
3505
  try:
@@ -3383,6 +3541,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3383
3541
  if reversed_choice:
3384
3542
  palette_obj = palette_obj.reversed()
3385
3543
  im.set_cmap(palette_obj)
3544
+ # Store the colormap name explicitly so it can be retrieved reliably when saving
3545
+ setattr(im, '_operando_cmap_name', choice)
3386
3546
  try:
3387
3547
  # Update custom colorbar with new colormap
3388
3548
  if cbar is not None:
@@ -3419,7 +3579,10 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3419
3579
  cb_w_in, cb_gap_in, ec_gap_in, ec_w_in, ax_w_in, ax_h_in = _ensure_fixed_params(fig, ax, cbar.ax, ec_ax)
3420
3580
  fam = plt.rcParams.get('font.sans-serif', [''])[0]
3421
3581
  fsize = plt.rcParams.get('font.size', None)
3422
- cmap_name = getattr(im.get_cmap(), 'name', None)
3582
+ # Get colormap name: first check if we stored it explicitly, otherwise try to get from colormap object
3583
+ cmap_name = getattr(im, '_operando_cmap_name', None)
3584
+ if cmap_name is None:
3585
+ cmap_name = getattr(im.get_cmap(), 'name', None)
3423
3586
  cb_vis = bool(cbar.ax.get_visible())
3424
3587
  ec_vis = bool(ec_ax.get_visible()) if ec_ax is not None else None
3425
3588
  cb_label_text = str(getattr(cbar.ax, '_colorbar_label', cbar.ax.get_ylabel() or 'Intensity'))
@@ -3590,7 +3753,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3590
3753
  last_style_path = getattr(fig, '_last_style_export_path', None)
3591
3754
  if ec_ax is None:
3592
3755
  print("\nNote: Style export (.bps/.bpsg) is only available in dual-pane mode (with EC file).")
3593
- sub = input("Style submenu: (q=return, r=refresh): ").strip().lower()
3756
+ sub = _safe_input("Style submenu: (q=return, r=refresh): ").strip().lower()
3594
3757
  if sub == 'q':
3595
3758
  break
3596
3759
  if sub == 'r' or sub == '':
@@ -3600,9 +3763,9 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3600
3763
  continue
3601
3764
  else:
3602
3765
  if last_style_path:
3603
- sub = input("Style submenu: (e=export, o=overwrite last, q=return, r=refresh): ").strip().lower()
3766
+ sub = _safe_input("Style submenu: (e=export, o=overwrite last, q=return, r=refresh): ").strip().lower()
3604
3767
  else:
3605
- sub = input("Style submenu: (e=export, q=return, r=refresh): ").strip().lower()
3768
+ sub = _safe_input("Style submenu: (e=export, q=return, r=refresh): ").strip().lower()
3606
3769
  if sub == 'q':
3607
3770
  break
3608
3771
  if sub == 'r' or sub == '':
@@ -3615,7 +3778,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3615
3778
  if not os.path.exists(last_style_path):
3616
3779
  print(f"Previous export file not found: {last_style_path}")
3617
3780
  continue
3618
- yn = input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
3781
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
3619
3782
  if yn != 'y':
3620
3783
  continue
3621
3784
  # Determine export type from existing file and rebuild config
@@ -3636,7 +3799,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3636
3799
  print("Export options:")
3637
3800
  print(" ps = style only (.bps)")
3638
3801
  print(" psg = style + geometry (.bpsg)")
3639
- exp_choice = input("Export choice (ps/psg, q=cancel): ").strip().lower()
3802
+ exp_choice = _safe_input("Export choice (ps/psg, q=cancel): ").strip().lower()
3640
3803
  if not exp_choice or exp_choice == 'q':
3641
3804
  print("Style export canceled.")
3642
3805
  continue
@@ -3830,7 +3993,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3830
3993
  else:
3831
3994
  print(f" {_i}: {fname}")
3832
3995
 
3833
- choice_name = input("Enter new filename or number to overwrite (q=cancel): ").strip()
3996
+ choice_name = _safe_input("Enter new filename or number to overwrite (q=cancel): ").strip()
3834
3997
  if not choice_name or choice_name.lower() == 'q':
3835
3998
  print("Style export canceled.")
3836
3999
  continue
@@ -3839,7 +4002,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3839
4002
  _idx = int(choice_name)
3840
4003
  if 1 <= _idx <= len(_style_files):
3841
4004
  name = _style_files[_idx-1]
3842
- yn = input(f"Overwrite '{name}'? (y/n): ").strip().lower()
4005
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
3843
4006
  if yn == 'y':
3844
4007
  target = file_list[_idx-1][1] # Full path from list
3845
4008
  else:
@@ -3856,7 +4019,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3856
4019
  else:
3857
4020
  target = get_organized_path(name, 'style', base_path=save_base)
3858
4021
  if os.path.exists(target):
3859
- yn = input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
4022
+ yn = _safe_input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
3860
4023
  if yn != 'y':
3861
4024
  target = None
3862
4025
  if target:
@@ -3986,6 +4149,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3986
4149
  if cmap:
3987
4150
  try:
3988
4151
  im.set_cmap(cmap)
4152
+ # Store the colormap name explicitly so it can be retrieved reliably when saving
4153
+ setattr(im, '_operando_cmap_name', cmap)
3989
4154
  if cbar is not None:
3990
4155
  _update_custom_colorbar(cbar.ax, im)
3991
4156
  except Exception:
@@ -4259,7 +4424,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4259
4424
  try:
4260
4425
  intensity_range = op.get('intensity_range')
4261
4426
  if intensity_range and isinstance(intensity_range, (list, tuple)) and len(intensity_range) == 2:
4262
- im.set_clim(float(intensity_range[0]), float(intensity_range[1]))
4427
+ _safe_set_clim(im, float(intensity_range[0]), float(intensity_range[1]))
4263
4428
  print(f"Applied intensity range: {intensity_range[0]:.4g} to {intensity_range[1]:.4g}")
4264
4429
  except Exception as e:
4265
4430
  print(f"Warning: Could not apply intensity range: {e}")
@@ -4553,16 +4718,18 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4553
4718
  print("Tip: Use LaTeX/mathtext for special characters:")
4554
4719
  print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
4555
4720
  print(" Bullet: $\\bullet$ → • | Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
4721
+ print(" Shortcuts: g{super(-1)} → g$^{\\mathrm{-1}}$ | Li{sub(2)}O → Li$_{\\mathrm{2}}$O")
4556
4722
  while True:
4557
- sub = input("or> ").strip().lower()
4723
+ sub = _safe_input("or> ").strip().lower()
4558
4724
  if not sub:
4559
4725
  continue
4560
4726
  if sub == 'q':
4561
4727
  break
4562
4728
  if sub == 'x':
4563
4729
  cur = ax.get_xlabel() or ''
4564
- lab = input(f"New operando X label (blank=cancel, current='{cur}'): ")
4730
+ lab = _safe_input(f"New operando X label (blank=cancel, current='{cur}'): ")
4565
4731
  if lab:
4732
+ lab = convert_label_shortcuts(lab)
4566
4733
  _snapshot("rename-op-x")
4567
4734
  try:
4568
4735
  ax.set_xlabel(lab)
@@ -4574,8 +4741,9 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4574
4741
  pass
4575
4742
  elif sub == 'y':
4576
4743
  cur = ax.get_ylabel() or ''
4577
- lab = input(f"New operando Y label (blank=cancel, current='{cur}'): ")
4744
+ lab = _safe_input(f"New operando Y label (blank=cancel, current='{cur}'): ")
4578
4745
  if lab:
4746
+ lab = convert_label_shortcuts(lab)
4579
4747
  _snapshot("rename-op-y")
4580
4748
  try:
4581
4749
  ax.set_ylabel(lab)
@@ -4605,16 +4773,18 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4605
4773
  print("Tip: Use LaTeX/mathtext for special characters:")
4606
4774
  print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
4607
4775
  print(" Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
4776
+ print(" Shortcuts: g{super(-1)} → g$^{\\mathrm{-1}}$ | Li{sub(2)}O → Li$_{\\mathrm{2}}$O")
4608
4777
  while True:
4609
- sub = input("er> ").strip().lower()
4778
+ sub = _safe_input("er> ").strip().lower()
4610
4779
  if not sub:
4611
4780
  continue
4612
4781
  if sub == 'q':
4613
4782
  break
4614
4783
  if sub == 'x':
4615
4784
  cur = ec_ax.get_xlabel() or ''
4616
- lab = input(f"New EC X label (blank=cancel, current='{cur}'): ")
4785
+ lab = _safe_input(f"New EC X label (blank=cancel, current='{cur}'): ")
4617
4786
  if lab:
4787
+ lab = convert_label_shortcuts(lab)
4618
4788
  _snapshot("rename-ec-x")
4619
4789
  try:
4620
4790
  ec_ax.set_xlabel(lab)
@@ -4625,8 +4795,9 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4625
4795
  pass
4626
4796
  elif sub == 'y':
4627
4797
  cur = ec_ax.get_ylabel() or ''
4628
- lab = input(f"New EC Y label (blank=cancel, current='{cur}'): ")
4798
+ lab = _safe_input(f"New EC Y label (blank=cancel, current='{cur}'): ")
4629
4799
  if lab:
4800
+ lab = convert_label_shortcuts(lab)
4630
4801
  _snapshot("rename-ec-y")
4631
4802
  try:
4632
4803
  ec_ax.set_ylabel(lab)
@@ -4663,7 +4834,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4663
4834
  print_menu(); continue
4664
4835
  print("EC line submenu: c=color, l=linewidth, q=back")
4665
4836
  while True:
4666
- sub = input("el> ").strip().lower()
4837
+ sub = _safe_input("el> ").strip().lower()
4667
4838
  if not sub:
4668
4839
  continue
4669
4840
  if sub == 'q':
@@ -4679,7 +4850,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4679
4850
  else:
4680
4851
  print("\nNo saved colors. Type 'u' to manage saved colors.")
4681
4852
  print(" (Enter color name/hex, saved color number, or 'u' to manage)")
4682
- val = input(f"Color (current={cur}, blank=cancel): ").strip()
4853
+ val = _safe_input(f"Color (current={cur}, blank=cancel): ").strip()
4683
4854
  if not val:
4684
4855
  continue
4685
4856
  if val.lower() == 'u':
@@ -4696,7 +4867,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4696
4867
  print(f"Invalid color: {e}")
4697
4868
  elif sub == 'l':
4698
4869
  cur = ln.get_linewidth()
4699
- val = input(f"Line width (current={cur}, blank=cancel): ").strip()
4870
+ val = _safe_input(f"Line width (current={cur}, blank=cancel): ").strip()
4700
4871
  if not val:
4701
4872
  continue
4702
4873
  _snapshot("ec-line-width")
@@ -4721,7 +4892,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4721
4892
  continue
4722
4893
  while True:
4723
4894
  cur = ec_ax.get_ylim(); print(f"Current EC time range (Y): {cur[0]:.4g} {cur[1]:.4g}")
4724
- line = input("New time range (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
4895
+ line = _safe_input("New time range (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
4725
4896
  if not line or line.lower() == 'q':
4726
4897
  break
4727
4898
  if line.lower() == 'w':
@@ -4729,7 +4900,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4729
4900
  while True:
4730
4901
  cur = ec_ax.get_ylim()
4731
4902
  print(f"Current EC time range (Y): {cur[0]:.4g} {cur[1]:.4g}")
4732
- val = input(f"Enter new upper time limit (current lower: {cur[0]:.4g}, q=back): ").strip()
4903
+ val = _safe_input(f"Enter new upper time limit (current lower: {cur[0]:.4g}, q=back): ").strip()
4733
4904
  if not val or val.lower() == 'q':
4734
4905
  break
4735
4906
  try:
@@ -4747,7 +4918,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4747
4918
  while True:
4748
4919
  cur = ec_ax.get_ylim()
4749
4920
  print(f"Current EC time range (Y): {cur[0]:.4g} {cur[1]:.4g}")
4750
- val = input(f"Enter new lower time limit (current upper: {cur[1]:.4g}, q=back): ").strip()
4921
+ val = _safe_input(f"Enter new lower time limit (current upper: {cur[1]:.4g}, q=back): ").strip()
4751
4922
  if not val or val.lower() == 'q':
4752
4923
  break
4753
4924
  try:
@@ -4861,7 +5032,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4861
5032
  print("The .mpt file must contain the '<I>/mA' column to use this feature.")
4862
5033
  print_menu(); continue
4863
5034
  while True:
4864
- sub = input("ey submenu: n=ions, t=time, q=back: ").strip().lower()
5035
+ sub = _safe_input("ey submenu: n=ions, t=time, q=back: ").strip().lower()
4865
5036
  if not sub:
4866
5037
  continue
4867
5038
  if sub == 'q':
@@ -4878,7 +5049,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4878
5049
  prompt = "Enter mass(mg), capacity-per-ion(mAh g^-1), start-ions (e.g. 4.5 26.8 0), q=cancel: "
4879
5050
  else:
4880
5051
  prompt = f"Enter mass,cap-per-ion,start-ions (blank=reuse {mass_mg} {cap_per_ion} {start_ions}; q=cancel): "
4881
- s = input(prompt).strip()
5052
+ s = _safe_input(prompt).strip()
4882
5053
  if not s:
4883
5054
  if need_input:
4884
5055
  continue
@@ -5164,7 +5335,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
5164
5335
  while True:
5165
5336
  cur = ec_ax.get_xlim()
5166
5337
  print(f"Current EC X range: {cur[0]:.4g} {cur[1]:.4g}")
5167
- line = input("New EC X range (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
5338
+ line = _safe_input("New EC X range (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
5168
5339
  if not line or line.lower() == 'q':
5169
5340
  break
5170
5341
  if line.lower() == 'w':
@@ -5172,7 +5343,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
5172
5343
  while True:
5173
5344
  cur = ec_ax.get_xlim()
5174
5345
  print(f"Current EC X range: {cur[0]:.4g} {cur[1]:.4g}")
5175
- val = input(f"Enter new upper EC X limit (current lower: {cur[0]:.4g}, q=back): ").strip()
5346
+ val = _safe_input(f"Enter new upper EC X limit (current lower: {cur[0]:.4g}, q=back): ").strip()
5176
5347
  if not val or val.lower() == 'q':
5177
5348
  break
5178
5349
  try:
@@ -5191,7 +5362,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
5191
5362
  while True:
5192
5363
  cur = ec_ax.get_xlim()
5193
5364
  print(f"Current EC X range: {cur[0]:.4g} {cur[1]:.4g}")
5194
- val = input(f"Enter new lower EC X limit (current upper: {cur[1]:.4g}, q=back): ").strip()
5365
+ val = _safe_input(f"Enter new lower EC X limit (current upper: {cur[1]:.4g}, q=back): ").strip()
5195
5366
  if not val or val.lower() == 'q':
5196
5367
  break
5197
5368
  try:
@@ -5253,7 +5424,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
5253
5424
  cur_w, cur_h = _get_fig_size(fig)
5254
5425
  print(f"Current canvas size: {cur_w:.2f} x {cur_h:.2f} in (W x H)")
5255
5426
  print("Canvas: only figure size will change; panel widths/gaps are not altered.")
5256
- line = input("New canvas size 'W H' (blank=cancel): ").strip()
5427
+ line = _safe_input("New canvas size 'W H' (blank=cancel): ").strip()
5257
5428
  if line:
5258
5429
  _snapshot("canvas-size")
5259
5430
  try: