batplot 1.8.0__tar.gz → 1.8.1__tar.gz

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.
Files changed (60) hide show
  1. {batplot-1.8.0/batplot.egg-info → batplot-1.8.1}/PKG-INFO +1 -1
  2. {batplot-1.8.0 → batplot-1.8.1}/batplot/__init__.py +1 -1
  3. {batplot-1.8.0 → batplot-1.8.1}/batplot/args.py +3 -3
  4. {batplot-1.8.0 → batplot-1.8.1}/batplot/cpc_interactive.py +86 -3
  5. {batplot-1.8.0 → batplot-1.8.1}/batplot/electrochem_interactive.py +28 -0
  6. {batplot-1.8.0 → batplot-1.8.1}/batplot/interactive.py +8 -2
  7. {batplot-1.8.0 → batplot-1.8.1}/batplot/operando.py +2 -0
  8. {batplot-1.8.0 → batplot-1.8.1}/batplot/operando_ec_interactive.py +116 -15
  9. {batplot-1.8.0 → batplot-1.8.1}/batplot/session.py +18 -1
  10. {batplot-1.8.0 → batplot-1.8.1}/batplot/utils.py +40 -0
  11. {batplot-1.8.0 → batplot-1.8.1}/batplot/version_check.py +85 -6
  12. {batplot-1.8.0 → batplot-1.8.1/batplot.egg-info}/PKG-INFO +1 -1
  13. {batplot-1.8.0 → batplot-1.8.1}/pyproject.toml +1 -1
  14. {batplot-1.8.0 → batplot-1.8.1}/LICENSE +0 -0
  15. {batplot-1.8.0 → batplot-1.8.1}/MANIFEST.in +0 -0
  16. {batplot-1.8.0 → batplot-1.8.1}/README.md +0 -0
  17. {batplot-1.8.0 → batplot-1.8.1}/USER_MANUAL.md +0 -0
  18. {batplot-1.8.0 → batplot-1.8.1}/batplot/batch.py +0 -0
  19. {batplot-1.8.0 → batplot-1.8.1}/batplot/batplot.py +0 -0
  20. {batplot-1.8.0 → batplot-1.8.1}/batplot/cif.py +0 -0
  21. {batplot-1.8.0 → batplot-1.8.1}/batplot/cli.py +0 -0
  22. {batplot-1.8.0 → batplot-1.8.1}/batplot/color_utils.py +0 -0
  23. {batplot-1.8.0 → batplot-1.8.1}/batplot/config.py +0 -0
  24. {batplot-1.8.0 → batplot-1.8.1}/batplot/converters.py +0 -0
  25. {batplot-1.8.0 → batplot-1.8.1}/batplot/data/USER_MANUAL.md +0 -0
  26. {batplot-1.8.0 → batplot-1.8.1}/batplot/manual.py +0 -0
  27. {batplot-1.8.0 → batplot-1.8.1}/batplot/modes.py +0 -0
  28. {batplot-1.8.0 → batplot-1.8.1}/batplot/plotting.py +0 -0
  29. {batplot-1.8.0 → batplot-1.8.1}/batplot/readers.py +0 -0
  30. {batplot-1.8.0 → batplot-1.8.1}/batplot/style.py +0 -0
  31. {batplot-1.8.0 → batplot-1.8.1}/batplot/ui.py +0 -0
  32. {batplot-1.8.0 → batplot-1.8.1}/batplot.egg-info/SOURCES.txt +0 -0
  33. {batplot-1.8.0 → batplot-1.8.1}/batplot.egg-info/dependency_links.txt +0 -0
  34. {batplot-1.8.0 → batplot-1.8.1}/batplot.egg-info/entry_points.txt +0 -0
  35. {batplot-1.8.0 → batplot-1.8.1}/batplot.egg-info/requires.txt +0 -0
  36. {batplot-1.8.0 → batplot-1.8.1}/batplot.egg-info/top_level.txt +0 -0
  37. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/__init__.py +0 -0
  38. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/args.py +0 -0
  39. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/batch.py +0 -0
  40. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/batplot.py +0 -0
  41. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/cif.py +0 -0
  42. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/cli.py +0 -0
  43. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/color_utils.py +0 -0
  44. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/config.py +0 -0
  45. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/converters.py +0 -0
  46. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/cpc_interactive.py +0 -0
  47. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/electrochem_interactive.py +0 -0
  48. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/interactive.py +0 -0
  49. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/modes.py +0 -0
  50. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/operando.py +0 -0
  51. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/operando_ec_interactive.py +0 -0
  52. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/plotting.py +0 -0
  53. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/readers.py +0 -0
  54. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/session.py +0 -0
  55. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/style.py +0 -0
  56. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/ui.py +0 -0
  57. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/utils.py +0 -0
  58. {batplot-1.8.0 → batplot-1.8.1}/batplot_backup_20251121_223043/version_check.py +0 -0
  59. {batplot-1.8.0 → batplot-1.8.1}/setup.cfg +0 -0
  60. {batplot-1.8.0 → batplot-1.8.1}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batplot
3
- Version: 1.8.0
3
+ Version: 1.8.1
4
4
  Summary: Interactive plotting tool for material science (1D plot) and electrochemistry (GC, CV, dQ/dV, CPC, operando) with batch processing
5
5
  Author-email: Tian Dai <tianda@uio.no>
6
6
  License: MIT License
@@ -1,5 +1,5 @@
1
1
  """batplot: Interactive plotting for battery data visualization."""
2
2
 
3
- __version__ = "1.8.0"
3
+ __version__ = "1.8.1"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -169,7 +169,7 @@ def _print_general_help() -> None:
169
169
  " batplot --cv FILE.txt # EC CV (cyclic voltammetry) from .txt\n"
170
170
  " batplot --cv --all # Batch: all .mpt/.txt in directory (CV mode)\n\n"
171
171
  " [Operando]\n"
172
- " batplot --operando --i [FOLDER] # Operando contour (with or without .mpt file) with interactive menu\n\n"
172
+ " batplot --operando --i [FOLDER] # Operando contour (with or without .mpt file)\n\n"
173
173
  "Features:\n"
174
174
  " • Quick plotting with sensible defaults, no config files needed\n"
175
175
  " • Supports many common file formats (see -h xy/ec/op)\n"
@@ -183,9 +183,9 @@ def _print_general_help() -> None:
183
183
  " batplot -h xy # XY file plotting guide\n"
184
184
  " batplot -h ec # Electrochemistry (GC/dQdV/CV/CPC) guide\n"
185
185
  " batplot -h op # Operando guide\n"
186
- " batplot -m # Open the illustrated txt manual with highlights\n"
186
+ " batplot -m # Open the illustrated txt manual with highlights\n\n"
187
187
 
188
- "Contact & Updates:\n\n"
188
+ "Contact & Updates:\n"
189
189
  " Subscribe to batplot-lab@kjemi.uio.no for updates\n"
190
190
  " (If you are not from UiO, send an email to sympa@kjemi.uio.no with the subject line \"subscribe batplot-lab@kjemi.uio.no your-name\")\n"
191
191
  " Kindly cite the pypi package page (https://pypi.org/project/batplot/) if the plot is used for publication\n"
@@ -86,6 +86,7 @@ from .ui import (
86
86
  from .utils import (
87
87
  _confirm_overwrite,
88
88
  choose_save_path,
89
+ convert_label_shortcuts,
89
90
  choose_style_file,
90
91
  list_files_in_subdirectory,
91
92
  get_organized_path,
@@ -335,13 +336,36 @@ def _rebuild_legend(ax, ax2, file_data, preserve_position=True):
335
336
  """
336
337
  try:
337
338
  fig = ax.figure
338
- # Get stored position before rebuilding
339
+ # Get stored position before rebuilding. If none is stored yet, try to
340
+ # capture the current on-canvas position once so subsequent rebuilds
341
+ # (e.g., after renaming) do not jump to a new "best" location.
339
342
  xy_in = None
340
343
  if preserve_position:
341
344
  try:
342
345
  xy_in = getattr(fig, '_cpc_legend_xy_in', None)
343
346
  except Exception:
344
- pass
347
+ xy_in = None
348
+ if xy_in is None:
349
+ try:
350
+ leg0 = ax.get_legend()
351
+ if leg0 is not None and leg0.get_visible():
352
+ try:
353
+ renderer = fig.canvas.get_renderer()
354
+ except Exception:
355
+ fig.canvas.draw()
356
+ renderer = fig.canvas.get_renderer()
357
+ bb = leg0.get_window_extent(renderer=renderer)
358
+ cx = 0.5 * (bb.x0 + bb.x1)
359
+ cy = 0.5 * (bb.y0 + bb.y1)
360
+ fx, fy = fig.transFigure.inverted().transform((cx, cy))
361
+ fw, fh = fig.get_size_inches()
362
+ offset = ((fx - 0.5) * fw, (fy - 0.5) * fh)
363
+ offset = _sanitize_legend_offset(offset)
364
+ if offset is not None:
365
+ fig._cpc_legend_xy_in = offset
366
+ xy_in = offset
367
+ except Exception:
368
+ pass
345
369
 
346
370
  h1, l1 = ax.get_legend_handles_labels()
347
371
  h2, l2 = ax2.get_legend_handles_labels()
@@ -1643,6 +1667,31 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1643
1667
  else:
1644
1668
  # Single file mode: toggle efficiency
1645
1669
  push_state("visibility-eff")
1670
+ # Capture current legend position BEFORE toggling visibility
1671
+ try:
1672
+ if not hasattr(fig, '_cpc_legend_xy_in') or getattr(fig, '_cpc_legend_xy_in') is None:
1673
+ leg0 = ax.get_legend()
1674
+ if leg0 is not None and leg0.get_visible():
1675
+ try:
1676
+ # Ensure renderer exists
1677
+ try:
1678
+ renderer = fig.canvas.get_renderer()
1679
+ except Exception:
1680
+ fig.canvas.draw()
1681
+ renderer = fig.canvas.get_renderer()
1682
+ bb = leg0.get_window_extent(renderer=renderer)
1683
+ cx = 0.5 * (bb.x0 + bb.x1)
1684
+ cy = 0.5 * (bb.y0 + bb.y1)
1685
+ fx, fy = fig.transFigure.inverted().transform((cx, cy))
1686
+ fw, fh = fig.get_size_inches()
1687
+ offset = ((fx - 0.5) * fw, (fy - 0.5) * fh)
1688
+ offset = _sanitize_legend_offset(offset)
1689
+ if offset is not None:
1690
+ fig._cpc_legend_xy_in = offset
1691
+ except Exception:
1692
+ pass
1693
+ except Exception:
1694
+ pass
1646
1695
  vis = sc_eff.get_visible()
1647
1696
  sc_eff.set_visible(not vis)
1648
1697
  try:
@@ -1650,7 +1699,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1650
1699
  except Exception:
1651
1700
  pass
1652
1701
 
1653
- _rebuild_legend(ax, ax2, file_data)
1702
+ _rebuild_legend(ax, ax2, file_data, preserve_position=True)
1654
1703
  fig.canvas.draw_idle()
1655
1704
  except ValueError:
1656
1705
  print("Invalid input.")
@@ -1676,6 +1725,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1676
1725
  elif key == 'c':
1677
1726
  # Colors submenu: ly (left Y series) and ry (right Y efficiency), with user colors and palettes
1678
1727
  try:
1728
+ # Note: Individual series may use different colors, so we can't show a single "current" palette
1679
1729
  # Use same palettes as EC interactive
1680
1730
  palette_opts = ['tab10', 'Set2', 'Dark2', 'viridis', 'plasma']
1681
1731
  def _palette_color(name, idx=0, total=1, default_val=0.4):
@@ -2668,6 +2718,33 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2668
2718
  try:
2669
2719
  push_state("toggle-eff")
2670
2720
 
2721
+ # Capture current legend position BEFORE toggling visibility
2722
+ # This ensures the position is preserved when legend is rebuilt
2723
+ try:
2724
+ if not hasattr(fig, '_cpc_legend_xy_in') or getattr(fig, '_cpc_legend_xy_in') is None:
2725
+ leg0 = ax.get_legend()
2726
+ if leg0 is not None and leg0.get_visible():
2727
+ try:
2728
+ # Ensure renderer exists
2729
+ try:
2730
+ renderer = fig.canvas.get_renderer()
2731
+ except Exception:
2732
+ fig.canvas.draw()
2733
+ renderer = fig.canvas.get_renderer()
2734
+ bb = leg0.get_window_extent(renderer=renderer)
2735
+ cx = 0.5 * (bb.x0 + bb.x1)
2736
+ cy = 0.5 * (bb.y0 + bb.y1)
2737
+ fx, fy = fig.transFigure.inverted().transform((cx, cy))
2738
+ fw, fh = fig.get_size_inches()
2739
+ offset = ((fx - 0.5) * fw, (fy - 0.5) * fh)
2740
+ offset = _sanitize_legend_offset(offset)
2741
+ if offset is not None:
2742
+ fig._cpc_legend_xy_in = offset
2743
+ except Exception:
2744
+ pass
2745
+ except Exception:
2746
+ pass
2747
+
2671
2748
  # Determine current visibility state (check if any efficiency is visible)
2672
2749
  if is_multi_file:
2673
2750
  # In multi-file mode, check if any efficiency is visible
@@ -3738,6 +3815,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3738
3815
  print("Tip: Use LaTeX/mathtext for special characters:")
3739
3816
  print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
3740
3817
  print(" Bullet: $\\bullet$ → • | Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
3818
+ print(" Shortcuts: g{super(-1)} → g$^{\\mathrm{-1}}$ | Li{sub(2)}O → Li$_{\\mathrm{2}}$O")
3741
3819
  while True:
3742
3820
  print("Rename: x=x-axis, ly=left y-axis, ry=right y-axis, l=legend labels, q=back")
3743
3821
  sub = _safe_input("Rename> ").strip().lower()
@@ -3788,6 +3866,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3788
3866
  print(f"Current file name in legend: '{base_name}'")
3789
3867
  new_name = _safe_input("Enter new file name (q=cancel): ").strip()
3790
3868
  if new_name and new_name.lower() != 'q':
3869
+ new_name = convert_label_shortcuts(new_name)
3791
3870
  try:
3792
3871
  push_state("rename-legend")
3793
3872
 
@@ -3895,6 +3974,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3895
3974
  print(f"Current file name in legend: '{base_name}'")
3896
3975
  new_name = _safe_input("Enter new file name (q=cancel): ").strip()
3897
3976
  if new_name and new_name.lower() != 'q':
3977
+ new_name = convert_label_shortcuts(new_name)
3898
3978
  try:
3899
3979
  push_state("rename-legend")
3900
3980
 
@@ -3966,6 +4046,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3966
4046
  print(f"Current x-axis title: '{current}'")
3967
4047
  new_title = _safe_input("Enter new x-axis title (q=cancel): ")
3968
4048
  if new_title and new_title.lower() != 'q':
4049
+ new_title = convert_label_shortcuts(new_title)
3969
4050
  try:
3970
4051
  push_state("rename-x")
3971
4052
  ax.set_xlabel(new_title)
@@ -3985,6 +4066,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3985
4066
  print(f"Current left y-axis title: '{current}'")
3986
4067
  new_title = _safe_input("Enter new left y-axis title (q=cancel): ")
3987
4068
  if new_title and new_title.lower() != 'q':
4069
+ new_title = convert_label_shortcuts(new_title)
3988
4070
  try:
3989
4071
  push_state("rename-ly")
3990
4072
  ax.set_ylabel(new_title)
@@ -3999,6 +4081,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3999
4081
  print(f"Current right y-axis title: '{current}'")
4000
4082
  new_title = _safe_input("Enter new right y-axis title (q=cancel): ")
4001
4083
  if new_title and new_title.lower() != 'q':
4084
+ new_title = convert_label_shortcuts(new_title)
4002
4085
  try:
4003
4086
  push_state("rename-ry")
4004
4087
  ax2.set_ylabel(new_title)
@@ -31,6 +31,7 @@ from .utils import (
31
31
  choose_style_file,
32
32
  list_files_in_subdirectory,
33
33
  get_organized_path,
34
+ convert_label_shortcuts,
34
35
  )
35
36
  import time
36
37
  from .color_utils import (
@@ -502,6 +503,28 @@ def _rebuild_legend(ax):
502
503
  fig = ax.figure
503
504
  # Capture existing title before any rebuild so it isn't lost
504
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
505
528
  if not _get_legend_user_pref(fig):
506
529
  leg = ax.get_legend()
507
530
  if leg is not None:
@@ -2697,6 +2720,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2697
2720
  print("Tip: Use LaTeX/mathtext for special characters:")
2698
2721
  print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
2699
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")
2700
2724
  while True:
2701
2725
  print("Rename axis: x, y, both, q=back")
2702
2726
  sub = _safe_input("Rename> ").strip().lower()
@@ -2707,6 +2731,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2707
2731
  if sub in ('x','both'):
2708
2732
  txt = _safe_input("New X-axis label (blank=cancel): ")
2709
2733
  if txt:
2734
+ txt = convert_label_shortcuts(txt)
2710
2735
  push_state("rename-x")
2711
2736
  try:
2712
2737
  # Freeze layout and preserve existing pad for one-shot restore
@@ -2730,6 +2755,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2730
2755
  if sub in ('y','both'):
2731
2756
  txt = _safe_input("New Y-axis label (blank=cancel): ")
2732
2757
  if txt:
2758
+ txt = convert_label_shortcuts(txt)
2733
2759
  push_state("rename-y")
2734
2760
  base_ylabel = txt
2735
2761
  try:
@@ -3027,6 +3053,8 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
3027
3053
  _print_menu(len(all_cycles), is_dqdv)
3028
3054
  continue
3029
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
3030
3058
  print(f"Total cycles: {len(all_cycles)}")
3031
3059
  print("Enter one of:")
3032
3060
  print(_colorize_inline_commands(" - numbers: e.g. 1 5 10"))
@@ -25,6 +25,7 @@ from .utils import (
25
25
  choose_save_path,
26
26
  choose_style_file,
27
27
  list_files_in_subdirectory,
28
+ convert_label_shortcuts,
28
29
  get_organized_path,
29
30
  )
30
31
  import time
@@ -1955,12 +1956,11 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
1955
1956
  ax._cif_draw_func()
1956
1957
  fig.canvas.draw()
1957
1958
  elif sub == 'p':
1959
+ # Show current palette if one is applied
1958
1960
  history = getattr(fig, '_curve_palette_history', [])
1959
1961
  current_palette = history[-1]['palette'] if history else None
1960
1962
  if current_palette:
1961
1963
  print(f"Current palette: {current_palette}")
1962
- else:
1963
- print("Current palette: manual/custom")
1964
1964
  base_palettes = ['viridis', 'cividis', 'plasma', 'inferno', 'magma', 'batlow']
1965
1965
  extras = []
1966
1966
  def _palette_available(name: str) -> bool:
@@ -2211,6 +2211,7 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
2211
2211
  print("Tip: Use LaTeX/mathtext for special characters:")
2212
2212
  print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
2213
2213
  print(" Bullet: $\\bullet$ → • | Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
2214
+ print(" Shortcuts: g{super(-1)} → g$^{\\mathrm{-1}}$ | Li{sub(2)}O → Li$_{\\mathrm{2}}$O")
2214
2215
  idx_in = _safe_input("Curve number to rename (q=cancel): ").strip()
2215
2216
  if not idx_in or idx_in.lower() == 'q':
2216
2217
  print("Canceled.")
@@ -2227,6 +2228,7 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
2227
2228
  if not new_label or new_label.lower() == 'q':
2228
2229
  print("Canceled.")
2229
2230
  continue
2231
+ new_label = convert_label_shortcuts(new_label)
2230
2232
  push_state("rename-curve")
2231
2233
  labels[idx] = new_label
2232
2234
  label_text_objects[idx].set_text(f"{idx+1}: {new_label}")
@@ -2250,9 +2252,11 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
2250
2252
  print("Tip: Use LaTeX/mathtext for special characters:")
2251
2253
  print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
2252
2254
  print(" Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
2255
+ print(" Shortcuts: g{super(-1)} → g$^{\\mathrm{-1}}$ | Li{sub(2)}O → Li$_{\\mathrm{2}}$O")
2253
2256
  new_name = _safe_input("New CIF tick label (q=cancel): ")
2254
2257
  if not new_name or new_name.lower()=='q':
2255
2258
  print("Canceled."); continue
2259
+ new_name = convert_label_shortcuts(new_name)
2256
2260
  lab,fname,peaksQ,wl,qmax_sim,color = cts[idx]
2257
2261
  # Suspend extension while updating label
2258
2262
  if _bp is not None:
@@ -2278,10 +2282,12 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
2278
2282
  print("Tip: Use LaTeX/mathtext for special characters:")
2279
2283
  print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
2280
2284
  print(" Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
2285
+ print(" Shortcuts: g{super(-1)} → g$^{\\mathrm{-1}}$ | Li{sub(2)}O → Li$_{\\mathrm{2}}$O")
2281
2286
  new_axis = _safe_input("New axis label: ")
2282
2287
  if not new_axis or new_axis.lower() == 'q':
2283
2288
  print("Canceled.")
2284
2289
  continue
2290
+ new_axis = convert_label_shortcuts(new_axis)
2285
2291
  new_axis = normalize_label_text(new_axis)
2286
2292
  push_state("rename-axis")
2287
2293
  # Freeze layout and preserve current pad via one-shot pending to avoid drift
@@ -315,6 +315,8 @@ def plot_operando_folder(folder: str, args) -> Tuple[plt.Figure, plt.Axes, Dict[
315
315
  extent = (grid_x.min(), grid_x.max(), 0, Zm.shape[0]-1)
316
316
  # Bottom-to-top visual order (scan 0 at bottom) to match EC time progression -> origin='lower'
317
317
  im = ax.imshow(Zm, aspect='auto', origin='lower', extent=extent, cmap='viridis', interpolation='nearest')
318
+ # Store the colormap name explicitly so it can be retrieved reliably when saving
319
+ setattr(im, '_operando_cmap_name', 'viridis')
318
320
  # Create custom colorbar axes on the left (will be positioned by layout function)
319
321
  # Create a dummy axes that will be replaced by the custom colorbar in interactive menu
320
322
  cbar_ax = fig.add_axes([0.0, 0.0, 0.01, 0.01]) # Temporary position, will be repositioned
@@ -39,7 +39,7 @@ from .color_utils import (
39
39
  resolve_color_token,
40
40
  manage_user_colors,
41
41
  )
42
- from .utils import choose_style_file
42
+ from .utils import choose_style_file, convert_label_shortcuts
43
43
 
44
44
 
45
45
  class _FilterIMKWarning:
@@ -354,7 +354,7 @@ def _draw_custom_colorbar(cbar_ax, im, label='Intensity', label_mode='normal'):
354
354
 
355
355
  def _update_custom_colorbar(cbar_ax, im=None, label=None, label_mode=None):
356
356
  """Update the custom colorbar when colormap or limits change.
357
-
357
+
358
358
  Args:
359
359
  cbar_ax: Axes object containing the colorbar
360
360
  im: Optional AxesImage object (if None, uses stored reference)
@@ -365,17 +365,82 @@ def _update_custom_colorbar(cbar_ax, im=None, label=None, label_mode=None):
365
365
  im = getattr(cbar_ax, '_colorbar_im', None)
366
366
  if im is None:
367
367
  return
368
-
368
+
369
369
  if label is None:
370
370
  label = getattr(cbar_ax, '_colorbar_label', 'Intensity')
371
-
371
+
372
372
  if label_mode is None:
373
373
  label_mode = getattr(cbar_ax, '_colorbar_label_mode', 'normal')
374
-
374
+
375
375
  # Redraw the colorbar
376
376
  _draw_custom_colorbar(cbar_ax, im, label, label_mode)
377
377
 
378
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
+
379
444
  def _detach_mpl_colorbar_callbacks(cbar, im) -> None:
380
445
  """Detach a Matplotlib Colorbar from its mappable callbacks.
381
446
 
@@ -737,7 +802,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
737
802
 
738
803
  if intensity_max > intensity_min:
739
804
  # Update color limits
740
- im.set_clim(intensity_min, intensity_max)
805
+ _safe_set_clim(im, intensity_min, intensity_max)
741
806
 
742
807
  # Update colorbar if available
743
808
  try:
@@ -1111,7 +1176,10 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1111
1176
  clim = im.get_clim()
1112
1177
  except Exception:
1113
1178
  clim = None
1114
- 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)
1115
1183
  # EC mode and caches (only if ec_ax exists)
1116
1184
  if ec_ax is not None:
1117
1185
  mode = getattr(ec_ax, '_ec_y_mode', 'time')
@@ -1325,12 +1393,15 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1325
1393
  cbar.solids = None
1326
1394
  except Exception:
1327
1395
  pass
1328
- lo, hi = snap['clim']; im.set_clim(float(lo), float(hi))
1396
+ lo, hi = snap['clim']; _safe_set_clim(im, float(lo), float(hi))
1329
1397
  except Exception:
1330
1398
  pass
1331
1399
  try:
1332
1400
  if snap.get('cmap'):
1333
- 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)
1334
1405
  if cbar is not None:
1335
1406
  _update_custom_colorbar(cbar.ax, im)
1336
1407
  except Exception:
@@ -2023,8 +2094,10 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2023
2094
  continue
2024
2095
  except ValueError:
2025
2096
  print("Invalid input. Enter a number.")
2097
+ continue
2026
2098
  except Exception as e:
2027
2099
  print(f"Error: {e}")
2100
+ continue
2028
2101
  elif sub == 'e':
2029
2102
  if ec_ax is None:
2030
2103
  print("EC panel not available.")
@@ -2042,10 +2115,13 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2042
2115
  continue
2043
2116
  except ValueError:
2044
2117
  print("Invalid input. Enter a number.")
2118
+ continue
2045
2119
  except Exception as e:
2046
2120
  print(f"Error: {e}")
2121
+ continue
2047
2122
  else:
2048
2123
  print("Invalid choice.")
2124
+ continue
2049
2125
  elif choice != 'q':
2050
2126
  print("Invalid choice")
2051
2127
  else:
@@ -2107,10 +2183,13 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2107
2183
  continue
2108
2184
  except ValueError:
2109
2185
  print("Invalid input. Enter a number.")
2186
+ continue
2110
2187
  except Exception as e:
2111
2188
  print(f"Error: {e}")
2189
+ continue
2112
2190
  else:
2113
2191
  print("Invalid choice.")
2192
+ continue
2114
2193
  elif choice != 'q':
2115
2194
  print("Invalid choice")
2116
2195
  fig.canvas.draw_idle()
@@ -3280,7 +3359,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3280
3359
  print("Invalid value, ignored.")
3281
3360
  continue
3282
3361
  _snapshot("operando-intensity-range")
3283
- im.set_clim(cur[0], new_upper)
3362
+ _safe_set_clim(im, cur[0], new_upper)
3284
3363
  try:
3285
3364
  if cbar is not None:
3286
3365
  _update_custom_colorbar(cbar.ax, im)
@@ -3306,7 +3385,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3306
3385
  print("Invalid value, ignored.")
3307
3386
  continue
3308
3387
  _snapshot("operando-intensity-range")
3309
- im.set_clim(new_lower, cur[1])
3388
+ _safe_set_clim(im, new_lower, cur[1])
3310
3389
  try:
3311
3390
  if cbar is not None:
3312
3391
  _update_custom_colorbar(cbar.ax, im)
@@ -3320,7 +3399,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3320
3399
  if line.lower() == 'a':
3321
3400
  # Apply auto-normalization to visible data
3322
3401
  if auto_available:
3323
- im.set_clim(auto_lo, auto_hi)
3402
+ _safe_set_clim(im, auto_lo, auto_hi)
3324
3403
  try:
3325
3404
  if cbar is not None:
3326
3405
  _update_custom_colorbar(cbar.ax, im)
@@ -3332,7 +3411,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3332
3411
  print("Auto-fit unavailable: no finite data in visible area")
3333
3412
  else:
3334
3413
  lo, hi = map(float, line.split())
3335
- im.set_clim(lo, hi)
3414
+ _safe_set_clim(im, lo, hi)
3336
3415
  try:
3337
3416
  if cbar is not None:
3338
3417
  _update_custom_colorbar(cbar.ax, im)
@@ -3381,6 +3460,15 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3381
3460
  print_menu()
3382
3461
  elif cmd == 'oc':
3383
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
3384
3472
  def _refresh_available():
3385
3473
  return set(name.lower() for name in plt.colormaps())
3386
3474
  available = _refresh_available()
@@ -3453,6 +3541,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3453
3541
  if reversed_choice:
3454
3542
  palette_obj = palette_obj.reversed()
3455
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)
3456
3546
  try:
3457
3547
  # Update custom colorbar with new colormap
3458
3548
  if cbar is not None:
@@ -3489,7 +3579,10 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3489
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)
3490
3580
  fam = plt.rcParams.get('font.sans-serif', [''])[0]
3491
3581
  fsize = plt.rcParams.get('font.size', None)
3492
- 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)
3493
3586
  cb_vis = bool(cbar.ax.get_visible())
3494
3587
  ec_vis = bool(ec_ax.get_visible()) if ec_ax is not None else None
3495
3588
  cb_label_text = str(getattr(cbar.ax, '_colorbar_label', cbar.ax.get_ylabel() or 'Intensity'))
@@ -4056,6 +4149,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4056
4149
  if cmap:
4057
4150
  try:
4058
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)
4059
4154
  if cbar is not None:
4060
4155
  _update_custom_colorbar(cbar.ax, im)
4061
4156
  except Exception:
@@ -4329,7 +4424,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4329
4424
  try:
4330
4425
  intensity_range = op.get('intensity_range')
4331
4426
  if intensity_range and isinstance(intensity_range, (list, tuple)) and len(intensity_range) == 2:
4332
- im.set_clim(float(intensity_range[0]), float(intensity_range[1]))
4427
+ _safe_set_clim(im, float(intensity_range[0]), float(intensity_range[1]))
4333
4428
  print(f"Applied intensity range: {intensity_range[0]:.4g} to {intensity_range[1]:.4g}")
4334
4429
  except Exception as e:
4335
4430
  print(f"Warning: Could not apply intensity range: {e}")
@@ -4623,6 +4718,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4623
4718
  print("Tip: Use LaTeX/mathtext for special characters:")
4624
4719
  print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
4625
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")
4626
4722
  while True:
4627
4723
  sub = _safe_input("or> ").strip().lower()
4628
4724
  if not sub:
@@ -4633,6 +4729,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4633
4729
  cur = ax.get_xlabel() or ''
4634
4730
  lab = _safe_input(f"New operando X label (blank=cancel, current='{cur}'): ")
4635
4731
  if lab:
4732
+ lab = convert_label_shortcuts(lab)
4636
4733
  _snapshot("rename-op-x")
4637
4734
  try:
4638
4735
  ax.set_xlabel(lab)
@@ -4646,6 +4743,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4646
4743
  cur = ax.get_ylabel() or ''
4647
4744
  lab = _safe_input(f"New operando Y label (blank=cancel, current='{cur}'): ")
4648
4745
  if lab:
4746
+ lab = convert_label_shortcuts(lab)
4649
4747
  _snapshot("rename-op-y")
4650
4748
  try:
4651
4749
  ax.set_ylabel(lab)
@@ -4675,6 +4773,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4675
4773
  print("Tip: Use LaTeX/mathtext for special characters:")
4676
4774
  print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
4677
4775
  print(" Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
4776
+ print(" Shortcuts: g{super(-1)} → g$^{\\mathrm{-1}}$ | Li{sub(2)}O → Li$_{\\mathrm{2}}$O")
4678
4777
  while True:
4679
4778
  sub = _safe_input("er> ").strip().lower()
4680
4779
  if not sub:
@@ -4685,6 +4784,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4685
4784
  cur = ec_ax.get_xlabel() or ''
4686
4785
  lab = _safe_input(f"New EC X label (blank=cancel, current='{cur}'): ")
4687
4786
  if lab:
4787
+ lab = convert_label_shortcuts(lab)
4688
4788
  _snapshot("rename-ec-x")
4689
4789
  try:
4690
4790
  ec_ax.set_xlabel(lab)
@@ -4697,6 +4797,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4697
4797
  cur = ec_ax.get_ylabel() or ''
4698
4798
  lab = _safe_input(f"New EC Y label (blank=cancel, current='{cur}'): ")
4699
4799
  if lab:
4800
+ lab = convert_label_shortcuts(lab)
4700
4801
  _snapshot("rename-ec-y")
4701
4802
  try:
4702
4803
  ec_ax.set_ylabel(lab)
@@ -578,7 +578,10 @@ def dump_operando_session(
578
578
  # Use masked arrays to preserve NaNs if present
579
579
  data = _np.array(arr) # preserves mask where possible
580
580
  extent = tuple(map(float, im.get_extent())) if hasattr(im, 'get_extent') else None
581
- cmap_name = getattr(im.get_cmap(), 'name', None)
581
+ # Get colormap name: first check if we stored it explicitly, otherwise try to get from colormap object
582
+ cmap_name = getattr(im, '_operando_cmap_name', None)
583
+ if cmap_name is None:
584
+ cmap_name = getattr(im.get_cmap(), 'name', None)
582
585
  clim = tuple(map(float, im.get_clim())) if hasattr(im, 'get_clim') else None
583
586
  origin = getattr(im, 'origin', 'upper')
584
587
  interpolation = getattr(im, 'get_interpolation', lambda: None)() or 'nearest'
@@ -870,6 +873,8 @@ def load_operando_session(filename: str):
870
873
  cmap_name = 'viridis'
871
874
  im = ax.imshow(arr, aspect='auto', origin=op.get('origin', 'upper'), extent=extent,
872
875
  cmap=cmap_name, interpolation=op.get('interpolation', 'nearest'))
876
+ # Store the colormap name explicitly so it can be retrieved reliably when saving
877
+ setattr(im, '_operando_cmap_name', cmap_name)
873
878
  if op.get('clim'):
874
879
  try:
875
880
  im.set_clim(*op['clim'])
@@ -1315,6 +1320,18 @@ def load_operando_session(filename: str):
1315
1320
  elif ec_h_offset is not None:
1316
1321
  # EC panel doesn't exist but offset was saved - ignore it
1317
1322
  pass
1323
+
1324
+ # Apply layout with loaded offsets to ensure visual position matches saved position
1325
+ # This must happen after all offsets and geometry parameters are set
1326
+ try:
1327
+ from .operando_ec_interactive import _apply_group_layout_inches, _ensure_fixed_params
1328
+ # Get current geometry parameters (which should match what was just loaded)
1329
+ cb_w_i, cb_gap_i, ec_gap_i, ec_w_i, ax_w_i, ax_h_i = _ensure_fixed_params(fig, ax, cbar_ax, ec_ax)
1330
+ # Apply layout with loaded offsets (offsets are already set as attributes above)
1331
+ _apply_group_layout_inches(fig, ax, cbar_ax, ec_ax, ax_w_i, ax_h_i, cb_w_i, cb_gap_i, ec_gap_i, ec_w_i)
1332
+ except Exception:
1333
+ # If layout application fails, continue - better to have a slightly wrong layout than crash
1334
+ pass
1318
1335
  except Exception:
1319
1336
  pass
1320
1337
 
@@ -540,6 +540,46 @@ def list_files_in_subdirectory(extensions: tuple, file_type: str, base_path: str
540
540
  return sorted(files, key=lambda x: x[0])
541
541
 
542
542
 
543
+ def convert_label_shortcuts(text: str) -> str:
544
+ """Convert shortcut syntax to LaTeX format for labels.
545
+
546
+ Converts {super(...)} and {sub(...)} shortcuts to LaTeX superscript/subscript format.
547
+ This allows easier input of mathematical notation without typing full LaTeX.
548
+
549
+ Args:
550
+ text: Label text that may contain {super(...)} or {sub(...)} shortcuts
551
+
552
+ Returns:
553
+ Text with shortcuts converted to LaTeX format (uses \\mathrm{} to prevent italic rendering).
554
+
555
+ Examples:
556
+ >>> convert_label_shortcuts("g{super(-1)}")
557
+ "g$^{\\mathrm{-1}}$"
558
+ >>> convert_label_shortcuts("Li{sub(2)}FeSeO")
559
+ "Li$_{\\mathrm{2}}$FeSeO"
560
+ >>> convert_label_shortcuts("H{sub(2)}O")
561
+ "H$_{\\mathrm{2}}$O"
562
+ """
563
+ if not text:
564
+ return text
565
+
566
+ import re
567
+
568
+ # Convert {super(...)} to $^{\mathrm{...}}$ to prevent italic rendering
569
+ # Pattern matches {super(anything inside)}
570
+ # Use \mathrm{} to ensure non-italic rendering unless explicitly specified
571
+ # Need to escape backslashes in replacement string for LaTeX commands
572
+ text = re.sub(r'\{super\(([^)]+)\)\}', r'$^{\\mathrm{\1}}$', text)
573
+
574
+ # Convert {sub(...)} to $_{\mathrm{...}}$ to prevent italic rendering
575
+ # Pattern matches {sub(anything inside)}
576
+ # Use \mathrm{} to ensure non-italic rendering unless explicitly specified
577
+ # Need to escape backslashes in replacement string for LaTeX commands
578
+ text = re.sub(r'\{sub\(([^)]+)\)\}', r'$_{\\mathrm{\1}}$', text)
579
+
580
+ return text
581
+
582
+
543
583
  def normalize_label_text(text: str) -> str:
544
584
  """Normalize axis label text for proper matplotlib rendering.
545
585
 
@@ -53,6 +53,54 @@ import time
53
53
  from pathlib import Path
54
54
  from typing import Optional, Tuple
55
55
 
56
+ # ====================================================================================
57
+ # UPDATE INFO CONFIGURATION
58
+ # ====================================================================================
59
+ # Edit this section to customize update notification messages and add update info.
60
+ #
61
+ # HOW TO USE:
62
+ # ----------
63
+ # When releasing a new version, edit the UPDATE_INFO dictionary below to include
64
+ # information about what's new or important in the update. This information will
65
+ # be displayed to users when they run batplot and a newer version is available.
66
+ #
67
+ # EXAMPLE:
68
+ # --------
69
+ # UPDATE_INFO = {
70
+ # 'custom_message': "This update includes important bug fixes and new features.",
71
+ # 'update_notes': [
72
+ # "- Fixed colormap preservation issue in session files",
73
+ # "- Improved legend positioning when toggling visibility",
74
+ # "- Added superscript/subscript shortcuts for labels",
75
+ # "- Enhanced version check notifications"
76
+ # ],
77
+ # 'show_update_notes': True,
78
+ # }
79
+ #
80
+ # To disable custom messages, set 'custom_message' to None.
81
+ # To disable update notes, set 'update_notes' to None or an empty list [].
82
+ # ====================================================================================
83
+
84
+ UPDATE_INFO = {
85
+ # Custom message to include in update notification
86
+ # Set to None or empty string to disable
87
+ # This will be displayed as an additional line in the update message box
88
+ 'custom_message': "This update includes important bug fixes", # Example: "This update includes important bug fixes."
89
+
90
+ # Additional notes about the update (list of strings)
91
+ # Set to None or empty list [] to disable
92
+ # Each item in the list will be displayed as a separate line
93
+ 'update_notes': None, # Example: ["- Fixed colormap preservation issue", "- Improved legend positioning"]
94
+
95
+ # Whether to show update notes if provided
96
+ # Set to False to hide update notes even if they are defined
97
+ 'show_update_notes': True,
98
+ }
99
+
100
+ # ====================================================================================
101
+ # END OF UPDATE INFO CONFIGURATION
102
+ # ====================================================================================
103
+
56
104
 
57
105
  def get_cache_file() -> Path:
58
106
  """Get the path to the version check cache file."""
@@ -160,12 +208,43 @@ def _print_update_message(current: str, latest: str) -> None:
160
208
  current: Current version
161
209
  latest: Latest available version
162
210
  """
163
- print(f"\n\033[93m╭{'─' * 68}╮\033[0m")
164
- print(f"\033[93m│\033[0m \033[1mA new version of batplot is available!\033[0m" + " " * 31 + "\033[93m│\033[0m")
165
- print(f"\033[93m│\033[0m Current: \033[91m{current}\033[0m → Latest: \033[92m{latest}\033[0m" + " " * (42 - len(current) - len(latest)) + "\033[93m│\033[0m")
166
- print(f"\033[93m│\033[0m Update with: \033[96mpip install --upgrade batplot\033[0m" + " " * 20 + "\033[93m│\033[0m")
167
- print(f"\033[93m│\033[0m To disable this check: \033[96mexport BATPLOT_NO_VERSION_CHECK=1\033[0m" + " " * 7 + "\033[93m│\033[0m")
168
- print(f"\033[93m╰{'─' * 68}╯\033[0m\n")
211
+ # Calculate box width (minimum 68, expand if needed for longer messages)
212
+ box_width = 68
213
+ custom_msg = UPDATE_INFO.get('custom_message')
214
+ update_notes = UPDATE_INFO.get('update_notes')
215
+ show_notes = UPDATE_INFO.get('show_update_notes', True)
216
+
217
+ # Calculate required width based on content
218
+ max_line_len = 68 # Default minimum width
219
+ if custom_msg:
220
+ max_line_len = max(max_line_len, len(custom_msg) + 4)
221
+ if update_notes and show_notes:
222
+ for note in update_notes:
223
+ max_line_len = max(max_line_len, len(note) + 4)
224
+ # Ensure box width is at least the calculated width
225
+ box_width = max(68, min(max_line_len, 100)) # Cap at 100 for readability
226
+
227
+ print(f"\n\033[93m╭{'─' * box_width}╮\033[0m")
228
+ print(f"\033[93m│\033[0m \033[1mA new version of batplot is available!\033[0m" + " " * max(0, box_width - 34) + "\033[93m│\033[0m")
229
+ print(f"\033[93m│\033[0m Current: \033[91m{current}\033[0m → Latest: \033[92m{latest}\033[0m" + " " * max(0, box_width - 20 - len(current) - len(latest)) + "\033[93m│\033[0m")
230
+
231
+ # Add custom message if provided
232
+ if custom_msg and custom_msg.strip():
233
+ # Truncate if too long to fit in box
234
+ msg = custom_msg[:box_width - 6] if len(custom_msg) > box_width - 6 else custom_msg
235
+ print(f"\033[93m│\033[0m {msg}" + " " * max(0, box_width - len(msg) - 4) + "\033[93m│\033[0m")
236
+
237
+ # Add update notes if provided
238
+ if update_notes and show_notes and isinstance(update_notes, list):
239
+ for note in update_notes:
240
+ if note and note.strip():
241
+ # Truncate if too long to fit in box
242
+ note_text = note[:box_width - 6] if len(note) > box_width - 6 else note
243
+ print(f"\033[93m│\033[0m {note_text}" + " " * max(0, box_width - len(note_text) - 4) + "\033[93m│\033[0m")
244
+
245
+ print(f"\033[93m│\033[0m Update with: \033[96mpip install --upgrade batplot\033[0m" + " " * max(0, box_width - 34) + "\033[93m│\033[0m")
246
+ print(f"\033[93m│\033[0m To disable this check: \033[96mexport BATPLOT_NO_VERSION_CHECK=1\033[0m" + " " * max(0, box_width - 45) + "\033[93m│\033[0m")
247
+ print(f"\033[93m╰{'─' * box_width}╯\033[0m\n")
169
248
 
170
249
 
171
250
  if __name__ == '__main__':
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batplot
3
- Version: 1.8.0
3
+ Version: 1.8.1
4
4
  Summary: Interactive plotting tool for material science (1D plot) and electrochemistry (GC, CV, dQ/dV, CPC, operando) with batch processing
5
5
  Author-email: Tian Dai <tianda@uio.no>
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "batplot"
7
- version = "1.8.0"
7
+ version = "1.8.1"
8
8
  description = "Interactive plotting tool for material science (1D plot) and electrochemistry (GC, CV, dQ/dV, CPC, operando) with batch processing"
9
9
  authors = [
10
10
  { name = "Tian Dai", email = "tianda@uio.no" }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes