batplot 1.7.27__py3-none-any.whl → 1.8.0__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
@@ -41,6 +42,37 @@ from .color_utils import (
41
42
  from .utils import choose_style_file
42
43
 
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
74
+
75
+
44
76
  def _axis_tick_width(axis_obj, which: str = 'major'):
45
77
  """Return tick line width from axis tick params or rc defaults."""
46
78
  try:
@@ -344,6 +376,94 @@ def _update_custom_colorbar(cbar_ax, im=None, label=None, label_mode=None):
344
376
  _draw_custom_colorbar(cbar_ax, im, label, label_mode)
345
377
 
346
378
 
379
+ def _detach_mpl_colorbar_callbacks(cbar, im) -> None:
380
+ """Detach a Matplotlib Colorbar from its mappable callbacks.
381
+
382
+ Why this exists:
383
+ - In this interactive menu we draw a *custom* colorbar by clearing/redrawing `cbar.ax`.
384
+ - If `cbar` is a real `matplotlib.colorbar.Colorbar` (e.g., loaded from a session),
385
+ it remains connected to `im` via `im.callbacksSM`. Subsequent `im.set_clim()` /
386
+ `im.set_cmap()` triggers `Colorbar.update_normal()`, which can crash after we
387
+ cleared/redrew the axes (observed as: NotImplementedError: cannot remove artist).
388
+ - We therefore disconnect that callback once and always update the custom colorbar
389
+ via `_update_custom_colorbar(...)`.
390
+ """
391
+ try:
392
+ if cbar is None or im is None:
393
+ return
394
+ cax = getattr(cbar, 'ax', None)
395
+ if cax is not None and getattr(cax, '_bp_detached_mpl_colorbar', False):
396
+ return
397
+
398
+ # APPROACH 1: Try to find and disconnect the callback ID
399
+ cid = None
400
+ for attr in ('_cid', '_cid_colorbar', 'cid'):
401
+ try:
402
+ v = getattr(cbar, attr, None)
403
+ if isinstance(v, int):
404
+ cid = v
405
+ break
406
+ except Exception:
407
+ pass
408
+
409
+ if cid is not None:
410
+ try:
411
+ cbreg = getattr(im, 'callbacksSM', None)
412
+ if cbreg is not None and hasattr(cbreg, 'disconnect'):
413
+ cbreg.disconnect(cid)
414
+ except Exception:
415
+ pass
416
+
417
+ # APPROACH 2: Disconnect ALL callbacks from the image (nuclear option)
418
+ try:
419
+ cbreg = getattr(im, 'callbacksSM', None)
420
+ if cbreg is not None:
421
+ # Try to clear all callbacks
422
+ if hasattr(cbreg, 'callbacks'):
423
+ try:
424
+ cbreg.callbacks.clear()
425
+ except Exception:
426
+ pass
427
+ # Also try the _signals dict if it exists
428
+ if hasattr(cbreg, '_signals'):
429
+ try:
430
+ for signal_dict in cbreg._signals.values():
431
+ if hasattr(signal_dict, 'clear'):
432
+ signal_dict.clear()
433
+ except Exception:
434
+ pass
435
+ except Exception:
436
+ pass
437
+
438
+ # APPROACH 3: Monkey-patch the update_normal method to be a no-op
439
+ # This is the most reliable approach for preventing the callback
440
+ try:
441
+ if hasattr(cbar, 'update_normal'):
442
+ def _noop_update(*args, **kwargs):
443
+ pass
444
+ cbar.update_normal = _noop_update
445
+ except Exception:
446
+ pass
447
+
448
+ # APPROACH 4: Prevent future built-in updates by nulling internal state
449
+ try:
450
+ if hasattr(cbar, 'mappable'):
451
+ cbar.mappable = None
452
+ except Exception:
453
+ pass
454
+ try:
455
+ if hasattr(cbar, 'solids'):
456
+ cbar.solids = None
457
+ except Exception:
458
+ pass
459
+
460
+ if cax is not None:
461
+ setattr(cax, '_bp_detached_mpl_colorbar', True)
462
+ except Exception:
463
+ # Never let detaching break the interactive menu.
464
+ return
465
+
466
+
347
467
  def _ensure_fixed_params(fig, ax, cbar_ax, ec_ax):
348
468
  """Initialize and return fixed geometry parameters in inches.
349
469
 
@@ -540,6 +660,11 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
540
660
  # Normalize file path list for downstream helpers
541
661
  file_paths = list(file_paths) if file_paths else []
542
662
 
663
+ # If we were given a real Matplotlib Colorbar (e.g. from session load),
664
+ # detach it from `im` immediately. This must happen before any function
665
+ # that may clear/redraw `cbar.ax` (custom colorbar) is called.
666
+ _detach_mpl_colorbar_callbacks(cbar, im)
667
+
543
668
  def _renormalize_to_visible():
544
669
  """Adjust color scale to match the intensity range of the currently visible region.
545
670
 
@@ -904,6 +1029,9 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
904
1029
  # Initialize custom colorbar (replaces matplotlib's colorbar)
905
1030
  cbar_label = getattr(cbar.ax, '_colorbar_label', 'Intensity')
906
1031
  cbar_label_mode = getattr(fig, '_colorbar_label_mode', 'normal')
1032
+ # If we were given a real Matplotlib Colorbar (e.g. from session load),
1033
+ # detach it from `im` before we clear/redraw the axes for the custom colorbar.
1034
+ _detach_mpl_colorbar_callbacks(cbar, im)
907
1035
  _draw_custom_colorbar(cbar.ax, im, cbar_label, cbar_label_mode)
908
1036
  # Decrease distance between operando and EC plots once per session
909
1037
  if not getattr(ec_ax, '_ec_gap_adjusted', False):
@@ -1204,7 +1332,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1204
1332
  if snap.get('cmap'):
1205
1333
  im.set_cmap(snap['cmap'])
1206
1334
  if cbar is not None:
1207
- cbar.update_normal(im)
1335
+ _update_custom_colorbar(cbar.ax, im)
1208
1336
  except Exception:
1209
1337
  pass
1210
1338
  # Restore colorbar side (ticks/label) and redraw custom colorbar to keep position
@@ -1667,7 +1795,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1667
1795
  print("Crosshair OFF.")
1668
1796
  while True:
1669
1797
  try:
1670
- cmd = input("Press a key: ").strip().lower()
1798
+ cmd = _safe_input("Press a key: ").strip().lower()
1671
1799
  except (KeyboardInterrupt, EOFError):
1672
1800
  print("\n\nExiting interactive menu...")
1673
1801
  break
@@ -1675,7 +1803,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1675
1803
  continue
1676
1804
  if cmd == 'q':
1677
1805
  try:
1678
- ans = input("Quit interactive? Remember to save (e=export, s=save). Quit now? (y/n): ").strip().lower()
1806
+ ans = _safe_input("Quit interactive? Remember to save (e=export, s=save). Quit now? (y/n): ").strip().lower()
1679
1807
  except Exception:
1680
1808
  ans = 'y'
1681
1809
  if ans == 'y':
@@ -1718,9 +1846,9 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1718
1846
 
1719
1847
  last_figure_path = getattr(fig, '_last_figure_export_path', None)
1720
1848
  if last_figure_path:
1721
- fname = input("Export filename (default .svg if no extension), number to overwrite, or o to overwrite last (q=cancel): ").strip()
1849
+ fname = _safe_input("Export filename (default .svg if no extension), number to overwrite, or o to overwrite last (q=cancel): ").strip()
1722
1850
  else:
1723
- fname = input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
1851
+ fname = _safe_input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
1724
1852
  if not fname or fname.lower() == 'q':
1725
1853
  print_menu(); continue
1726
1854
 
@@ -1733,7 +1861,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1733
1861
  if not os.path.exists(last_figure_path):
1734
1862
  print(f"Previous export file not found: {last_figure_path}")
1735
1863
  print_menu(); continue
1736
- yn = input(f"Overwrite '{os.path.basename(last_figure_path)}'? (y/n): ").strip().lower()
1864
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_figure_path)}'? (y/n): ").strip().lower()
1737
1865
  if yn != 'y':
1738
1866
  print_menu(); continue
1739
1867
  target = last_figure_path
@@ -1744,7 +1872,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1744
1872
  idx = int(fname)
1745
1873
  if 1 <= idx <= len(files):
1746
1874
  name = files[idx-1]
1747
- yn = input(f"Overwrite '{name}'? (y/n): ").strip().lower()
1875
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
1748
1876
  if yn != 'y':
1749
1877
  print_menu(); continue
1750
1878
  target = file_list[idx-1][1] # Full path from list
@@ -1823,7 +1951,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1823
1951
  cb_h_offset = getattr(cbar.ax, '_cb_h_offset_in', 0.0)
1824
1952
  ec_h_offset = getattr(ec_ax, '_ec_h_offset_in', 0.0)
1825
1953
  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")
1826
- choice = input("v> ").strip().lower()
1954
+ choice = _safe_input("v> ").strip().lower()
1827
1955
  if choice == '1':
1828
1956
  # Toggle colorbar
1829
1957
  cb_vis = cbar.ax.get_visible()
@@ -1857,7 +1985,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1857
1985
  # Change colorbar label text
1858
1986
  current_label = getattr(cbar.ax, '_colorbar_label', 'Intensity')
1859
1987
  print(f"Current colorbar label: {current_label}")
1860
- new_label = input("New colorbar label (blank to keep): ").strip()
1988
+ new_label = _safe_input("New colorbar label (blank to keep): ").strip()
1861
1989
  if new_label:
1862
1990
  cbar.ax._colorbar_label = new_label
1863
1991
  try:
@@ -1878,12 +2006,12 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1878
2006
  if ec_ax is not None:
1879
2007
  print(f" EC panel offset: {ec_h_offset:.3f}\" (positive=right, negative=left)")
1880
2008
  print("Commands: c=colorbar, e=EC panel, q=back")
1881
- sub = input("m> ").strip().lower()
2009
+ sub = _safe_input("m> ").strip().lower()
1882
2010
  if not sub or sub == 'q':
1883
2011
  break
1884
2012
  if sub == 'c':
1885
2013
  try:
1886
- new_offset = input(f"Enter colorbar horizontal offset in inches (current: {cb_h_offset:.3f}\"): ").strip()
2014
+ new_offset = _safe_input(f"Enter colorbar horizontal offset in inches (current: {cb_h_offset:.3f}\"): ").strip()
1887
2015
  if new_offset:
1888
2016
  cb_h_offset = float(new_offset)
1889
2017
  setattr(cbar.ax, '_cb_h_offset_in', cb_h_offset)
@@ -1891,6 +2019,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1891
2019
  _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)
1892
2020
  fig.canvas.draw_idle()
1893
2021
  print(f"Colorbar horizontal offset set to {cb_h_offset:.3f}\"")
2022
+ # Continue in loop to show menu again
2023
+ continue
1894
2024
  except ValueError:
1895
2025
  print("Invalid input. Enter a number.")
1896
2026
  except Exception as e:
@@ -1900,7 +2030,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1900
2030
  print("EC panel not available.")
1901
2031
  continue
1902
2032
  try:
1903
- new_offset = input(f"Enter EC panel horizontal offset in inches (current: {ec_h_offset:.3f}\"): ").strip()
2033
+ new_offset = _safe_input(f"Enter EC panel horizontal offset in inches (current: {ec_h_offset:.3f}\"): ").strip()
1904
2034
  if new_offset:
1905
2035
  ec_h_offset = float(new_offset)
1906
2036
  setattr(ec_ax, '_ec_h_offset_in', ec_h_offset)
@@ -1908,6 +2038,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1908
2038
  _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)
1909
2039
  fig.canvas.draw_idle()
1910
2040
  print(f"EC panel horizontal offset set to {ec_h_offset:.3f}\"")
2041
+ # Continue in loop to show menu again
2042
+ continue
1911
2043
  except ValueError:
1912
2044
  print("Invalid input. Enter a number.")
1913
2045
  except Exception as e:
@@ -1920,7 +2052,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1920
2052
  # Operando-only mode: toggle colorbar or change label mode
1921
2053
  cb_h_offset = getattr(cbar.ax, '_cb_h_offset_in', 0.0)
1922
2054
  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")
1923
- choice = input("v> ").strip().lower()
2055
+ choice = _safe_input("v> ").strip().lower()
1924
2056
  if choice == '1':
1925
2057
  cb_vis = cbar.ax.get_visible()
1926
2058
  cbar.ax.set_visible(not cb_vis)
@@ -1940,7 +2072,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1940
2072
  # Change colorbar label text
1941
2073
  current_label = getattr(cbar.ax, '_colorbar_label', 'Intensity')
1942
2074
  print(f"Current colorbar label: {current_label}")
1943
- new_label = input("New colorbar label (blank to keep): ").strip()
2075
+ new_label = _safe_input("New colorbar label (blank to keep): ").strip()
1944
2076
  if new_label:
1945
2077
  cbar.ax._colorbar_label = new_label
1946
2078
  try:
@@ -1958,12 +2090,12 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1958
2090
  print(f"\nHorizontal position (relative to canvas center):")
1959
2091
  print(f" Colorbar offset: {cb_h_offset:.3f}\" (positive=right, negative=left)")
1960
2092
  print("Commands: c=colorbar, q=back")
1961
- sub = input("m> ").strip().lower()
2093
+ sub = _safe_input("m> ").strip().lower()
1962
2094
  if not sub or sub == 'q':
1963
2095
  break
1964
2096
  if sub == 'c':
1965
2097
  try:
1966
- new_offset = input(f"Enter colorbar horizontal offset in inches (current: {cb_h_offset:.3f}\"): ").strip()
2098
+ new_offset = _safe_input(f"Enter colorbar horizontal offset in inches (current: {cb_h_offset:.3f}\"): ").strip()
1967
2099
  if new_offset:
1968
2100
  cb_h_offset = float(new_offset)
1969
2101
  setattr(cbar.ax, '_cb_h_offset_in', cb_h_offset)
@@ -1971,6 +2103,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1971
2103
  _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)
1972
2104
  fig.canvas.draw_idle()
1973
2105
  print(f"Colorbar horizontal offset set to {cb_h_offset:.3f}\"")
2106
+ # Continue in loop to show menu again
2107
+ continue
1974
2108
  except ValueError:
1975
2109
  print("Invalid input. Enter a number.")
1976
2110
  except Exception as e:
@@ -2013,7 +2147,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2013
2147
  prompt = "Enter new filename (no ext needed), number to overwrite, or o to overwrite last (q=cancel): "
2014
2148
  else:
2015
2149
  prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
2016
- choice = input(prompt).strip()
2150
+ choice = _safe_input(prompt).strip()
2017
2151
  if not choice or choice.lower() == 'q':
2018
2152
  print_menu(); continue
2019
2153
  if choice.lower() == 'o':
@@ -2024,7 +2158,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2024
2158
  if not os.path.exists(last_session_path):
2025
2159
  print(f"Previous save file not found: {last_session_path}")
2026
2160
  print_menu(); continue
2027
- yn = input(f"Overwrite '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
2161
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
2028
2162
  if yn != 'y':
2029
2163
  print_menu(); continue
2030
2164
  dump_operando_session(last_session_path, fig=fig, ax=ax, im=im, cbar=cbar, ec_ax=ec_ax, skip_confirm=True)
@@ -2034,7 +2168,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2034
2168
  idx = int(choice)
2035
2169
  if 1 <= idx <= len(files):
2036
2170
  name = files[idx-1]
2037
- yn = input(f"Overwrite '{name}'? (y/n): ").strip().lower()
2171
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
2038
2172
  if yn != 'y':
2039
2173
  print_menu(); continue
2040
2174
  target = os.path.join(folder, name)
@@ -2051,7 +2185,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2051
2185
  name = name + '.pkl'
2052
2186
  target = name if os.path.isabs(name) else os.path.join(folder, name)
2053
2187
  if os.path.exists(target):
2054
- yn = input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
2188
+ yn = _safe_input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
2055
2189
  if yn != 'y':
2056
2190
  print_menu(); continue
2057
2191
  dump_operando_session(target, fig=fig, ax=ax, im=im, cbar=cbar, ec_ax=ec_ax, skip_confirm=True)
@@ -2076,7 +2210,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2076
2210
  # Always read fresh value from attribute to avoid stale cached value
2077
2211
  ax_h_in = getattr(ax, '_fixed_ax_h_in', ax_h_in)
2078
2212
  print(f"Current height: {ax_h_in:.2f} in")
2079
- val = input("New height (inches): ").strip()
2213
+ val = _safe_input("New height (inches): ").strip()
2080
2214
  if val:
2081
2215
  _snapshot("height")
2082
2216
  try:
@@ -2116,7 +2250,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2116
2250
  print(f"\nFont submenu (current: family='{cur_family}', size={cur_size})")
2117
2251
  print(" f: change family | s: change size | q: back")
2118
2252
  while True:
2119
- sub = input("Font> ").strip().lower()
2253
+ sub = _safe_input("Font> ").strip().lower()
2120
2254
  if not sub:
2121
2255
  continue
2122
2256
  if sub == 'q':
@@ -2129,7 +2263,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2129
2263
  for i, font in enumerate(fonts, 1):
2130
2264
  print(f" {i}: {font}")
2131
2265
  print("Or enter custom font name directly.")
2132
- choice = input("Font family (number or name): ").strip()
2266
+ choice = _safe_input("Font family (number or name): ").strip()
2133
2267
  if not choice:
2134
2268
  continue
2135
2269
  _snapshot("font-family")
@@ -2149,7 +2283,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2149
2283
  elif sub == 's':
2150
2284
  # Show current size and accept direct input
2151
2285
  cur_size = plt.rcParams.get('font.size', None)
2152
- choice = input(f"Font size (current: {cur_size}): ").strip()
2286
+ choice = _safe_input(f"Font size (current: {cur_size}): ").strip()
2153
2287
  if not choice:
2154
2288
  continue
2155
2289
  _snapshot("font-size")
@@ -2172,7 +2306,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2172
2306
  print(_colorize_inline_commands(" 1.5 2.5 - set frame=1.5, ticks=2.5"))
2173
2307
  print(_colorize_inline_commands(" q - cancel"))
2174
2308
 
2175
- inp = input("Line widths> ").strip().lower()
2309
+ inp = _safe_input("Line widths> ").strip().lower()
2176
2310
  if not inp or inp == 'q':
2177
2311
  print_menu()
2178
2312
  continue
@@ -2235,6 +2369,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2235
2369
  # Unified WASD ticks/labels/spines submenu for either pane
2236
2370
  # Import here to avoid scoping issues with nested functions
2237
2371
  from matplotlib.ticker import AutoMinorLocator, NullFormatter, MaxNLocator, NullLocator
2372
+ # Import UI positioning functions locally to ensure they're accessible in nested functions
2373
+ 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
2238
2374
 
2239
2375
  def _get_tick_state(a):
2240
2376
  # Unified keys with fallbacks for legacy combined flags
@@ -2320,7 +2456,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2320
2456
  print(_colorize_inline_commands("Choose pane: o=operando, e=ec, q=back"))
2321
2457
  else:
2322
2458
  print(_colorize_inline_commands("Choose pane: o=operando, q=back"))
2323
- pane = input("ot> ").strip().lower()
2459
+ pane = _safe_input("ot> ").strip().lower()
2324
2460
  if not pane:
2325
2461
  continue
2326
2462
  if pane == 'q':
@@ -2539,7 +2675,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2539
2675
  print(_colorize_inline_commands("Type 'i' to invert tick direction, 'l' to change tick length, 'list' for state, 'q' to return."))
2540
2676
  print(_colorize_inline_commands(" p = adjust title offsets (w=top, s=bottom, a=left, d=right)"))
2541
2677
  while True:
2542
- cmd2 = input(_colorize_prompt("Toggle> ")).strip().lower()
2678
+ cmd2 = _safe_input(_colorize_prompt("Toggle> ")).strip().lower()
2543
2679
  if not cmd2:
2544
2680
  continue
2545
2681
  if cmd2 == 'q':
@@ -2564,7 +2700,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2564
2700
  # Get current major tick length from axes
2565
2701
  current_major = ax.xaxis.get_major_ticks()[0].tick1line.get_markersize() if ax.xaxis.get_major_ticks() else 4.0
2566
2702
  print(f"Current major tick length: {current_major}")
2567
- new_length_str = input("Enter new major tick length (e.g., 6.0): ").strip()
2703
+ new_length_str = _safe_input("Enter new major tick length (e.g., 6.0): ").strip()
2568
2704
  if not new_length_str:
2569
2705
  continue
2570
2706
  new_major = float(new_length_str)
@@ -2657,7 +2793,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2657
2793
  current_y_px = _px_value('_top_xlabel_manual_offset_y_pts', target)
2658
2794
  current_x_px = _px_value('_top_xlabel_manual_offset_x_pts', target)
2659
2795
  print(f"Top title offset: Y={current_y_px:+.2f} px (positive=up), X={current_x_px:+.2f} px (positive=right)")
2660
- sub = input(_colorize_prompt("top (w=up, s=down, a=left, d=right, 0=reset, q=back): ")).strip().lower()
2796
+ sub = _safe_input(_colorize_prompt("top (w=up, s=down, a=left, d=right, 0=reset, q=back): ")).strip().lower()
2661
2797
  if not sub:
2662
2798
  continue
2663
2799
  if sub == 'q':
@@ -2694,7 +2830,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2694
2830
  while True:
2695
2831
  current_y_px = _px_value('_bottom_xlabel_manual_offset_y_pts', target)
2696
2832
  print(f"Bottom title offset: Y={current_y_px:+.2f} px (positive=down)")
2697
- sub = input(_colorize_prompt("bottom (s=down, w=up, 0=reset, q=back): ")).strip().lower()
2833
+ sub = _safe_input(_colorize_prompt("bottom (s=down, w=up, 0=reset, q=back): ")).strip().lower()
2698
2834
  if not sub:
2699
2835
  continue
2700
2836
  if sub == 'q':
@@ -2724,7 +2860,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2724
2860
  while True:
2725
2861
  current_x_px = _px_value('_left_ylabel_manual_offset_x_pts', target)
2726
2862
  print(f"Left title offset: X={current_x_px:+.2f} px (positive=left)")
2727
- sub = input(_colorize_prompt("left (a=left, d=right, 0=reset, q=back): ")).strip().lower()
2863
+ sub = _safe_input(_colorize_prompt("left (a=left, d=right, 0=reset, q=back): ")).strip().lower()
2728
2864
  if not sub:
2729
2865
  continue
2730
2866
  if sub == 'q':
@@ -2761,7 +2897,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2761
2897
  current_x_px = _px_value('_right_ylabel_manual_offset_x_pts', target)
2762
2898
  current_y_px = _px_value('_right_ylabel_manual_offset_y_pts', target)
2763
2899
  print(f"Right title offset: X={current_x_px:+.2f} px (positive=right), Y={current_y_px:+.2f} px (positive=up)")
2764
- sub = input(_colorize_prompt("right (d=right, a=left, w=up, s=down, 0=reset, q=back): ")).strip().lower()
2900
+ sub = _safe_input(_colorize_prompt("right (d=right, a=left, w=up, s=down, 0=reset, q=back): ")).strip().lower()
2765
2901
  if not sub:
2766
2902
  continue
2767
2903
  if sub == 'q':
@@ -2800,7 +2936,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2800
2936
  print(" " + _colorize_menu('d : adjust right title (d=right, a=left, w=up, s=down)'))
2801
2937
  print(" " + _colorize_menu('r : reset all offsets'))
2802
2938
  print(" " + _colorize_menu('q : return'))
2803
- choice = input(_colorize_prompt("p> ")).strip().lower()
2939
+ choice = _safe_input(_colorize_prompt("p> ")).strip().lower()
2804
2940
  if not choice:
2805
2941
  continue
2806
2942
  if choice == 'q':
@@ -2916,7 +3052,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2916
3052
  elif cmd == 'ox':
2917
3053
  while True:
2918
3054
  cur = ax.get_xlim(); print(f"Current operando X: {cur[0]:.4g} {cur[1]:.4g}")
2919
- line = input("New X range (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
3055
+ line = _safe_input("New X range (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
2920
3056
  if not line or line.lower() == 'q':
2921
3057
  break
2922
3058
  if line.lower() == 'w':
@@ -2924,7 +3060,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2924
3060
  while True:
2925
3061
  cur = ax.get_xlim()
2926
3062
  print(f"Current operando X: {cur[0]:.4g} {cur[1]:.4g}")
2927
- val = input(f"Enter new upper X limit (current lower: {cur[0]:.4g}, q=back): ").strip()
3063
+ val = _safe_input(f"Enter new upper X limit (current lower: {cur[0]:.4g}, q=back): ").strip()
2928
3064
  if not val or val.lower() == 'q':
2929
3065
  break
2930
3066
  try:
@@ -2943,7 +3079,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2943
3079
  while True:
2944
3080
  cur = ax.get_xlim()
2945
3081
  print(f"Current operando X: {cur[0]:.4g} {cur[1]:.4g}")
2946
- val = input(f"Enter new lower X limit (current upper: {cur[1]:.4g}, q=back): ").strip()
3082
+ val = _safe_input(f"Enter new lower X limit (current upper: {cur[1]:.4g}, q=back): ").strip()
2947
3083
  if not val or val.lower() == 'q':
2948
3084
  break
2949
3085
  try:
@@ -2989,7 +3125,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2989
3125
  elif cmd == 'oy':
2990
3126
  while True:
2991
3127
  cur = ax.get_ylim(); print(f"Current operando Y: {cur[0]:.4g} {cur[1]:.4g}")
2992
- line = input("New Y range (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
3128
+ line = _safe_input("New Y range (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
2993
3129
  if not line or line.lower() == 'q':
2994
3130
  break
2995
3131
  if line.lower() == 'w':
@@ -2997,7 +3133,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2997
3133
  while True:
2998
3134
  cur = ax.get_ylim()
2999
3135
  print(f"Current operando Y: {cur[0]:.4g} {cur[1]:.4g}")
3000
- val = input(f"Enter new upper Y limit (current lower: {cur[0]:.4g}, q=back): ").strip()
3136
+ val = _safe_input(f"Enter new upper Y limit (current lower: {cur[0]:.4g}, q=back): ").strip()
3001
3137
  if not val or val.lower() == 'q':
3002
3138
  break
3003
3139
  try:
@@ -3016,7 +3152,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3016
3152
  while True:
3017
3153
  cur = ax.get_ylim()
3018
3154
  print(f"Current operando Y: {cur[0]:.4g} {cur[1]:.4g}")
3019
- val = input(f"Enter new lower Y limit (current upper: {cur[1]:.4g}, q=back): ").strip()
3155
+ val = _safe_input(f"Enter new lower Y limit (current upper: {cur[1]:.4g}, q=back): ").strip()
3020
3156
  if not val or val.lower() == 'q':
3021
3157
  break
3022
3158
  try:
@@ -3119,9 +3255,9 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3119
3255
  auto_available = False
3120
3256
 
3121
3257
  if auto_available:
3122
- line = input("New intensity range (min max, w=upper only, s=lower only, a=auto-fit to visible, q=back): ").strip()
3258
+ line = _safe_input("New intensity range (min max, w=upper only, s=lower only, a=auto-fit to visible, q=back): ").strip()
3123
3259
  else:
3124
- line = input("New intensity range (min max, w=upper only, s=lower only, q=back): ").strip()
3260
+ line = _safe_input("New intensity range (min max, w=upper only, s=lower only, q=back): ").strip()
3125
3261
 
3126
3262
  if not line or line.lower() == 'q':
3127
3263
  break
@@ -3135,7 +3271,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3135
3271
  except Exception:
3136
3272
  print("Could not retrieve current color scale range")
3137
3273
  break
3138
- val = input(f"Enter new upper intensity limit (current lower: {cur[0]:.4g}, q=back): ").strip()
3274
+ val = _safe_input(f"Enter new upper intensity limit (current lower: {cur[0]:.4g}, q=back): ").strip()
3139
3275
  if not val or val.lower() == 'q':
3140
3276
  break
3141
3277
  try:
@@ -3161,7 +3297,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3161
3297
  except Exception:
3162
3298
  print("Could not retrieve current color scale range")
3163
3299
  break
3164
- val = input(f"Enter new lower intensity limit (current upper: {cur[1]:.4g}, q=back): ").strip()
3300
+ val = _safe_input(f"Enter new lower intensity limit (current upper: {cur[1]:.4g}, q=back): ").strip()
3165
3301
  if not val or val.lower() == 'q':
3166
3302
  break
3167
3303
  try:
@@ -3212,7 +3348,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3212
3348
  while True:
3213
3349
  ax_w_in = getattr(ax, '_fixed_ax_w_in', ax_w_in)
3214
3350
  print(f"Current operando width: {ax_w_in:.2f} in")
3215
- val = input("New width (inches, q=back): ").strip()
3351
+ val = _safe_input("New width (inches, q=back): ").strip()
3216
3352
  if not val or val.lower() == 'q':
3217
3353
  break
3218
3354
  _snapshot("operando-width")
@@ -3232,7 +3368,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3232
3368
  while True:
3233
3369
  ec_w_in = getattr(ec_ax, '_fixed_ec_w_in', ec_w_in)
3234
3370
  print(f"Current EC width: {ec_w_in:.2f} in")
3235
- val = input("New EC width (inches, q=back): ").strip()
3371
+ val = _safe_input("New EC width (inches, q=back): ").strip()
3236
3372
  if not val or val.lower() == 'q':
3237
3373
  break
3238
3374
  _snapshot("ec-width")
@@ -3275,7 +3411,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3275
3411
  if optional:
3276
3412
  print("\nOther available: " + ", ".join(optional))
3277
3413
  print(_colorize_inline_commands("Append _r to reverse (e.g., viridis_r or 1_r). Blank to cancel."))
3278
- choice = input(f"Palette name or number (1-{len(rec_palettes)}): ").strip()
3414
+ choice = _safe_input(f"Palette name or number (1-{len(rec_palettes)}): ").strip()
3279
3415
  if not choice:
3280
3416
  print_menu(); continue
3281
3417
  try:
@@ -3524,7 +3660,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3524
3660
  last_style_path = getattr(fig, '_last_style_export_path', None)
3525
3661
  if ec_ax is None:
3526
3662
  print("\nNote: Style export (.bps/.bpsg) is only available in dual-pane mode (with EC file).")
3527
- sub = input("Style submenu: (q=return, r=refresh): ").strip().lower()
3663
+ sub = _safe_input("Style submenu: (q=return, r=refresh): ").strip().lower()
3528
3664
  if sub == 'q':
3529
3665
  break
3530
3666
  if sub == 'r' or sub == '':
@@ -3534,9 +3670,9 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3534
3670
  continue
3535
3671
  else:
3536
3672
  if last_style_path:
3537
- sub = input("Style submenu: (e=export, o=overwrite last, q=return, r=refresh): ").strip().lower()
3673
+ sub = _safe_input("Style submenu: (e=export, o=overwrite last, q=return, r=refresh): ").strip().lower()
3538
3674
  else:
3539
- sub = input("Style submenu: (e=export, q=return, r=refresh): ").strip().lower()
3675
+ sub = _safe_input("Style submenu: (e=export, q=return, r=refresh): ").strip().lower()
3540
3676
  if sub == 'q':
3541
3677
  break
3542
3678
  if sub == 'r' or sub == '':
@@ -3549,7 +3685,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3549
3685
  if not os.path.exists(last_style_path):
3550
3686
  print(f"Previous export file not found: {last_style_path}")
3551
3687
  continue
3552
- yn = input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
3688
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
3553
3689
  if yn != 'y':
3554
3690
  continue
3555
3691
  # Determine export type from existing file and rebuild config
@@ -3570,7 +3706,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3570
3706
  print("Export options:")
3571
3707
  print(" ps = style only (.bps)")
3572
3708
  print(" psg = style + geometry (.bpsg)")
3573
- exp_choice = input("Export choice (ps/psg, q=cancel): ").strip().lower()
3709
+ exp_choice = _safe_input("Export choice (ps/psg, q=cancel): ").strip().lower()
3574
3710
  if not exp_choice or exp_choice == 'q':
3575
3711
  print("Style export canceled.")
3576
3712
  continue
@@ -3764,7 +3900,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3764
3900
  else:
3765
3901
  print(f" {_i}: {fname}")
3766
3902
 
3767
- choice_name = input("Enter new filename or number to overwrite (q=cancel): ").strip()
3903
+ choice_name = _safe_input("Enter new filename or number to overwrite (q=cancel): ").strip()
3768
3904
  if not choice_name or choice_name.lower() == 'q':
3769
3905
  print("Style export canceled.")
3770
3906
  continue
@@ -3773,7 +3909,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3773
3909
  _idx = int(choice_name)
3774
3910
  if 1 <= _idx <= len(_style_files):
3775
3911
  name = _style_files[_idx-1]
3776
- yn = input(f"Overwrite '{name}'? (y/n): ").strip().lower()
3912
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
3777
3913
  if yn == 'y':
3778
3914
  target = file_list[_idx-1][1] # Full path from list
3779
3915
  else:
@@ -3790,7 +3926,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3790
3926
  else:
3791
3927
  target = get_organized_path(name, 'style', base_path=save_base)
3792
3928
  if os.path.exists(target):
3793
- yn = input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
3929
+ yn = _safe_input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
3794
3930
  if yn != 'y':
3795
3931
  target = None
3796
3932
  if target:
@@ -3921,7 +4057,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3921
4057
  try:
3922
4058
  im.set_cmap(cmap)
3923
4059
  if cbar is not None:
3924
- cbar.update_normal(im)
4060
+ _update_custom_colorbar(cbar.ax, im)
3925
4061
  except Exception:
3926
4062
  pass
3927
4063
 
@@ -4488,14 +4624,14 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4488
4624
  print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
4489
4625
  print(" Bullet: $\\bullet$ → • | Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
4490
4626
  while True:
4491
- sub = input("or> ").strip().lower()
4627
+ sub = _safe_input("or> ").strip().lower()
4492
4628
  if not sub:
4493
4629
  continue
4494
4630
  if sub == 'q':
4495
4631
  break
4496
4632
  if sub == 'x':
4497
4633
  cur = ax.get_xlabel() or ''
4498
- lab = input(f"New operando X label (blank=cancel, current='{cur}'): ")
4634
+ lab = _safe_input(f"New operando X label (blank=cancel, current='{cur}'): ")
4499
4635
  if lab:
4500
4636
  _snapshot("rename-op-x")
4501
4637
  try:
@@ -4508,7 +4644,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4508
4644
  pass
4509
4645
  elif sub == 'y':
4510
4646
  cur = ax.get_ylabel() or ''
4511
- lab = input(f"New operando Y label (blank=cancel, current='{cur}'): ")
4647
+ lab = _safe_input(f"New operando Y label (blank=cancel, current='{cur}'): ")
4512
4648
  if lab:
4513
4649
  _snapshot("rename-op-y")
4514
4650
  try:
@@ -4540,14 +4676,14 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4540
4676
  print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
4541
4677
  print(" Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
4542
4678
  while True:
4543
- sub = input("er> ").strip().lower()
4679
+ sub = _safe_input("er> ").strip().lower()
4544
4680
  if not sub:
4545
4681
  continue
4546
4682
  if sub == 'q':
4547
4683
  break
4548
4684
  if sub == 'x':
4549
4685
  cur = ec_ax.get_xlabel() or ''
4550
- lab = input(f"New EC X label (blank=cancel, current='{cur}'): ")
4686
+ lab = _safe_input(f"New EC X label (blank=cancel, current='{cur}'): ")
4551
4687
  if lab:
4552
4688
  _snapshot("rename-ec-x")
4553
4689
  try:
@@ -4559,7 +4695,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4559
4695
  pass
4560
4696
  elif sub == 'y':
4561
4697
  cur = ec_ax.get_ylabel() or ''
4562
- lab = input(f"New EC Y label (blank=cancel, current='{cur}'): ")
4698
+ lab = _safe_input(f"New EC Y label (blank=cancel, current='{cur}'): ")
4563
4699
  if lab:
4564
4700
  _snapshot("rename-ec-y")
4565
4701
  try:
@@ -4597,7 +4733,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4597
4733
  print_menu(); continue
4598
4734
  print("EC line submenu: c=color, l=linewidth, q=back")
4599
4735
  while True:
4600
- sub = input("el> ").strip().lower()
4736
+ sub = _safe_input("el> ").strip().lower()
4601
4737
  if not sub:
4602
4738
  continue
4603
4739
  if sub == 'q':
@@ -4613,7 +4749,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4613
4749
  else:
4614
4750
  print("\nNo saved colors. Type 'u' to manage saved colors.")
4615
4751
  print(" (Enter color name/hex, saved color number, or 'u' to manage)")
4616
- val = input(f"Color (current={cur}, blank=cancel): ").strip()
4752
+ val = _safe_input(f"Color (current={cur}, blank=cancel): ").strip()
4617
4753
  if not val:
4618
4754
  continue
4619
4755
  if val.lower() == 'u':
@@ -4630,7 +4766,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4630
4766
  print(f"Invalid color: {e}")
4631
4767
  elif sub == 'l':
4632
4768
  cur = ln.get_linewidth()
4633
- val = input(f"Line width (current={cur}, blank=cancel): ").strip()
4769
+ val = _safe_input(f"Line width (current={cur}, blank=cancel): ").strip()
4634
4770
  if not val:
4635
4771
  continue
4636
4772
  _snapshot("ec-line-width")
@@ -4655,7 +4791,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4655
4791
  continue
4656
4792
  while True:
4657
4793
  cur = ec_ax.get_ylim(); print(f"Current EC time range (Y): {cur[0]:.4g} {cur[1]:.4g}")
4658
- line = input("New time range (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
4794
+ line = _safe_input("New time range (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
4659
4795
  if not line or line.lower() == 'q':
4660
4796
  break
4661
4797
  if line.lower() == 'w':
@@ -4663,7 +4799,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4663
4799
  while True:
4664
4800
  cur = ec_ax.get_ylim()
4665
4801
  print(f"Current EC time range (Y): {cur[0]:.4g} {cur[1]:.4g}")
4666
- val = input(f"Enter new upper time limit (current lower: {cur[0]:.4g}, q=back): ").strip()
4802
+ val = _safe_input(f"Enter new upper time limit (current lower: {cur[0]:.4g}, q=back): ").strip()
4667
4803
  if not val or val.lower() == 'q':
4668
4804
  break
4669
4805
  try:
@@ -4681,7 +4817,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4681
4817
  while True:
4682
4818
  cur = ec_ax.get_ylim()
4683
4819
  print(f"Current EC time range (Y): {cur[0]:.4g} {cur[1]:.4g}")
4684
- val = input(f"Enter new lower time limit (current upper: {cur[1]:.4g}, q=back): ").strip()
4820
+ val = _safe_input(f"Enter new lower time limit (current upper: {cur[1]:.4g}, q=back): ").strip()
4685
4821
  if not val or val.lower() == 'q':
4686
4822
  break
4687
4823
  try:
@@ -4795,7 +4931,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4795
4931
  print("The .mpt file must contain the '<I>/mA' column to use this feature.")
4796
4932
  print_menu(); continue
4797
4933
  while True:
4798
- sub = input("ey submenu: n=ions, t=time, q=back: ").strip().lower()
4934
+ sub = _safe_input("ey submenu: n=ions, t=time, q=back: ").strip().lower()
4799
4935
  if not sub:
4800
4936
  continue
4801
4937
  if sub == 'q':
@@ -4812,7 +4948,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4812
4948
  prompt = "Enter mass(mg), capacity-per-ion(mAh g^-1), start-ions (e.g. 4.5 26.8 0), q=cancel: "
4813
4949
  else:
4814
4950
  prompt = f"Enter mass,cap-per-ion,start-ions (blank=reuse {mass_mg} {cap_per_ion} {start_ions}; q=cancel): "
4815
- s = input(prompt).strip()
4951
+ s = _safe_input(prompt).strip()
4816
4952
  if not s:
4817
4953
  if need_input:
4818
4954
  continue
@@ -5098,7 +5234,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
5098
5234
  while True:
5099
5235
  cur = ec_ax.get_xlim()
5100
5236
  print(f"Current EC X range: {cur[0]:.4g} {cur[1]:.4g}")
5101
- line = input("New EC X range (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
5237
+ line = _safe_input("New EC X range (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
5102
5238
  if not line or line.lower() == 'q':
5103
5239
  break
5104
5240
  if line.lower() == 'w':
@@ -5106,7 +5242,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
5106
5242
  while True:
5107
5243
  cur = ec_ax.get_xlim()
5108
5244
  print(f"Current EC X range: {cur[0]:.4g} {cur[1]:.4g}")
5109
- val = input(f"Enter new upper EC X limit (current lower: {cur[0]:.4g}, q=back): ").strip()
5245
+ val = _safe_input(f"Enter new upper EC X limit (current lower: {cur[0]:.4g}, q=back): ").strip()
5110
5246
  if not val or val.lower() == 'q':
5111
5247
  break
5112
5248
  try:
@@ -5125,7 +5261,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
5125
5261
  while True:
5126
5262
  cur = ec_ax.get_xlim()
5127
5263
  print(f"Current EC X range: {cur[0]:.4g} {cur[1]:.4g}")
5128
- val = input(f"Enter new lower EC X limit (current upper: {cur[1]:.4g}, q=back): ").strip()
5264
+ val = _safe_input(f"Enter new lower EC X limit (current upper: {cur[1]:.4g}, q=back): ").strip()
5129
5265
  if not val or val.lower() == 'q':
5130
5266
  break
5131
5267
  try:
@@ -5187,7 +5323,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
5187
5323
  cur_w, cur_h = _get_fig_size(fig)
5188
5324
  print(f"Current canvas size: {cur_w:.2f} x {cur_h:.2f} in (W x H)")
5189
5325
  print("Canvas: only figure size will change; panel widths/gaps are not altered.")
5190
- line = input("New canvas size 'W H' (blank=cancel): ").strip()
5326
+ line = _safe_input("New canvas size 'W H' (blank=cancel): ").strip()
5191
5327
  if line:
5192
5328
  _snapshot("canvas-size")
5193
5329
  try: