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.

@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  from typing import Dict, Iterable, List, Optional, Tuple
10
10
  import json
11
11
  import os
12
+ import sys
12
13
 
13
14
  import matplotlib.pyplot as plt
14
15
  import matplotlib.cm as cm
@@ -30,6 +31,7 @@ from .utils import (
30
31
  choose_style_file,
31
32
  list_files_in_subdirectory,
32
33
  get_organized_path,
34
+ convert_label_shortcuts,
33
35
  )
34
36
  import time
35
37
  from .color_utils import (
@@ -42,6 +44,37 @@ from .color_utils import (
42
44
  )
43
45
 
44
46
 
47
+ class _FilterIMKWarning:
48
+ """Filter that suppresses macOS IMKCFRunLoopWakeUpReliable warnings while preserving other errors."""
49
+ def __init__(self, original_stderr):
50
+ self.original_stderr = original_stderr
51
+
52
+ def write(self, message):
53
+ # Filter out the harmless macOS IMK warning
54
+ if 'IMKCFRunLoopWakeUpReliable' not in message:
55
+ self.original_stderr.write(message)
56
+
57
+ def flush(self):
58
+ self.original_stderr.flush()
59
+
60
+
61
+ def _safe_input(prompt: str = "") -> str:
62
+ """Wrapper around input() that suppresses macOS IMKCFRunLoopWakeUpReliable warnings.
63
+
64
+ This is a harmless macOS system message that appears when using input() in terminals.
65
+ """
66
+ # Filter stderr to hide macOS IMK warnings while preserving other errors
67
+ original_stderr = sys.stderr
68
+ sys.stderr = _FilterIMKWarning(original_stderr)
69
+ try:
70
+ result = input(prompt)
71
+ return result
72
+ except (KeyboardInterrupt, EOFError):
73
+ raise
74
+ finally:
75
+ sys.stderr = original_stderr
76
+
77
+
45
78
  def _colorize_menu(text):
46
79
  """Colorize menu items: command in cyan, colon in white, description in default."""
47
80
  if ':' not in text:
@@ -470,6 +503,28 @@ def _rebuild_legend(ax):
470
503
  fig = ax.figure
471
504
  # Capture existing title before any rebuild so it isn't lost
472
505
  _store_legend_title(fig, ax)
506
+ # If no stored position yet, try to capture the current legend location once
507
+ # so rebuilds (e.g., after renaming) don't jump to a new "best" spot.
508
+ try:
509
+ if getattr(fig, '_ec_legend_xy_in', None) is None:
510
+ leg0 = ax.get_legend()
511
+ if leg0 is not None and leg0.get_visible():
512
+ try:
513
+ renderer = fig.canvas.get_renderer()
514
+ except Exception:
515
+ fig.canvas.draw()
516
+ renderer = fig.canvas.get_renderer()
517
+ bb = leg0.get_window_extent(renderer=renderer)
518
+ cx = 0.5 * (bb.x0 + bb.x1)
519
+ cy = 0.5 * (bb.y0 + bb.y1)
520
+ fx, fy = fig.transFigure.inverted().transform((cx, cy))
521
+ fw, fh = fig.get_size_inches()
522
+ offset = ((fx - 0.5) * fw, (fy - 0.5) * fh)
523
+ offset = _sanitize_legend_offset(fig, offset)
524
+ if offset is not None:
525
+ fig._ec_legend_xy_in = offset
526
+ except Exception:
527
+ pass
473
528
  if not _get_legend_user_pref(fig):
474
529
  leg = ax.get_legend()
475
530
  if leg is not None:
@@ -937,6 +992,9 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
937
992
 
938
993
  def _title_offset_menu():
939
994
  """Allow nudging duplicate top/right titles by single-pixel increments."""
995
+ # Import UI positioning functions locally to ensure they're accessible in nested functions
996
+ 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
997
+
940
998
  def _dpi():
941
999
  try:
942
1000
  return float(fig.dpi)
@@ -980,7 +1038,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
980
1038
  current_y_px = _px_value('_top_xlabel_manual_offset_y_pts')
981
1039
  current_x_px = _px_value('_top_xlabel_manual_offset_x_pts')
982
1040
  print(f"Top title offset: Y={current_y_px:+.2f} px (positive=up), X={current_x_px:+.2f} px (positive=right)")
983
- sub = input(_colorize_prompt("top (w=up, s=down, a=left, d=right, 0=reset, q=back): ")).strip().lower()
1041
+ sub = _safe_input(_colorize_prompt("top (w=up, s=down, a=left, d=right, 0=reset, q=back): ")).strip().lower()
984
1042
  if not sub:
985
1043
  continue
986
1044
  if sub == 'q':
@@ -1018,7 +1076,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1018
1076
  current_x_px = _px_value('_right_ylabel_manual_offset_x_pts')
1019
1077
  current_y_px = _px_value('_right_ylabel_manual_offset_y_pts')
1020
1078
  print(f"Right title offset: X={current_x_px:+.2f} px (positive=right), Y={current_y_px:+.2f} px (positive=up)")
1021
- sub = input(_colorize_prompt("right (d=right, a=left, w=up, s=down, 0=reset, q=back): ")).strip().lower()
1079
+ sub = _safe_input(_colorize_prompt("right (d=right, a=left, w=up, s=down, 0=reset, q=back): ")).strip().lower()
1022
1080
  if not sub:
1023
1081
  continue
1024
1082
  if sub == 'q':
@@ -1055,7 +1113,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1055
1113
  while True:
1056
1114
  current_y_px = _px_value('_bottom_xlabel_manual_offset_y_pts')
1057
1115
  print(f"Bottom title offset: Y={current_y_px:+.2f} px (positive=down)")
1058
- sub = input(_colorize_prompt("bottom (s=down, w=up, 0=reset, q=back): ")).strip().lower()
1116
+ sub = _safe_input(_colorize_prompt("bottom (s=down, w=up, 0=reset, q=back): ")).strip().lower()
1059
1117
  if not sub:
1060
1118
  continue
1061
1119
  if sub == 'q':
@@ -1085,7 +1143,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1085
1143
  while True:
1086
1144
  current_x_px = _px_value('_left_ylabel_manual_offset_x_pts')
1087
1145
  print(f"Left title offset: X={current_x_px:+.2f} px (positive=left)")
1088
- sub = input(_colorize_prompt("left (a=left, d=right, 0=reset, q=back): ")).strip().lower()
1146
+ sub = _safe_input(_colorize_prompt("left (a=left, d=right, 0=reset, q=back): ")).strip().lower()
1089
1147
  if not sub:
1090
1148
  continue
1091
1149
  if sub == 'q':
@@ -1116,7 +1174,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1116
1174
  print(" " + _colorize_menu('d : adjust right title (d=right, a=left, w=up, s=down)'))
1117
1175
  print(" " + _colorize_menu('r : reset all offsets'))
1118
1176
  print(" " + _colorize_menu('q : return'))
1119
- choice = input(_colorize_prompt("p> ")).strip().lower()
1177
+ choice = _safe_input(_colorize_prompt("p> ")).strip().lower()
1120
1178
  if not choice:
1121
1179
  continue
1122
1180
  if choice == 'q':
@@ -1587,7 +1645,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1587
1645
  _print_menu(len(all_cycles), is_dqdv)
1588
1646
  while True:
1589
1647
  try:
1590
- key = input("Press a key: ").strip().lower()
1648
+ key = _safe_input("Press a key: ").strip().lower()
1591
1649
  except (KeyboardInterrupt, EOFError):
1592
1650
  print("\n\nExiting interactive menu...")
1593
1651
  break
@@ -1595,7 +1653,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1595
1653
  continue
1596
1654
  if key == 'q':
1597
1655
  try:
1598
- confirm = input(_colorize_prompt("Quit EC interactive? Remember to save (e=export, s=save). Quit now? (y/n): ")).strip().lower()
1656
+ confirm = _safe_input(_colorize_prompt("Quit EC interactive? Remember to save (e=export, s=save). Quit now? (y/n): ")).strip().lower()
1599
1657
  except Exception:
1600
1658
  confirm = 'y'
1601
1659
  if confirm == 'y':
@@ -1630,9 +1688,9 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1630
1688
 
1631
1689
  last_figure_path = getattr(fig, '_last_figure_export_path', None)
1632
1690
  if last_figure_path:
1633
- fname = input("Export filename (default .svg if no extension), number to overwrite, or o to overwrite last (q=cancel): ").strip()
1691
+ fname = _safe_input("Export filename (default .svg if no extension), number to overwrite, or o to overwrite last (q=cancel): ").strip()
1634
1692
  else:
1635
- fname = input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
1693
+ fname = _safe_input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
1636
1694
  if not fname or fname.lower() == 'q':
1637
1695
  _print_menu(len(all_cycles), is_dqdv)
1638
1696
  continue
@@ -1648,7 +1706,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1648
1706
  print(f"Previous export file not found: {last_figure_path}")
1649
1707
  _print_menu(len(all_cycles), is_dqdv)
1650
1708
  continue
1651
- yn = input(f"Overwrite '{os.path.basename(last_figure_path)}'? (y/n): ").strip().lower()
1709
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_figure_path)}'? (y/n): ").strip().lower()
1652
1710
  if yn != 'y':
1653
1711
  _print_menu(len(all_cycles), is_dqdv)
1654
1712
  continue
@@ -1660,7 +1718,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1660
1718
  idx = int(fname)
1661
1719
  if 1 <= idx <= len(files):
1662
1720
  name = files[idx-1]
1663
- yn = input(f"Overwrite '{name}'? (y/n): ").strip().lower()
1721
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
1664
1722
  if yn != 'y':
1665
1723
  _print_menu(len(all_cycles), is_dqdv)
1666
1724
  continue
@@ -1798,7 +1856,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1798
1856
  xy_in = _sanitize_legend_offset(fig, getattr(fig, '_ec_legend_xy_in', (0.0, 0.0))) or (0.0, 0.0)
1799
1857
  print(f"Legend is {'ON' if vis else 'off'}; position (inches from center): x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
1800
1858
  while True:
1801
- sub = input(_colorize_prompt("Legend: (t=toggle, p=set position, q=back): ")).strip().lower()
1859
+ sub = _safe_input(_colorize_prompt("Legend: (t=toggle, p=set position, q=back): ")).strip().lower()
1802
1860
  if not sub:
1803
1861
  continue
1804
1862
  if sub == 'q':
@@ -1822,7 +1880,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1822
1880
  while True:
1823
1881
  xy_in = _sanitize_legend_offset(fig, getattr(fig, '_ec_legend_xy_in', (0.0, 0.0))) or (0.0, 0.0)
1824
1882
  print(f"Current position: x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
1825
- pos_cmd = input(_colorize_prompt("Position: (x y) or x=x only, y=y only, q=back: ")).strip().lower()
1883
+ pos_cmd = _safe_input(_colorize_prompt("Position: (x y) or x=x only, y=y only, q=back: ")).strip().lower()
1826
1884
  if not pos_cmd or pos_cmd == 'q':
1827
1885
  break
1828
1886
  if pos_cmd == 'x':
@@ -1830,7 +1888,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1830
1888
  while True:
1831
1889
  xy_in = _sanitize_legend_offset(fig, getattr(fig, '_ec_legend_xy_in', (0.0, 0.0))) or (0.0, 0.0)
1832
1890
  print(f"Current position: x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
1833
- val = input(f"Enter new x position (current y: {xy_in[1]:.2f}, q=back): ").strip()
1891
+ val = _safe_input(f"Enter new x position (current y: {xy_in[1]:.2f}, q=back): ").strip()
1834
1892
  if not val or val.lower() == 'q':
1835
1893
  break
1836
1894
  try:
@@ -1857,7 +1915,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1857
1915
  while True:
1858
1916
  xy_in = _sanitize_legend_offset(fig, getattr(fig, '_ec_legend_xy_in', (0.0, 0.0))) or (0.0, 0.0)
1859
1917
  print(f"Current position: x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
1860
- val = input(f"Enter new y position (current x: {xy_in[0]:.2f}, q=back): ").strip()
1918
+ val = _safe_input(f"Enter new y position (current x: {xy_in[0]:.2f}, q=back): ").strip()
1861
1919
  if not val or val.lower() == 'q':
1862
1920
  break
1863
1921
  try:
@@ -1932,9 +1990,9 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1932
1990
 
1933
1991
  last_style_path = getattr(fig, '_last_style_export_path', None)
1934
1992
  if last_style_path:
1935
- sub = input(_colorize_prompt("Style submenu: (e=export, o=overwrite last, q=return, r=refresh): ")).strip().lower()
1993
+ sub = _safe_input(_colorize_prompt("Style submenu: (e=export, o=overwrite last, q=return, r=refresh): ")).strip().lower()
1936
1994
  else:
1937
- sub = input(_colorize_prompt("Style submenu: (e=export, q=return, r=refresh): ")).strip().lower()
1995
+ sub = _safe_input(_colorize_prompt("Style submenu: (e=export, q=return, r=refresh): ")).strip().lower()
1938
1996
  if sub == 'q':
1939
1997
  break
1940
1998
  if sub == 'r' or sub == '':
@@ -1947,7 +2005,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1947
2005
  if not os.path.exists(last_style_path):
1948
2006
  print(f"Previous export file not found: {last_style_path}")
1949
2007
  continue
1950
- yn = input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
2008
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
1951
2009
  if yn != 'y':
1952
2010
  continue
1953
2011
  # Rebuild config based on current state
@@ -1974,7 +2032,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1974
2032
  print("Export options:")
1975
2033
  print(" ps = style only (.bps)")
1976
2034
  print(" psg = style + geometry (.bpsg)")
1977
- exp_choice = input(_colorize_prompt("Export choice (ps/psg, q=cancel): ")).strip().lower()
2035
+ exp_choice = _safe_input(_colorize_prompt("Export choice (ps/psg, q=cancel): ")).strip().lower()
1978
2036
  if not exp_choice or exp_choice == 'q':
1979
2037
  print("Style export canceled.")
1980
2038
  continue
@@ -2426,13 +2484,13 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2426
2484
  print(f" {_colorize_menu('ld : show line and dots (markers) for all curves')}")
2427
2485
  print(f" {_colorize_menu('d : show only dots (no connecting line) for all curves')}")
2428
2486
  print(f" {_colorize_menu('q : return')}")
2429
- sub = input(_colorize_prompt("Choose (c/f/g/l/ld/d/q): ")).strip().lower()
2487
+ sub = _safe_input(_colorize_prompt("Choose (c/f/g/l/ld/d/q): ")).strip().lower()
2430
2488
  if not sub:
2431
2489
  continue
2432
2490
  if sub == 'q':
2433
2491
  break
2434
2492
  if sub == 'c':
2435
- spec = input("Curve linewidth (single value for all curves, q=cancel): ").strip()
2493
+ spec = _safe_input("Curve linewidth (single value for all curves, q=cancel): ").strip()
2436
2494
  if not spec or spec.lower() == 'q':
2437
2495
  continue
2438
2496
  # Apply single width to all curves
@@ -2461,7 +2519,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2461
2519
  except ValueError:
2462
2520
  print("Invalid width value.")
2463
2521
  elif sub == 'f':
2464
- fw_in = input("Enter frame/tick width (e.g., 1.5) or 'm M' (major minor) or q: ").strip()
2522
+ fw_in = _safe_input("Enter frame/tick width (e.g., 1.5) or 'm M' (major minor) or q: ").strip()
2465
2523
  if not fw_in or fw_in.lower() == 'q':
2466
2524
  print("Canceled.")
2467
2525
  continue
@@ -2536,7 +2594,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2536
2594
  # Line + dots for all curves
2537
2595
  push_state("line+dots")
2538
2596
  try:
2539
- msize_in = input("Marker size (blank=auto ~3*lw): ").strip()
2597
+ msize_in = _safe_input("Marker size (blank=auto ~3*lw): ").strip()
2540
2598
  custom_msize = float(msize_in) if msize_in else None
2541
2599
  except ValueError:
2542
2600
  custom_msize = None
@@ -2566,7 +2624,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2566
2624
  # Dots only for all curves
2567
2625
  push_state("dots-only")
2568
2626
  try:
2569
- msize_in = input("Marker size (blank=auto ~3*lw): ").strip()
2627
+ msize_in = _safe_input("Marker size (blank=auto ~3*lw): ").strip()
2570
2628
  custom_msize = float(msize_in) if msize_in else None
2571
2629
  except ValueError:
2572
2630
  custom_msize = None
@@ -2613,7 +2671,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2613
2671
  print(f" {idx}: {color_block(color)} {color}")
2614
2672
  print("Type 'u' to edit saved colors.")
2615
2673
  print("q: back to main menu")
2616
- line = input("Enter mappings (e.g., w:red a:#4561F7) or q: ").strip()
2674
+ line = _safe_input("Enter mappings (e.g., w:red a:#4561F7) or q: ").strip()
2617
2675
  if not line or line.lower() == 'q':
2618
2676
  break
2619
2677
  if line.lower() == 'u':
@@ -2662,16 +2720,18 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2662
2720
  print("Tip: Use LaTeX/mathtext for special characters:")
2663
2721
  print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
2664
2722
  print(" Bullet: $\\bullet$ → • | Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
2723
+ print(" Shortcuts: g{super(-1)} → g$^{\\mathrm{-1}}$ | Li{sub(2)}O → Li$_{\\mathrm{2}}$O")
2665
2724
  while True:
2666
2725
  print("Rename axis: x, y, both, q=back")
2667
- sub = input("Rename> ").strip().lower()
2726
+ sub = _safe_input("Rename> ").strip().lower()
2668
2727
  if not sub:
2669
2728
  continue
2670
2729
  if sub == 'q':
2671
2730
  break
2672
2731
  if sub in ('x','both'):
2673
- txt = input("New X-axis label (blank=cancel): ")
2732
+ txt = _safe_input("New X-axis label (blank=cancel): ")
2674
2733
  if txt:
2734
+ txt = convert_label_shortcuts(txt)
2675
2735
  push_state("rename-x")
2676
2736
  try:
2677
2737
  # Freeze layout and preserve existing pad for one-shot restore
@@ -2693,8 +2753,9 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2693
2753
  except Exception:
2694
2754
  pass
2695
2755
  if sub in ('y','both'):
2696
- txt = input("New Y-axis label (blank=cancel): ")
2756
+ txt = _safe_input("New Y-axis label (blank=cancel): ")
2697
2757
  if txt:
2758
+ txt = convert_label_shortcuts(txt)
2698
2759
  push_state("rename-y")
2699
2760
  base_ylabel = txt
2700
2761
  try:
@@ -2829,7 +2890,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2829
2890
  print(_colorize_inline_commands(" 1=spine 2=ticks 3=minor ticks 4=tick labels 5=axis title"))
2830
2891
  print(_colorize_inline_commands("Type 'i' to invert tick direction, 'l' to change tick length, 'list' for state, 'q' to return."))
2831
2892
  print(_colorize_inline_commands(" p = adjust title offsets (w=top, s=bottom, a=left, d=right)"))
2832
- cmd = input(_colorize_prompt("t> ")).strip().lower()
2893
+ cmd = _safe_input(_colorize_prompt("t> ")).strip().lower()
2833
2894
  if not cmd:
2834
2895
  continue
2835
2896
  if cmd == 'q':
@@ -2856,7 +2917,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2856
2917
  # Get current major tick length from axes
2857
2918
  current_major = ax.xaxis.get_major_ticks()[0].tick1line.get_markersize() if ax.xaxis.get_major_ticks() else 4.0
2858
2919
  print(f"Current major tick length: {current_major}")
2859
- new_length_str = input("Enter new major tick length (e.g., 6.0): ").strip()
2920
+ new_length_str = _safe_input("Enter new major tick length (e.g., 6.0): ").strip()
2860
2921
  if not new_length_str:
2861
2922
  continue
2862
2923
  new_major = float(new_length_str)
@@ -2944,7 +3005,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2944
3005
  prompt = "Enter new filename (no ext needed), number to overwrite, or o to overwrite last (q=cancel): "
2945
3006
  else:
2946
3007
  prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
2947
- choice = input(prompt).strip()
3008
+ choice = _safe_input(prompt).strip()
2948
3009
  if not choice or choice.lower() == 'q':
2949
3010
  _print_menu(len(all_cycles), is_dqdv); continue
2950
3011
  if choice.lower() == 'o':
@@ -2955,7 +3016,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2955
3016
  if not os.path.exists(last_session_path):
2956
3017
  print(f"Previous save file not found: {last_session_path}")
2957
3018
  _print_menu(len(all_cycles), is_dqdv); continue
2958
- yn = input(f"Overwrite '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
3019
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
2959
3020
  if yn != 'y':
2960
3021
  _print_menu(len(all_cycles), is_dqdv); continue
2961
3022
  dump_ec_session(last_session_path, fig=fig, ax=ax, cycle_lines=cycle_lines, skip_confirm=True)
@@ -2965,7 +3026,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2965
3026
  idx = int(choice)
2966
3027
  if 1 <= idx <= len(files):
2967
3028
  name = files[idx-1]
2968
- yn = input(f"Overwrite '{name}'? (y/n): ").strip().lower()
3029
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
2969
3030
  if yn != 'y':
2970
3031
  _print_menu(len(all_cycles), is_dqdv); continue
2971
3032
  target = os.path.join(folder, name)
@@ -2982,7 +3043,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2982
3043
  name = name + '.pkl'
2983
3044
  target = name if os.path.isabs(name) else os.path.join(folder, name)
2984
3045
  if os.path.exists(target):
2985
- yn = input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
3046
+ yn = _safe_input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
2986
3047
  if yn != 'y':
2987
3048
  _print_menu(len(all_cycles), is_dqdv); continue
2988
3049
  dump_ec_session(target, fig=fig, ax=ax, cycle_lines=cycle_lines, skip_confirm=True)
@@ -2992,6 +3053,8 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2992
3053
  _print_menu(len(all_cycles), is_dqdv)
2993
3054
  continue
2994
3055
  elif key == 'c':
3056
+ # Show current palette if one is applied (this is informational only)
3057
+ # Note: Individual cycles may use different colors, so we can't show a single "current" palette
2995
3058
  print(f"Total cycles: {len(all_cycles)}")
2996
3059
  print("Enter one of:")
2997
3060
  print(_colorize_inline_commands(" - numbers: e.g. 1 5 10"))
@@ -3018,7 +3081,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3018
3081
  for idx, color in enumerate(user_colors, 1):
3019
3082
  print(f" {idx}: {color_block(color)} {color}")
3020
3083
  print("Type 'u' to edit saved colors before assigning.")
3021
- line = input("Selection: ").strip()
3084
+ line = _safe_input("Selection: ").strip()
3022
3085
  if not line:
3023
3086
  continue
3024
3087
  if line.lower() == 'u':
@@ -3163,14 +3226,14 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3163
3226
  # X-axis submenu: number-of-ions vs capacity
3164
3227
  while True:
3165
3228
  print("X-axis menu: n=number of ions, c=capacity, q=back")
3166
- sub = input("X> ").strip().lower()
3229
+ sub = _safe_input("X> ").strip().lower()
3167
3230
  if not sub:
3168
3231
  continue
3169
3232
  if sub == 'q':
3170
3233
  break
3171
3234
  if sub == 'n':
3172
3235
  print("Input the theoretical capacity per 1 active ion (mAh g^-1), e.g., 125")
3173
- val = input("C_theoretical_per_ion: ").strip()
3236
+ val = _safe_input("C_theoretical_per_ion: ").strip()
3174
3237
  try:
3175
3238
  c_th = float(val)
3176
3239
  if c_th <= 0:
@@ -3307,7 +3370,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3307
3370
  cur_size = plt.rcParams.get('font.size', None)
3308
3371
  while True:
3309
3372
  print(f"\nFont menu (current: family='{cur_family}', size={cur_size}): f=font family, s=size, q=back")
3310
- sub = input("Font> ").strip().lower()
3373
+ sub = _safe_input("Font> ").strip().lower()
3311
3374
  if not sub:
3312
3375
  continue
3313
3376
  if sub == 'q':
@@ -3320,7 +3383,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3320
3383
  for i, font in enumerate(fonts, 1):
3321
3384
  print(f" {i}: {font}")
3322
3385
  print("Or enter custom font name directly.")
3323
- choice = input(f"Font family (current: '{cur_family}', number or name): ").strip()
3386
+ choice = _safe_input(f"Font family (current: '{cur_family}', number or name): ").strip()
3324
3387
  if not choice:
3325
3388
  continue
3326
3389
  # Check if it's a number
@@ -3352,7 +3415,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3352
3415
  # Show current size and accept direct input
3353
3416
  import matplotlib as mpl
3354
3417
  cur_size = mpl.rcParams.get('font.size', None)
3355
- choice = input(f"Font size (current: {cur_size}): ").strip()
3418
+ choice = _safe_input(f"Font size (current: {cur_size}): ").strip()
3356
3419
  if not choice:
3357
3420
  continue
3358
3421
  try:
@@ -3377,7 +3440,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3377
3440
  while True:
3378
3441
  current_xlim = ax.get_xlim()
3379
3442
  print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
3380
- lim = input("Set X limits (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
3443
+ lim = _safe_input("Set X limits (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
3381
3444
  if not lim or lim.lower() == 'q':
3382
3445
  break
3383
3446
  if lim.lower() == 'a':
@@ -3399,7 +3462,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3399
3462
  while True:
3400
3463
  current_xlim = ax.get_xlim()
3401
3464
  print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
3402
- val = input(f"Enter new upper X limit (current lower: {current_xlim[0]:.6g}, q=back): ").strip()
3465
+ val = _safe_input(f"Enter new upper X limit (current lower: {current_xlim[0]:.6g}, q=back): ").strip()
3403
3466
  if not val or val.lower() == 'q':
3404
3467
  break
3405
3468
  try:
@@ -3425,7 +3488,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3425
3488
  while True:
3426
3489
  current_xlim = ax.get_xlim()
3427
3490
  print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
3428
- val = input(f"Enter new lower X limit (current upper: {current_xlim[1]:.6g}, q=back): ").strip()
3491
+ val = _safe_input(f"Enter new lower X limit (current upper: {current_xlim[1]:.6g}, q=back): ").strip()
3429
3492
  if not val or val.lower() == 'q':
3430
3493
  break
3431
3494
  try:
@@ -3461,7 +3524,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3461
3524
  while True:
3462
3525
  current_ylim = ax.get_ylim()
3463
3526
  print(f"Current Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3464
- lim = input("Set Y limits (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
3527
+ lim = _safe_input("Set Y limits (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
3465
3528
  if not lim or lim.lower() == 'q':
3466
3529
  break
3467
3530
  if lim.lower() == 'a':
@@ -3483,7 +3546,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3483
3546
  while True:
3484
3547
  current_ylim = ax.get_ylim()
3485
3548
  print(f"Current Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3486
- val = input(f"Enter new upper Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
3549
+ val = _safe_input(f"Enter new upper Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
3487
3550
  if not val or val.lower() == 'q':
3488
3551
  break
3489
3552
  try:
@@ -3509,7 +3572,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3509
3572
  while True:
3510
3573
  current_ylim = ax.get_ylim()
3511
3574
  print(f"Current Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3512
- val = input(f"Enter new lower Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
3575
+ val = _safe_input(f"Enter new lower Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
3513
3576
  if not val or val.lower() == 'q':
3514
3577
  break
3515
3578
  try:
@@ -3544,7 +3607,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3544
3607
  # Geometry submenu: plot frame vs canvas (scales moved to separate keys)
3545
3608
  while True:
3546
3609
  print("Geometry menu: p=plot frame size, c=canvas size, q=back")
3547
- sub = input("Geom> ").strip().lower()
3610
+ sub = _safe_input("Geom> ").strip().lower()
3548
3611
  if not sub:
3549
3612
  continue
3550
3613
  if sub == 'q':
@@ -3583,7 +3646,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3583
3646
  print(" o: remove outliers (removes abrupt dQ/dV spikes)")
3584
3647
  print(" r: reset to original data")
3585
3648
  print(" q: back to main menu")
3586
- sub = input("sm> ").strip().lower()
3649
+ sub = _safe_input("sm> ").strip().lower()
3587
3650
  if not sub:
3588
3651
  continue
3589
3652
  if sub == 'q':
@@ -3618,7 +3681,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3618
3681
  if sub == 'a':
3619
3682
  try:
3620
3683
  while True:
3621
- threshold_input = input("Enter minimum voltage step in mV (default 0.5 mV, 'q'=quit, 'e'=explain): ").strip()
3684
+ threshold_input = _safe_input("Enter minimum voltage step in mV (default 0.5 mV, 'q'=quit, 'e'=explain): ").strip()
3622
3685
  if threshold_input.lower() == 'q':
3623
3686
  break
3624
3687
  if threshold_input.lower() == 'e':
@@ -3692,7 +3755,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3692
3755
  try:
3693
3756
  print("DiffCap smoothing per Thompson et al. (2020): clean ΔV < threshold and apply Savitzky–Golay (order 3).")
3694
3757
  while True:
3695
- delta_input = input("Minimum ΔV between points (mV, default 1.0, 'q'=quit, 'e'=explain): ").strip()
3758
+ delta_input = _safe_input("Minimum ΔV between points (mV, default 1.0, 'q'=quit, 'e'=explain): ").strip()
3696
3759
  if delta_input.lower() == 'q':
3697
3760
  break
3698
3761
  if delta_input.lower() == 'e':
@@ -3715,7 +3778,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3715
3778
  if delta_input and delta_input.lower() == 'q': # User quit at previous step
3716
3779
  continue
3717
3780
  while True:
3718
- window_input = input("Savitzky–Golay window (odd, default 9, 'q'=quit, 'e'=explain): ").strip()
3781
+ window_input = _safe_input("Savitzky–Golay window (odd, default 9, 'q'=quit, 'e'=explain): ").strip()
3719
3782
  if window_input.lower() == 'q':
3720
3783
  break
3721
3784
  if window_input.lower() == 'e':
@@ -3734,7 +3797,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3734
3797
  if window_input and window_input.lower() == 'q': # User quit at previous step
3735
3798
  continue
3736
3799
  while True:
3737
- poly_input = input("Polynomial order (default 3, 'q'=quit, 'e'=explain): ").strip()
3800
+ poly_input = _safe_input("Polynomial order (default 3, 'q'=quit, 'e'=explain): ").strip()
3738
3801
  if poly_input.lower() == 'q':
3739
3802
  break
3740
3803
  if poly_input.lower() == 'e':
@@ -3806,7 +3869,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3806
3869
  print(" 1: Z-score (enter standard deviation threshold, default 5.0)")
3807
3870
  print(" 2: MAD (median absolute deviation, default factor 6.0)")
3808
3871
  while True:
3809
- method = input("Method (1/2, blank=cancel, 'q'=quit, 'e'=explain): ").strip()
3872
+ method = _safe_input("Method (1/2, blank=cancel, 'q'=quit, 'e'=explain): ").strip()
3810
3873
  if not method or method.lower() == 'q':
3811
3874
  break
3812
3875
  if method.lower() == 'e':
@@ -3832,7 +3895,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3832
3895
  continue
3833
3896
  try:
3834
3897
  while True:
3835
- thresh_input = input("Enter threshold (blank=default, 'q'=quit, 'e'=explain): ").strip()
3898
+ thresh_input = _safe_input("Enter threshold (blank=default, 'q'=quit, 'e'=explain): ").strip()
3836
3899
  if thresh_input.lower() == 'q':
3837
3900
  break
3838
3901
  if thresh_input.lower() == 'e':
@@ -4364,7 +4427,7 @@ def _export_style_dialog(cfg: Dict, default_ext: str = '.bpcfg', base_path: Opti
4364
4427
  for i, f in enumerate(bpcfg_files, 1):
4365
4428
  print(f" {i}: {f}")
4366
4429
 
4367
- choice = input(f"Export to file? Enter filename or number to overwrite (q=cancel): ").strip()
4430
+ choice = _safe_input(f"Export to file? Enter filename or number to overwrite (q=cancel): ").strip()
4368
4431
  if not choice or choice.lower() == 'q':
4369
4432
  return
4370
4433