batplot 1.4.0__tar.gz → 1.4.2__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 (32) hide show
  1. {batplot-1.4.0 → batplot-1.4.2}/PKG-INFO +1 -1
  2. {batplot-1.4.0 → batplot-1.4.2}/batplot/args.py +18 -8
  3. {batplot-1.4.0 → batplot-1.4.2}/batplot/batch.py +1 -1
  4. {batplot-1.4.0 → batplot-1.4.2}/batplot/batplot.py +61 -20
  5. {batplot-1.4.0 → batplot-1.4.2}/batplot/cli.py +4 -0
  6. {batplot-1.4.0 → batplot-1.4.2}/batplot/cpc_interactive.py +65 -15
  7. {batplot-1.4.0 → batplot-1.4.2}/batplot/electrochem_interactive.py +122 -22
  8. {batplot-1.4.0 → batplot-1.4.2}/batplot/interactive.py +148 -45
  9. {batplot-1.4.0 → batplot-1.4.2}/batplot/operando_ec_interactive.py +76 -20
  10. {batplot-1.4.0 → batplot-1.4.2}/batplot/session.py +2 -0
  11. {batplot-1.4.0 → batplot-1.4.2}/batplot/style.py +11 -0
  12. {batplot-1.4.0 → batplot-1.4.2}/batplot.egg-info/PKG-INFO +1 -1
  13. {batplot-1.4.0 → batplot-1.4.2}/pyproject.toml +1 -1
  14. {batplot-1.4.0 → batplot-1.4.2}/LICENSE +0 -0
  15. {batplot-1.4.0 → batplot-1.4.2}/README.md +0 -0
  16. {batplot-1.4.0 → batplot-1.4.2}/batplot/__init__.py +0 -0
  17. {batplot-1.4.0 → batplot-1.4.2}/batplot/batplot_new.py +0 -0
  18. {batplot-1.4.0 → batplot-1.4.2}/batplot/cif.py +0 -0
  19. {batplot-1.4.0 → batplot-1.4.2}/batplot/converters.py +0 -0
  20. {batplot-1.4.0 → batplot-1.4.2}/batplot/modes.py +0 -0
  21. {batplot-1.4.0 → batplot-1.4.2}/batplot/operando.py +0 -0
  22. {batplot-1.4.0 → batplot-1.4.2}/batplot/plotting.py +0 -0
  23. {batplot-1.4.0 → batplot-1.4.2}/batplot/readers.py +0 -0
  24. {batplot-1.4.0 → batplot-1.4.2}/batplot/ui.py +0 -0
  25. {batplot-1.4.0 → batplot-1.4.2}/batplot/utils.py +0 -0
  26. {batplot-1.4.0 → batplot-1.4.2}/batplot.egg-info/SOURCES.txt +0 -0
  27. {batplot-1.4.0 → batplot-1.4.2}/batplot.egg-info/dependency_links.txt +0 -0
  28. {batplot-1.4.0 → batplot-1.4.2}/batplot.egg-info/entry_points.txt +0 -0
  29. {batplot-1.4.0 → batplot-1.4.2}/batplot.egg-info/requires.txt +0 -0
  30. {batplot-1.4.0 → batplot-1.4.2}/batplot.egg-info/top_level.txt +0 -0
  31. {batplot-1.4.0 → batplot-1.4.2}/setup.cfg +0 -0
  32. {batplot-1.4.0 → batplot-1.4.2}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batplot
3
- Version: 1.4.0
3
+ Version: 1.4.2
4
4
  Summary: Interactive plotting for XRD, PDF, and XAS data (.xye, .xy, .qye, .dat, .csv, .gr, .nor, .chik, .chir)
5
5
  Author-email: Tian Dai <tianda@uio.no>
6
6
  License: MIT License
@@ -104,14 +104,16 @@ def _print_general_help() -> None:
104
104
  " • Batch styling: apply .bps/.bpsg files to all exports (use --all flag)\n"
105
105
  " • Format option: use --format png/pdf/jpg/etc to change export format\n\n"
106
106
 
107
- "More help:\n"
108
- " batplot -h xy # XY file plotting guide\n"
109
- " batplot -h ec # Electrochemistry (GC/dQdV/CV/CPC) guide\n"
110
- " batplot -h op # Operando guide\n\n"
111
- "Contact & Updates:\n"
112
- " Subscribe to batplot-lab@kjemi.uio.no for updates and feedback\n"
113
- " GitHub: https://github.com/tiandai-chem/batplot\n"
114
- " Email: tianda@uio.no\n"
107
+ "More help:\n"
108
+ " batplot -h xy # XY file plotting guide\n"
109
+ " batplot -h ec # Electrochemistry (GC/dQdV/CV/CPC) guide\n"
110
+ " batplot -h op # Operando guide\n"
111
+ " Manual: https://github.com/tiandai-chem/batplot/blob/main/USER_MANUAL.md\n\n"
112
+ "Contact & Updates:\n"
113
+ " Subscribe to batplot-lab@kjemi.uio.no for updates\n"
114
+ " (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"
115
+ " GitHub: https://github.com/tiandai-chem/batplot\n"
116
+ " Email: tianda@uio.no\n"
115
117
  )
116
118
  _print_help(msg)
117
119
 
@@ -143,6 +145,10 @@ def _print_xy_help() -> None:
143
145
  " --interactive : open interactive menu for styling, ranges, fonts, export, sessions\n"
144
146
  " --delta/-d <float> : spacing between curves, e.g. --delta 0.1\n"
145
147
  " --norm : normalize intensity to 0-1 range. Stack mode (--stack) auto-normalizes\n"
148
+ " --chik : EXAFS χ(k) plot (sets labels to k (Å⁻¹) vs χ(k))\n"
149
+ " --kchik : multiply y by x for EXAFS kχ(k) plots (sets labels to k (Å⁻¹) vs kχ(k) (Å⁻¹))\n"
150
+ " --k2chik : multiply y by x² for EXAFS k²χ(k) plots (sets labels to k (Å⁻¹) vs k²χ(k) (Å⁻²))\n"
151
+ " --k3chik : multiply y by x³ for EXAFS k³χ(k) plots (sets labels to k (Å⁻¹) vs k³χ(k) (Å⁻³))\n"
146
152
  " --xrange/-r <min> <max> : set x-axis range, e.g. --xrange 0 10\n"
147
153
  " --out/-o <filename> : save figure to file, e.g. --out file.svg\n"
148
154
  " --xaxis <type> : set x-axis type (Q, 2theta, r, k, energy, rft, time, or user defined)\n"
@@ -234,6 +240,10 @@ def build_parser() -> argparse.ArgumentParser:
234
240
  parser.add_argument("--wl", type=float, help=argparse.SUPPRESS)
235
241
  parser.add_argument("--fullprof", nargs="+", type=float, help=argparse.SUPPRESS)
236
242
  parser.add_argument("--norm", action="store_true", help=argparse.SUPPRESS)
243
+ parser.add_argument("--chik", action="store_true", help=argparse.SUPPRESS)
244
+ parser.add_argument("--kchik", action="store_true", help=argparse.SUPPRESS)
245
+ parser.add_argument("--k2chik", action="store_true", help=argparse.SUPPRESS)
246
+ parser.add_argument("--k3chik", action="store_true", help=argparse.SUPPRESS)
237
247
  parser.add_argument("--interactive", action="store_true", help=argparse.SUPPRESS)
238
248
  parser.add_argument("--savefig", type=str, help=argparse.SUPPRESS)
239
249
  parser.add_argument("--stack", action="store_true", help=argparse.SUPPRESS)
@@ -285,7 +285,7 @@ def batch_process(directory: str, args):
285
285
  args._batch_warned_extensions.add(ext)
286
286
  print(f" Note: Reading '{ext}' files as 2-column (x, y) data with x-axis = {args.xaxis}")
287
287
  else:
288
- raise ValueError(f"Cannot determine X-axis type for file {fname} (need .qye/.gr/.nor/.chik/.chir or --xaxis).")
288
+ raise ValueError(f"Unknown file type: {fname}. Use --xaxis [Q|2theta|r|k|energy|rft] or batplot -h for help.")
289
289
 
290
290
  # Convert to Q if needed
291
291
  if axis_mode == 'Q' and ext not in ('.qye', '.gr', '.nor'):
@@ -1587,6 +1587,12 @@ def batplot_main() -> int:
1587
1587
  fig._stack_label_at_bottom = stack_label_at_bottom
1588
1588
  except Exception:
1589
1589
  pass
1590
+ # Restore grid state
1591
+ try:
1592
+ grid_state = bool(sess.get('grid', False))
1593
+ ax.grid(grid_state, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
1594
+ except Exception:
1595
+ pass
1590
1596
  # CIF tick series (optional)
1591
1597
  cif_tick_series = sess.get('cif_tick_series') or []
1592
1598
  cif_hkl_map = {k: [tuple(v) for v in val] for k,val in sess.get('cif_hkl_map', {}).items()}
@@ -1879,7 +1885,7 @@ def batplot_main() -> int:
1879
1885
  if args.xaxis:
1880
1886
  axis_mode = args.xaxis
1881
1887
  else:
1882
- raise ValueError("Cannot determine X-axis type for .txt files. Please specify --xaxis (Q, 2theta, r, k, energy, rft, or 'user defined').")
1888
+ raise ValueError("Unknown file type. Use: batplot file.txt --xaxis [Q|2theta|r|k|energy|rft] or batplot -h for help.")
1883
1889
  elif any_lambda or any_cif:
1884
1890
  if args.xaxis and args.xaxis.lower() in ("2theta","two_theta","tth"):
1885
1891
  axis_mode = "2theta"
@@ -1890,7 +1896,7 @@ def batplot_main() -> int:
1890
1896
  elif args.xaxis:
1891
1897
  axis_mode = args.xaxis
1892
1898
  else:
1893
- raise ValueError("Cannot determine X-axis type (need .qye / .gr / .nor / .chik / .chir / .cif / wavelength / --xaxis). For .txt or unknown file types, use --xaxis Q, 2theta, r, k, energy, rft, or 'user defined'.")
1899
+ raise ValueError("Unknown file type. Use: batplot file.csv --xaxis [Q|2theta|r|k|energy|rft] or batplot -h for help.")
1894
1900
 
1895
1901
  use_Q = axis_mode == "Q"
1896
1902
  use_2th = axis_mode == "2theta"
@@ -2125,6 +2131,24 @@ def batplot_main() -> int:
2125
2131
  else:
2126
2132
  x_plot = x_full
2127
2133
 
2134
+ # ---- Apply EXAFS k-weighting transformation if requested ----
2135
+ if getattr(args, 'k3chik', False):
2136
+ # Multiply y by x³ for EXAFS k³χ(k) plots
2137
+ y_plot = y_plot * (x_plot ** 3)
2138
+ y_full_raw = y_full_raw * (x_full ** 3)
2139
+ raw_y_full_list[-1] = y_full_raw
2140
+ elif getattr(args, 'k2chik', False):
2141
+ # Multiply y by x² for EXAFS k²χ(k) plots
2142
+ y_plot = y_plot * (x_plot ** 2)
2143
+ y_full_raw = y_full_raw * (x_full ** 2)
2144
+ raw_y_full_list[-1] = y_full_raw
2145
+ elif getattr(args, 'kchik', False):
2146
+ # Multiply y by x for EXAFS kχ(k) plots
2147
+ y_plot = y_plot * x_plot
2148
+ y_full_raw = y_full_raw * x_full
2149
+ raw_y_full_list[-1] = y_full_raw
2150
+ # elif getattr(args, 'chik', False): no multiplication needed, just label change
2151
+
2128
2152
  # ---- Normalize (display subset) ----
2129
2153
  # Auto-normalize for --stack mode, or explicit --norm flag
2130
2154
  should_normalize = args.stack or getattr(args, 'norm', False)
@@ -2499,26 +2523,43 @@ def batplot_main() -> int:
2499
2523
  ax._cif_extend_func = extend_cif_tick_series
2500
2524
  ax._cif_draw_func = draw_cif_ticks
2501
2525
 
2502
- if use_E: x_label = "Energy (eV)"
2503
- elif use_r: x_label = r"r (Å)"
2504
- elif use_k: x_label = r"k ($\mathrm{\AA}^{-1}$)"
2505
- elif use_rft: x_label = "Radial distance (Å)"
2506
- elif use_Q: x_label = r"Q ($\mathrm{\AA}^{-1}$)"
2507
- elif use_2th: x_label = r"$2\theta$ (deg)"
2508
- elif use_time: x_label = "Time (h)"
2509
- elif args.xaxis:
2510
- x_label = str(args.xaxis)
2526
+ # Handle EXAFS k-weighted χ(k) mode labels
2527
+ if getattr(args, 'k3chik', False):
2528
+ x_label = r"k ($\mathrm{\AA}^{-1}$)"
2529
+ y_label = r"k$^3$χ(k) ($\mathrm{\AA}^{-3}$)"
2530
+ elif getattr(args, 'k2chik', False):
2531
+ x_label = r"k ($\mathrm{\AA}^{-1}$)"
2532
+ y_label = r"k$^2$χ(k) ($\mathrm{\AA}^{-2}$)"
2533
+ elif getattr(args, 'kchik', False):
2534
+ x_label = r"k ($\mathrm{\AA}^{-1}$)"
2535
+ y_label = r"kχ(k) ($\mathrm{\AA}^{-1}$)"
2536
+ elif getattr(args, 'chik', False):
2537
+ x_label = r"k ($\mathrm{\AA}^{-1}$)"
2538
+ y_label = r"χ(k)"
2511
2539
  else:
2512
- x_label = "X"
2540
+ if use_E: x_label = "Energy (eV)"
2541
+ elif use_r: x_label = r"r (Å)"
2542
+ elif use_k: x_label = r"k ($\mathrm{\AA}^{-1}$)"
2543
+ elif use_rft: x_label = "Radial distance (Å)"
2544
+ elif use_Q: x_label = r"Q ($\mathrm{\AA}^{-1}$)"
2545
+ elif use_2th: x_label = r"$2\theta$ (deg)"
2546
+ elif use_time: x_label = "Time (h)"
2547
+ elif args.xaxis:
2548
+ x_label = str(args.xaxis)
2549
+ else:
2550
+ x_label = "X"
2551
+
2552
+ # Y-axis label: normalized if --stack or --norm, or voltage for time mode
2553
+ should_normalize = args.stack or getattr(args, 'norm', False)
2554
+ if use_time:
2555
+ y_label = "Voltage (V)"
2556
+ elif should_normalize:
2557
+ y_label = "Normalized intensity (a.u.)"
2558
+ else:
2559
+ y_label = "Intensity"
2560
+
2513
2561
  ax.set_xlabel(x_label, fontsize=16)
2514
- # Y-axis label: normalized if --stack or --norm, or voltage for time mode
2515
- should_normalize = args.stack or getattr(args, 'norm', False)
2516
- if use_time:
2517
- ax.set_ylabel("Voltage (V)", fontsize=16)
2518
- elif should_normalize:
2519
- ax.set_ylabel("Normalized intensity (a.u.)", fontsize=16)
2520
- else:
2521
- ax.set_ylabel("Intensity", fontsize=16)
2562
+ ax.set_ylabel(y_label, fontsize=16)
2522
2563
 
2523
2564
  # Store originals for axis-title toggle restoration (t menu bn/ln)
2524
2565
  try:
@@ -27,6 +27,10 @@ def main(argv: Optional[list] = None) -> int:
27
27
  # Import the main batplot function (now refactored to be safe)
28
28
  from .batplot import batplot_main
29
29
  return batplot_main()
30
+ except ValueError as e:
31
+ # Print clean error message without traceback
32
+ print(f"Error: {e}", file=sys.stderr)
33
+ return 1
30
34
  finally:
31
35
  if argv is not None:
32
36
  sys.argv = old_argv
@@ -23,6 +23,52 @@ from .ui import (
23
23
  from .utils import _confirm_overwrite
24
24
 
25
25
 
26
+ def _colorize_menu(text):
27
+ """Colorize menu items: command in cyan, colon in white, description in default."""
28
+ if ':' not in text:
29
+ return text
30
+ parts = text.split(':', 1)
31
+ cmd = parts[0].strip()
32
+ desc = parts[1].strip() if len(parts) > 1 else ''
33
+ return f"\033[96m{cmd}\033[0m: {desc}" # Cyan for command, default for description
34
+
35
+
36
+ def _colorize_prompt(text):
37
+ """Colorize commands within input prompts. Handles formats like (s=size, f=family, q=return) or (y/n)."""
38
+ import re
39
+ pattern = r'\(([a-z]+=[^,)]+(?:,\s*[a-z]+=[^,)]+)*|[a-z]+(?:/[a-z]+)+)\)'
40
+
41
+ def colorize_match(match):
42
+ content = match.group(1)
43
+ if '/' in content:
44
+ parts = content.split('/')
45
+ colored_parts = [f"\033[96m{p.strip()}\033[0m" for p in parts]
46
+ return f"({'/'.join(colored_parts)})"
47
+ else:
48
+ parts = content.split(',')
49
+ colored_parts = []
50
+ for part in parts:
51
+ part = part.strip()
52
+ if '=' in part:
53
+ cmd, desc = part.split('=', 1)
54
+ colored_parts.append(f"\033[96m{cmd.strip()}\033[0m={desc.strip()}")
55
+ else:
56
+ colored_parts.append(part)
57
+ return f"({', '.join(colored_parts)})"
58
+
59
+ return re.sub(pattern, colorize_match, text)
60
+
61
+
62
+ def _colorize_inline_commands(text):
63
+ """Colorize inline command examples in help text. Colors quoted examples and specific known commands."""
64
+ import re
65
+ # Color quoted command examples (like 's2 w5 a4', 'w2 w5')
66
+ text = re.sub(r"'([a-z0-9\s_-]+)'", lambda m: f"'\033[96m{m.group(1)}\033[0m'", text)
67
+ # Color specific known commands: q, i, l, list, help, all
68
+ text = re.sub(r'\b(q|i|l|list|help|all)\b(?=\s*[=,]|\s*$)', lambda m: f"\033[96m{m.group(1)}\033[0m", text)
69
+ return text
70
+
71
+
26
72
  def _generate_similar_color(base_color):
27
73
  """Generate a similar but distinguishable color for discharge from charge color."""
28
74
  try:
@@ -83,13 +129,17 @@ def _print_menu():
83
129
  w2 = max(18, *(len(s) for s in col2))
84
130
  w3 = max(12, *(len(s) for s in col3))
85
131
  rows = max(len(col1), len(col2), len(col3))
86
- print("\nCPC interactive menu:")
87
- print(f" {'(Styles)':<{w1}} {'(Geometries)':<{w2}} {'(Options)':<{w3}}")
132
+ print("\n\033[1mCPC interactive menu:\033[0m") # Bold title
133
+ print(f" \033[93m{'(Styles)':<{w1}}\033[0m \033[93m{'(Geometries)':<{w2}}\033[0m \033[93m{'(Options)':<{w3}}\033[0m") # Yellow headers
88
134
  for i in range(rows):
89
- p1 = col1[i] if i < len(col1) else ""
90
- p2 = col2[i] if i < len(col2) else ""
91
- p3 = col3[i] if i < len(col3) else ""
92
- print(f" {p1:<{w1}} {p2:<{w2}} {p3:<{w3}}")
135
+ p1 = _colorize_menu(col1[i]) if i < len(col1) else ""
136
+ p2 = _colorize_menu(col2[i]) if i < len(col2) else ""
137
+ p3 = _colorize_menu(col3[i]) if i < len(col3) else ""
138
+ # Add padding to account for ANSI escape codes
139
+ pad1 = w1 + (9 if i < len(col1) else 0)
140
+ pad2 = w2 + (9 if i < len(col2) else 0)
141
+ pad3 = w3 + (9 if i < len(col3) else 0)
142
+ print(f" {p1:<{pad1}} {p2:<{pad2}} {p3:<{pad3}}")
93
143
 
94
144
 
95
145
  def _get_current_file_artists(file_data, current_idx):
@@ -859,7 +909,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
859
909
 
860
910
  if key == 'q':
861
911
  try:
862
- confirm = input("Quit CPC interactive? Remember to save! Quit now? (y/n): ").strip().lower()
912
+ confirm = input(_colorize_prompt("Quit CPC interactive? Remember to save! Quit now? (y/n): ")).strip().lower()
863
913
  except Exception:
864
914
  confirm = 'y'
865
915
  if confirm == 'y':
@@ -1155,9 +1205,9 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1155
1205
  # Spine colors (w=top, a=left, s=bottom, d=right)
1156
1206
  try:
1157
1207
  print("Set spine colors (with matching tick and label colors):")
1158
- print(" w : top spine | a : left spine")
1159
- print(" s : bottom spine | d : right spine")
1160
- print("Example: w:red a:#4561F7 s:blue d:green")
1208
+ print(_colorize_inline_commands(" w : top spine | a : left spine"))
1209
+ print(_colorize_inline_commands(" s : bottom spine | d : right spine"))
1210
+ print(_colorize_inline_commands("Example: w:red a:#4561F7 s:blue d:green"))
1161
1211
  line = input("Enter mappings (e.g., w:red a:#4561F7) or q: ").strip()
1162
1212
  if line and line.lower() != 'q':
1163
1213
  push_state("color-spine")
@@ -1457,7 +1507,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1457
1507
  print(f"Existing style files in Styles/:")
1458
1508
  for i, f in enumerate(files, 1):
1459
1509
  print(f" {i}: {f}")
1460
- sub = input("Style submenu: (e=export, q=return): ").strip().lower()
1510
+ sub = input(_colorize_prompt("Style submenu: (e=export, q=return): ")).strip().lower()
1461
1511
  if sub == 'e':
1462
1512
  choice = input("Enter new filename or number to overwrite (q=cancel): ").strip()
1463
1513
  if not choice or choice.lower() == 'q':
@@ -2141,10 +2191,10 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2141
2191
  print(f" left a1:{b(wasd['left']['spine'])} a2:{b(wasd['left']['ticks'])} a3:{b(wasd['left']['minor'])} a4:{b(wasd['left']['labels'])} a5:{b(wasd['left']['title'])}")
2142
2192
  print(f" right d1:{b(wasd['right']['spine'])} d2:{b(wasd['right']['ticks'])} d3:{b(wasd['right']['minor'])} d4:{b(wasd['right']['labels'])} d5:{b(wasd['right']['title'])}")
2143
2193
 
2144
- print("WASD toggles: direction (w/a/s/d) x action (1..5)")
2145
- print(" 1=spine 2=ticks 3=minor ticks 4=tick labels 5=axis title")
2146
- print("Examples: 'w2 w5' to toggle top ticks and top title; 'd2 d5' for right.")
2147
- print("Type 'i' to invert tick direction, 'l' to change tick length, 'list' to show current state, 'q' to go back.")
2194
+ print(_colorize_inline_commands("WASD toggles: direction (w/a/s/d) x action (1..5)"))
2195
+ print(_colorize_inline_commands(" 1=spine 2=ticks 3=minor ticks 4=tick labels 5=axis title"))
2196
+ print(_colorize_inline_commands("Examples: 'w2 w5' to toggle top ticks and top title; 'd2 d5' for right."))
2197
+ print(_colorize_inline_commands("Type 'i' to invert tick direction, 'l' to change tick length, 'list' to show current state, 'q' to go back."))
2148
2198
  while True:
2149
2199
  cmd = input("t> ").strip().lower()
2150
2200
  if not cmd:
@@ -25,6 +25,52 @@ from .plotting import update_labels as _update_labels
25
25
  from .utils import _confirm_overwrite
26
26
 
27
27
 
28
+ def _colorize_menu(text):
29
+ """Colorize menu items: command in cyan, colon in white, description in default."""
30
+ if ':' not in text:
31
+ return text
32
+ parts = text.split(':', 1)
33
+ cmd = parts[0].strip()
34
+ desc = parts[1].strip() if len(parts) > 1 else ''
35
+ return f"\033[96m{cmd}\033[0m: {desc}" # Cyan for command, default for description
36
+
37
+
38
+ def _colorize_prompt(text):
39
+ """Colorize commands within input prompts. Handles formats like (s=size, f=family, q=return) or (y/n)."""
40
+ import re
41
+ pattern = r'\(([a-z]+=[^,)]+(?:,\s*[a-z]+=[^,)]+)*|[a-z]+(?:/[a-z]+)+)\)'
42
+
43
+ def colorize_match(match):
44
+ content = match.group(1)
45
+ if '/' in content:
46
+ parts = content.split('/')
47
+ colored_parts = [f"\033[96m{p.strip()}\033[0m" for p in parts]
48
+ return f"({'/'.join(colored_parts)})"
49
+ else:
50
+ parts = content.split(',')
51
+ colored_parts = []
52
+ for part in parts:
53
+ part = part.strip()
54
+ if '=' in part:
55
+ cmd, desc = part.split('=', 1)
56
+ colored_parts.append(f"\033[96m{cmd.strip()}\033[0m={desc.strip()}")
57
+ else:
58
+ colored_parts.append(part)
59
+ return f"({', '.join(colored_parts)})"
60
+
61
+ return re.sub(pattern, colorize_match, text)
62
+
63
+
64
+ def _colorize_inline_commands(text):
65
+ """Colorize inline command examples in help text. Colors quoted examples and specific known commands."""
66
+ import re
67
+ # Color quoted command examples (like 's2 w5 a4', 'w2 w5')
68
+ text = re.sub(r"'([a-z0-9\s_-]+)'", lambda m: f"'\033[96m{m.group(1)}\033[0m'", text)
69
+ # Color specific known commands: q, i, l, list, help, all
70
+ text = re.sub(r'\b(q|i|l|list|help|all)\b(?=\s*[=,]|\s*$)', lambda m: f"\033[96m{m.group(1)}\033[0m", text)
71
+ return text
72
+
73
+
28
74
  def _print_menu(n_cycles: int, is_dqdv: bool = False):
29
75
  # Three-column menu similar to operando: Styles | Geometries | Options
30
76
  # Use dynamic column widths for clean alignment.
@@ -60,13 +106,17 @@ def _print_menu(n_cycles: int, is_dqdv: bool = False):
60
106
  w2 = max(len("(Geometries)"), *(len(s) for s in col2), 12)
61
107
  w3 = max(len("(Options)"), *(len(s) for s in col3), 12)
62
108
  rows = max(len(col1), len(col2), len(col3))
63
- print("\nInteractive menu:")
64
- print(f" {'(Styles)':<{w1}} {'(Geometries)':<{w2}} {'(Options)':<{w3}}")
109
+ print("\n\033[1mInteractive menu:\033[0m") # Bold title
110
+ print(f" \033[93m{'(Styles)':<{w1}}\033[0m \033[93m{'(Geometries)':<{w2}}\033[0m \033[93m{'(Options)':<{w3}}\033[0m") # Yellow headers
65
111
  for i in range(rows):
66
- p1 = col1[i] if i < len(col1) else ""
67
- p2 = col2[i] if i < len(col2) else ""
68
- p3 = col3[i] if i < len(col3) else ""
69
- print(f" {p1:<{w1}} {p2:<{w2}} {p3:<{w3}}")
112
+ p1 = _colorize_menu(col1[i]) if i < len(col1) else ""
113
+ p2 = _colorize_menu(col2[i]) if i < len(col2) else ""
114
+ p3 = _colorize_menu(col3[i]) if i < len(col3) else ""
115
+ # Add padding to account for ANSI escape codes
116
+ pad1 = w1 + (9 if i < len(col1) else 0)
117
+ pad2 = w2 + (9 if i < len(col2) else 0)
118
+ pad3 = w3 + (9 if i < len(col3) else 0)
119
+ print(f" {p1:<{pad1}} {p2:<{pad2}} {p3:<{pad3}}")
70
120
 
71
121
 
72
122
  def _iter_cycle_lines(cycle_lines: Dict[int, Dict[str, Optional[object]]]):
@@ -863,7 +913,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
863
913
  continue
864
914
  if key == 'q':
865
915
  try:
866
- confirm = input("Quit EC interactive? Remember to save (e=export, s=save). Quit now? (y/n): ").strip().lower()
916
+ confirm = input(_colorize_prompt("Quit EC interactive? Remember to save (e=export, s=save). Quit now? (y/n): ")).strip().lower()
867
917
  except Exception:
868
918
  confirm = 'y'
869
919
  if confirm == 'y':
@@ -1020,7 +1070,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1020
1070
  xy_in = getattr(fig, '_ec_legend_xy_in', (0.0, 0.0))
1021
1071
  print(f"Legend is {'ON' if vis else 'off'}; position (inches from center): x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
1022
1072
  while True:
1023
- sub = input("Legend: t=toggle, m=set position (x y inches), q=back: ").strip().lower()
1073
+ sub = input(_colorize_prompt("Legend: (t=toggle, m=set position, q=back): ")).strip().lower()
1024
1074
  if not sub:
1025
1075
  continue
1026
1076
  if sub == 'q':
@@ -1387,13 +1437,15 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1387
1437
  print(f" Tick widths: xM={x_maj if x_maj is not None else '?'} xm={x_min if x_min is not None else '?'} yM={y_maj if y_maj is not None else '?'} ym={y_min if y_min is not None else '?'}")
1388
1438
  if cur_curve_lw is not None:
1389
1439
  print(f" Curves (all): {cur_curve_lw:.3g}")
1390
- print("Line submenu:")
1391
- print(" c : change curve line widths")
1392
- print(" f : change frame (axes spines) and tick widths")
1393
- print(" ld : show line and dots (markers) for all curves")
1394
- print(" d : show only dots (no connecting line) for all curves")
1395
- print(" q : return")
1396
- sub = input("Choose (c/f/ld/d/q): ").strip().lower()
1440
+ print("\033[1mLine submenu:\033[0m")
1441
+ print(f" {_colorize_menu('c : change curve line widths')}")
1442
+ print(f" {_colorize_menu('f : change frame (axes spines) and tick widths')}")
1443
+ print(f" {_colorize_menu('g : toggle grid lines')}")
1444
+ print(f" {_colorize_menu('l : show only lines (no markers) for all curves')}")
1445
+ print(f" {_colorize_menu('ld : show line and dots (markers) for all curves')}")
1446
+ print(f" {_colorize_menu('d : show only dots (no connecting line) for all curves')}")
1447
+ print(f" {_colorize_menu('q : return')}")
1448
+ sub = input(_colorize_prompt("Choose (c/f/g/l/ld/d/q): ")).strip().lower()
1397
1449
  if not sub:
1398
1450
  continue
1399
1451
  if sub == 'q':
@@ -1451,6 +1503,54 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1451
1503
  print(f"Set frame width={frame_w}, major tick width={tick_major}, minor tick width={tick_minor}")
1452
1504
  except ValueError:
1453
1505
  print("Invalid numeric value(s).")
1506
+ elif sub == 'g':
1507
+ push_state("grid")
1508
+ # Toggle grid state - check if any gridlines are visible
1509
+ current_grid = False
1510
+ try:
1511
+ # Check if grid is currently on by looking at gridline visibility
1512
+ for line in ax.get_xgridlines() + ax.get_ygridlines():
1513
+ if line.get_visible():
1514
+ current_grid = True
1515
+ break
1516
+ except Exception:
1517
+ current_grid = ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else False
1518
+
1519
+ new_grid_state = not current_grid
1520
+ if new_grid_state:
1521
+ # Enable grid with light styling
1522
+ ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
1523
+ else:
1524
+ # Disable grid (no style parameters when disabling)
1525
+ ax.grid(False)
1526
+ fig.canvas.draw()
1527
+ print(f"Grid {'enabled' if new_grid_state else 'disabled'}.")
1528
+ elif sub == 'l':
1529
+ # Line-only mode: set linestyle to solid and remove markers
1530
+ push_state("line-only")
1531
+ for cyc, role, ln in _iter_cycle_lines(cycle_lines):
1532
+ try:
1533
+ # Check if already in line-only mode (has line style and no marker)
1534
+ current_ls = ln.get_linestyle()
1535
+ current_marker = ln.get_marker()
1536
+ # If already line-only (has line, no marker), skip
1537
+ if current_ls not in ['None', '', ' ', 'none'] and current_marker in ['None', '', ' ', 'none', None]:
1538
+ continue
1539
+ # Otherwise, set to line-only
1540
+ ln.set_linestyle('-')
1541
+ ln.set_marker('None')
1542
+ except Exception:
1543
+ pass
1544
+ try:
1545
+ _rebuild_legend(ax)
1546
+ fig.canvas.draw()
1547
+ except Exception:
1548
+ try:
1549
+ _rebuild_legend(ax)
1550
+ except Exception:
1551
+ pass
1552
+ fig.canvas.draw_idle()
1553
+ print("Applied line-only style to all curves.")
1454
1554
  elif sub == 'ld':
1455
1555
  # Line + dots for all curves
1456
1556
  push_state("line+dots")
@@ -1521,9 +1621,9 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1521
1621
  # Spine colors (w=top, a=left, s=bottom, d=right)
1522
1622
  try:
1523
1623
  print("Set spine colors (with matching tick and label colors):")
1524
- print(" w : top spine | a : left spine")
1525
- print(" s : bottom spine | d : right spine")
1526
- print("Example: w:red a:#4561F7 s:blue d:green")
1624
+ print(_colorize_inline_commands(" w : top spine | a : left spine"))
1625
+ print(_colorize_inline_commands(" s : bottom spine | d : right spine"))
1626
+ print(_colorize_inline_commands("Example: w:red a:#4561F7 s:blue d:green"))
1527
1627
  line = input("Enter mappings (e.g., w:red a:#4561F7) or q: ").strip()
1528
1628
  if line and line.lower() != 'q':
1529
1629
  push_state("color-spine")
@@ -1845,10 +1945,10 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1845
1945
  elif key == 'c':
1846
1946
  print(f"Total cycles: {len(all_cycles)}")
1847
1947
  print("Enter one of:")
1848
- print(" - numbers: e.g. 1 5 10")
1849
- print(" - mappings: e.g. 1:red 5:#00B006 10:blue")
1850
- print(" - numbers + palette: e.g. 1 5 10 viridis OR 1 5 10 3")
1851
- print(" - all (optionally with palette): e.g. all OR all viridis OR all 3")
1948
+ print(_colorize_inline_commands(" - numbers: e.g. 1 5 10"))
1949
+ print(_colorize_inline_commands(" - mappings: e.g. 1:red 5:#00B006 10:blue"))
1950
+ print(_colorize_inline_commands(" - numbers + palette: e.g. 1 5 10 viridis OR 1 5 10 3"))
1951
+ print(_colorize_inline_commands(" - all (optionally with palette): e.g. all OR all viridis OR all 3"))
1852
1952
  print("\nRecommended palettes for scientific publications:")
1853
1953
  print(" 1. tab10 - Distinct, colorblind-friendly (default matplotlib)")
1854
1954
  print(" 2. Set2 - Soft, pastel colors for presentations")
@@ -99,6 +99,54 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
99
99
  if not hasattr(fig, '_stack_label_at_bottom'):
100
100
  fig._stack_label_at_bottom = False
101
101
 
102
+ # ANSI color codes for menu highlighting
103
+ def colorize_menu(text):
104
+ """Colorize menu items: command in cyan, colon in white, description in default."""
105
+ if ':' not in text:
106
+ return text
107
+ parts = text.split(':', 1)
108
+ cmd = parts[0].strip()
109
+ desc = parts[1].strip() if len(parts) > 1 else ''
110
+ return f"\033[96m{cmd}\033[0m: {desc}" # Cyan for command, default for description
111
+
112
+ def colorize_prompt(text):
113
+ """Colorize commands within input prompts. Handles formats like (s=size, f=family, q=return) or (y/n) or (q=cancel)."""
114
+ import re
115
+ # Pattern to match parenthesized command lists like (s=size, f=family, q=return) or (y/n) or (m/p/s/t/q) or (q=cancel)
116
+ pattern = r'\(([a-z]+=[^,)]+(?:,\s*[a-z]+=[^,)]+)*|[a-z]+(?:/[a-z]+)+)\)'
117
+
118
+ def colorize_match(match):
119
+ content = match.group(1)
120
+ # Check if it's slash-separated (like y/n or m/p/s/t/q)
121
+ if '/' in content:
122
+ parts = content.split('/')
123
+ colored_parts = [f"\033[96m{p.strip()}\033[0m" for p in parts]
124
+ return f"({'/'.join(colored_parts)})"
125
+ # Otherwise it's equals-separated (like s=size, f=family or q=cancel)
126
+ else:
127
+ parts = content.split(',')
128
+ colored_parts = []
129
+ for part in parts:
130
+ part = part.strip()
131
+ if '=' in part:
132
+ cmd, desc = part.split('=', 1)
133
+ colored_parts.append(f"\033[96m{cmd.strip()}\033[0m={desc.strip()}")
134
+ else:
135
+ colored_parts.append(part)
136
+ return f"({', '.join(colored_parts)})"
137
+
138
+ return re.sub(pattern, colorize_match, text)
139
+
140
+ def colorize_inline_commands(text):
141
+ """Colorize inline command examples in help text. Colors quoted examples and specific known commands."""
142
+ import re
143
+ # Color quoted command examples (like 's2 w5 a4', 'w2 w5', or 'all magma_r')
144
+ text = re.sub(r"'([a-z0-9\s_-]+)'", lambda m: f"'\033[96m{m.group(1)}\033[0m'", text)
145
+ # Color specific known single-letter commands: q, i, l, when they appear as standalone commands
146
+ # Pattern: word boundary + (q|i|l|list|help|all) + space/equals/comma/end
147
+ text = re.sub(r'\b(q|i|l|list|help|all)\b(?=\s*[=,]|\s*$)', lambda m: f"\033[96m{m.group(1)}\033[0m", text)
148
+ return text
149
+
102
150
  # REPLACED print_main_menu with column layout (now hides 'd' and 'y' in --stack)
103
151
  is_diffraction = use_Q or (not use_r and not use_E and not use_k and not use_rft) # 2θ or Q
104
152
  def print_main_menu():
@@ -121,18 +169,23 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
121
169
 
122
170
  if not is_diffraction:
123
171
  col3 = [item for item in col3 if not item.startswith("n:")]
124
- # Dynamic widths for cleaner alignment across terminals
172
+ # Dynamic widths for cleaner alignment across terminals (account for ANSI codes)
173
+ # Use plain text length for width calculations
125
174
  w1 = max(len("(Styles)"), *(len(s) for s in col1), 16)
126
175
  w2 = max(len("(Geometries)"), *(len(s) for s in col2), 16)
127
176
  w3 = max(len("(Options)"), *(len(s) for s in col3), 16)
128
177
  rows = max(len(col1), len(col2), len(col3))
129
- print("\nInteractive menu:")
130
- print(f" {'(Styles)':<{w1}} {'(Geometries)':<{w2}} {'(Options)':<{w3}}")
178
+ print("\n\033[1mInteractive menu:\033[0m") # Bold title
179
+ print(f" \033[93m{'(Styles)':<{w1}}\033[0m \033[93m{'(Geometries)':<{w2}}\033[0m \033[93m{'(Options)':<{w3}}\033[0m") # Yellow headers
131
180
  for i in range(rows):
132
- p1 = col1[i] if i < len(col1) else ""
133
- p2 = col2[i] if i < len(col2) else ""
134
- p3 = col3[i] if i < len(col3) else ""
135
- print(f" {p1:<{w1}} {p2:<{w2}} {p3:<{w3}}")
181
+ p1 = colorize_menu(col1[i]) if i < len(col1) else ""
182
+ p2 = colorize_menu(col2[i]) if i < len(col2) else ""
183
+ p3 = colorize_menu(col3[i]) if i < len(col3) else ""
184
+ # Add padding to account for ANSI escape codes (9 chars per colorized item)
185
+ pad1 = w1 + (9 if i < len(col1) else 0)
186
+ pad2 = w2 + (9 if i < len(col2) else 0)
187
+ pad3 = w3 + (9 if i < len(col3) else 0)
188
+ print(f" {p1:<{pad1}} {p2:<{pad2}} {p3:<{pad3}}")
136
189
 
137
190
  # --- Helper for spine visibility ---
138
191
  def set_spine_visible(which, visible):
@@ -690,7 +743,8 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
690
743
  "show_cif_hkl": (bool(getattr(_bp, 'show_cif_hkl')) if _bp is not None and hasattr(_bp, 'show_cif_hkl') else False),
691
744
  "show_cif_titles": (bool(getattr(_bp, 'show_cif_titles')) if _bp is not None and hasattr(_bp, 'show_cif_titles') else True),
692
745
  "rotation_angle": getattr(ax, '_rotation_angle', 0),
693
- "stack_label_at_bottom": getattr(fig, '_stack_label_at_bottom', False)
746
+ "stack_label_at_bottom": getattr(fig, '_stack_label_at_bottom', False),
747
+ "grid": ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else False
694
748
  }
695
749
  # Line + data arrays
696
750
  for i, ln in enumerate(ax.lines):
@@ -906,6 +960,16 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
906
960
  if 'stack_label_at_bottom' in snap:
907
961
  fig._stack_label_at_bottom = bool(snap['stack_label_at_bottom'])
908
962
 
963
+ # Restore grid state
964
+ if 'grid' in snap:
965
+ try:
966
+ if snap['grid']:
967
+ ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
968
+ else:
969
+ ax.grid(False)
970
+ except Exception:
971
+ pass
972
+
909
973
  # CIF tick sets & label visibility (write back to batplot module globals)
910
974
  if _bp is not None and snap.get("cif_tick_series") is not None and hasattr(_bp, 'cif_tick_series'):
911
975
  try:
@@ -960,7 +1024,7 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
960
1024
  continue
961
1025
 
962
1026
  if key == 'q':
963
- confirm = input("Quit interactive? Remember to save (e=export, s=save). Quit now? (y/n): ").strip().lower()
1027
+ confirm = input(colorize_prompt("Quit interactive? Remember to save (e=export, s=save). Quit now? (y/n): ")).strip().lower()
964
1028
  if confirm == 'y':
965
1029
  break
966
1030
  else:
@@ -995,11 +1059,11 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
995
1059
  elif key == 'h': # legend submenu
996
1060
  try:
997
1061
  while True:
998
- print("\nLegend submenu:")
999
- print(" v: show/hide curve names")
1062
+ print("\n\033[1mLegend submenu:\033[0m")
1063
+ print(f" {colorize_menu('v: show/hide curve names')}")
1000
1064
  current_pos = "bottom-right" if getattr(fig, '_stack_label_at_bottom', False) else "top-right"
1001
- print(f" s: legend position (current: {current_pos})")
1002
- print(" q: back to main menu")
1065
+ print(f" {colorize_menu(f's: legend position (current: {current_pos})')}")
1066
+ print(f" {colorize_menu('q: back to main menu')}")
1003
1067
  sub_key = input("Choose: ").strip().lower()
1004
1068
 
1005
1069
  if sub_key == 'q':
@@ -1136,14 +1200,14 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
1136
1200
  except Exception:
1137
1201
  pass
1138
1202
  while True:
1139
- print("Color menu:")
1140
- print(" m : manual color mapping (e.g., 1:red 2:#00B006)")
1141
- print(" p : apply colormap palette to a range (e.g., 1-3 viridis)")
1142
- print(" s : spine colors (e.g., w:red a:#4561F7 for top & left)")
1203
+ print("\033[1mColor menu:\033[0m")
1204
+ print(f" {colorize_menu('m : manual color mapping (e.g., 1:red 2:#00B006)')}")
1205
+ print(f" {colorize_menu('p : apply colormap palette to a range (e.g., 1-3 viridis)')}")
1206
+ print(f" {colorize_menu('s : spine colors (e.g., w:red a:#4561F7 for top & left)')}")
1143
1207
  if has_cif and (_bp is not None and getattr(_bp, 'cif_tick_series', None)):
1144
- print(" t : change CIF tick set color (e.g., 1:red 2:#888888)")
1145
- print(" q : return to main menu")
1146
- sub = input("Choose (m/p/s/t/q): ").strip().lower()
1208
+ print(f" {colorize_menu('t : change CIF tick set color (e.g., 1:red 2:#888888)')}")
1209
+ print(f" {colorize_menu('q : return to main menu')}")
1210
+ sub = input(colorize_prompt("Choose (m/p/s/t/q): ")).strip().lower()
1147
1211
  if sub == 'q':
1148
1212
  break
1149
1213
  if sub == '':
@@ -1177,9 +1241,9 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
1177
1241
  elif sub == 's':
1178
1242
  # Spine colors (w=top, a=left, s=bottom, d=right)
1179
1243
  print("Set spine colors (with matching tick and label colors):")
1180
- print(" w : top spine | a : left spine")
1181
- print(" s : bottom spine | d : right spine")
1182
- print("Example: w:red a:#4561F7 s:blue d:green")
1244
+ print(colorize_inline_commands(" w : top spine | a : left spine"))
1245
+ print(colorize_inline_commands(" s : bottom spine | d : right spine"))
1246
+ print(colorize_inline_commands("Example: w:red a:#4561F7 s:blue d:green"))
1183
1247
  line = input("Enter mappings (e.g., w:red a:#4561F7) or q: ").strip()
1184
1248
  if not line or line.lower() == 'q':
1185
1249
  print("Canceled.")
@@ -1252,7 +1316,7 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
1252
1316
  extras.append('batlowK')
1253
1317
  print("Common perceptually uniform palettes:")
1254
1318
  print(" " + ", ".join(base_palettes + extras[:2]))
1255
- print("Example: 1-4 viridis or: all magma_r or: 1-3,5 plasma, _r for reverse")
1319
+ print(colorize_inline_commands("Example: 1-4 viridis or: all magma_r or: 1-3,5 plasma, _r for reverse"))
1256
1320
  line = input("Enter range(s) and palette (e.g., '1-3 viridis') or q: ").strip()
1257
1321
  if not line or line.lower() == 'q':
1258
1322
  print("Canceled.")
@@ -1663,12 +1727,12 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
1663
1727
  except Exception as e:
1664
1728
  print(f"Error setting Y-axis range: {e}")
1665
1729
  elif key == 'd': # <-- DELTA / OFFSET HANDLER (now only reachable if not args.stack)
1666
- print("\nOffset adjustment menu:")
1667
- print(" 1-{}: adjust individual curve offset".format(len(labels)))
1668
- print(" a: adjust total offset (baseline shift)")
1669
- print(" r: reset all offsets to 0")
1670
- print(" d: change delta spacing (original behavior)")
1671
- print(" q: back to main menu")
1730
+ print("\n\033[1mOffset adjustment menu:\033[0m")
1731
+ print(f" {colorize_menu('1-{}: adjust individual curve offset'.format(len(labels)))}")
1732
+ print(f" {colorize_menu('a: adjust total offset (baseline shift)')}")
1733
+ print(f" {colorize_menu('r: reset all offsets to 0')}")
1734
+ print(f" {colorize_menu('d: change delta spacing (original behavior)')}")
1735
+ print(f" {colorize_menu('q: back to main menu')}")
1672
1736
 
1673
1737
  while True:
1674
1738
  offset_cmd = input("Offset> ").strip().lower()
@@ -1913,13 +1977,15 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
1913
1977
  elif key == 'l':
1914
1978
  try:
1915
1979
  while True:
1916
- print("Line submenu:")
1917
- print(" c : change curve line widths")
1918
- print(" f : change frame (axes spines) and tick widths")
1919
- print(" ld : show line and dots (markers) for all curves")
1920
- print(" d : show only dots (no connecting line) for all curves")
1921
- print(" q : return")
1922
- sub = input("Choose (c/f/ld/d/q): ").strip().lower()
1980
+ print("\033[1mLine submenu:\033[0m")
1981
+ print(f" {colorize_menu('c : change curve line widths')}")
1982
+ print(f" {colorize_menu('f : change frame (axes spines) and tick widths')}")
1983
+ print(f" {colorize_menu('g : toggle grid lines')}")
1984
+ print(f" {colorize_menu('l : show only lines (no markers) for all curves')}")
1985
+ print(f" {colorize_menu('ld : show line and dots (markers) for all curves')}")
1986
+ print(f" {colorize_menu('d : show only dots (no connecting line) for all curves')}")
1987
+ print(f" {colorize_menu('q : return')}")
1988
+ sub = input(colorize_prompt("Choose (c/f/g/l/ld/d/q): ")).strip().lower()
1923
1989
  if sub == 'q':
1924
1990
  break
1925
1991
  if sub == '':
@@ -1978,6 +2044,43 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
1978
2044
  print(f"Set frame width={frame_w}, major tick width={tick_major}, minor tick width={tick_minor}")
1979
2045
  except ValueError:
1980
2046
  print("Invalid numeric value(s).")
2047
+ elif sub == 'g':
2048
+ push_state("grid")
2049
+ # Toggle grid state - check if any gridlines are visible
2050
+ current_grid = False
2051
+ try:
2052
+ # Check if grid is currently on by looking at gridline visibility
2053
+ for line in ax.get_xgridlines() + ax.get_ygridlines():
2054
+ if line.get_visible():
2055
+ current_grid = True
2056
+ break
2057
+ except Exception:
2058
+ current_grid = ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else False
2059
+
2060
+ new_grid_state = not current_grid
2061
+ if new_grid_state:
2062
+ # Enable grid with light styling
2063
+ ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
2064
+ else:
2065
+ # Disable grid (no style parameters when disabling)
2066
+ ax.grid(False)
2067
+ fig.canvas.draw()
2068
+ print(f"Grid {'enabled' if new_grid_state else 'disabled'}.")
2069
+ elif sub == 'l':
2070
+ # Line-only mode: set linestyle to solid and remove markers
2071
+ push_state("line-only")
2072
+ for ln in ax.lines:
2073
+ # Check if already in line-only mode (has line style and no marker)
2074
+ current_ls = ln.get_linestyle()
2075
+ current_marker = ln.get_marker()
2076
+ # If already line-only (has line, no marker), skip
2077
+ if current_ls not in ['None', '', ' ', 'none'] and current_marker in ['None', '', ' ', 'none', None]:
2078
+ continue
2079
+ # Otherwise, set to line-only
2080
+ ln.set_linestyle('-')
2081
+ ln.set_marker('None')
2082
+ fig.canvas.draw()
2083
+ print("Applied line-only style to all curves.")
1981
2084
  elif sub == 'ld':
1982
2085
  push_state("line+dots")
1983
2086
  try:
@@ -2026,7 +2129,7 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
2026
2129
  print(f"Error setting widths: {e}")
2027
2130
  elif key == 'f':
2028
2131
  while True:
2029
- subkey = input("Font submenu (s=size, f=family, q=return): ").strip().lower()
2132
+ subkey = input(colorize_prompt("Font submenu (s=size, f=family, q=return): ")).strip().lower()
2030
2133
  if subkey == 'q':
2031
2134
  break
2032
2135
  if subkey == '':
@@ -2080,7 +2183,7 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
2080
2183
  elif key == 'g':
2081
2184
  try:
2082
2185
  while True:
2083
- choice = input("Resize submenu: (p=plot frame, c=canvas, q=cancel): ").strip().lower()
2186
+ choice = input(colorize_prompt("Resize submenu: (p=plot frame, c=canvas, q=cancel): ")).strip().lower()
2084
2187
  if not choice:
2085
2188
  continue
2086
2189
  if choice == 'q':
@@ -2157,11 +2260,11 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
2157
2260
  elif key == 't':
2158
2261
  try:
2159
2262
  while True:
2160
- print("Toggle help:")
2161
- print(" wasd choose side: w=top, a=left, s=bottom, d=right")
2162
- print(" 1..5 choose what: 1=spine line, 2=major ticks, 3=minor ticks, 4=labels, 5=axis title")
2163
- print(" Combine letter+number to toggle, e.g. 's2 w5 a4' (case-insensitive)")
2164
- print(" i = invert tick direction, l = change tick length, list = show state, q = return")
2263
+ print("\033[1mToggle help:\033[0m")
2264
+ print(colorize_inline_commands(" wasd choose side: w=top, a=left, s=bottom, d=right"))
2265
+ print(colorize_inline_commands(" 1..5 choose what: 1=spine line, 2=major ticks, 3=minor ticks, 4=labels, 5=axis title"))
2266
+ print(colorize_inline_commands(" Combine letter+number to toggle, e.g. 's2 w5 a4' (case-insensitive)"))
2267
+ print(colorize_inline_commands(" i = invert tick direction, l = change tick length, list = show state, q = return"))
2165
2268
  cmd = input("Enter code(s): ").strip().lower()
2166
2269
  if not cmd:
2167
2270
  continue
@@ -2464,7 +2567,7 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
2464
2567
  print("Existing style files in Styles/ (.bps/.bpsg):")
2465
2568
  for _i, _f in enumerate(_bpcfg_files, 1):
2466
2569
  print(f" {_i}: {_f}")
2467
- sub = input("Style submenu: (e=export, q=return, r=refresh): ").strip().lower()
2570
+ sub = input(colorize_prompt("Style submenu: (e=export, q=return, r=refresh): ")).strip().lower()
2468
2571
  if sub == 'q':
2469
2572
  break
2470
2573
  if sub == 'r' or sub == '':
@@ -28,6 +28,53 @@ from .ui import position_right_ylabel as _ui_position_right_ylabel
28
28
  from .ui import position_bottom_xlabel as _ui_position_bottom_xlabel
29
29
  from .ui import position_left_ylabel as _ui_position_left_ylabel
30
30
 
31
+
32
+ def _colorize_menu(text):
33
+ """Colorize menu items: command in cyan, colon in white, description in default."""
34
+ if ':' not in text:
35
+ return text
36
+ parts = text.split(':', 1)
37
+ cmd = parts[0].strip()
38
+ desc = parts[1].strip() if len(parts) > 1 else ''
39
+ return f"\033[96m{cmd}\033[0m: {desc}" # Cyan for command, default for description
40
+
41
+
42
+ def _colorize_prompt(text):
43
+ """Colorize commands within input prompts. Handles formats like (s=size, f=family, q=return) or (y/n)."""
44
+ import re
45
+ pattern = r'\(([a-z]+=[^,)]+(?:,\s*[a-z]+=[^,)]+)*|[a-z]+(?:/[a-z]+)+)\)'
46
+
47
+ def colorize_match(match):
48
+ content = match.group(1)
49
+ if '/' in content:
50
+ parts = content.split('/')
51
+ colored_parts = [f"\033[96m{p.strip()}\033[0m" for p in parts]
52
+ return f"({'/'.join(colored_parts)})"
53
+ else:
54
+ parts = content.split(',')
55
+ colored_parts = []
56
+ for part in parts:
57
+ part = part.strip()
58
+ if '=' in part:
59
+ cmd, desc = part.split('=', 1)
60
+ colored_parts.append(f"\033[96m{cmd.strip()}\033[0m={desc.strip()}")
61
+ else:
62
+ colored_parts.append(part)
63
+ return f"({', '.join(colored_parts)})"
64
+
65
+ return re.sub(pattern, colorize_match, text)
66
+
67
+
68
+ def _colorize_inline_commands(text):
69
+ """Colorize inline command examples in help text. Colors quoted examples and specific known commands."""
70
+ import re
71
+ # Color quoted command examples (like 's2 w5 a4', 'w2 w5')
72
+ text = re.sub(r"'([a-z0-9\s_-]+)'", lambda m: f"'\033[96m{m.group(1)}\033[0m'", text)
73
+ # Color specific known commands: q, i, l, list, help, all
74
+ text = re.sub(r'\b(q|i|l|list|help|all)\b(?=\s*[=,]|\s*$)', lambda m: f"\033[96m{m.group(1)}\033[0m", text)
75
+ return text
76
+
77
+
31
78
  # ============================================================================
32
79
  # Constants
33
80
  # ============================================================================
@@ -375,14 +422,19 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax):
375
422
  w3 = max(len("(EC)"), *(len(s) for s in col3), 14)
376
423
  w4 = max(len("(Options)"), *(len(s) for s in col4), 16)
377
424
  rows = max(len(col1), len(col2), len(col3), len(col4))
378
- print("\nInteractive menu:")
379
- print(f" {'(Styles)':<{w1}} {'(Operando)':<{w2}} {'(EC)':<{w3}} {'(Options)':<{w4}}")
425
+ print("\n\033[1mInteractive menu:\033[0m") # Bold title
426
+ print(f" \033[93m{'(Styles)':<{w1}}\033[0m \033[93m{'(Operando)':<{w2}}\033[0m \033[93m{'(EC)':<{w3}}\033[0m \033[93m{'(Options)':<{w4}}\033[0m") # Yellow headers
380
427
  for i in range(rows):
381
- p1 = col1[i] if i < len(col1) else ""
382
- p2 = col2[i] if i < len(col2) else ""
383
- p3 = col3[i] if i < len(col3) else ""
384
- p4 = col4[i] if i < len(col4) else ""
385
- print(f" {p1:<{w1}} {p2:<{w2}} {p3:<{w3}} {p4:<{w4}}")
428
+ p1 = _colorize_menu(col1[i]) if i < len(col1) else ""
429
+ p2 = _colorize_menu(col2[i]) if i < len(col2) else ""
430
+ p3 = _colorize_menu(col3[i]) if i < len(col3) else ""
431
+ p4 = _colorize_menu(col4[i]) if i < len(col4) else ""
432
+ # Add padding to account for ANSI escape codes
433
+ pad1 = w1 + (9 if i < len(col1) else 0)
434
+ pad2 = w2 + (9 if i < len(col2) else 0)
435
+ pad3 = w3 + (9 if i < len(col3) else 0)
436
+ pad4 = w4 + (9 if i < len(col4) else 0)
437
+ print(f" {p1:<{pad1}} {p2:<{pad2}} {p3:<{pad3}} {p4:<{pad4}}")
386
438
  else:
387
439
  # Operando-only menu (no EC panel)
388
440
  col1 = [
@@ -415,13 +467,17 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax):
415
467
  w2 = max(len("(Operando)"), *(len(s) for s in col2), 14)
416
468
  w3 = max(len("(Options)"), *(len(s) for s in col3), 16)
417
469
  rows = max(len(col1), len(col2), len(col3))
418
- print("\nInteractive menu:")
419
- print(f" {'(Styles)':<{w1}} {'(Operando)':<{w2}} {'(Options)':<{w3}}")
470
+ print("\n\033[1mInteractive menu:\033[0m") # Bold title
471
+ print(f" \033[93m{'(Styles)':<{w1}}\033[0m \033[93m{'(Operando)':<{w2}}\033[0m \033[93m{'(Options)':<{w3}}\033[0m") # Yellow headers
420
472
  for i in range(rows):
421
- p1 = col1[i] if i < len(col1) else ""
422
- p2 = col2[i] if i < len(col2) else ""
423
- p3 = col3[i] if i < len(col3) else ""
424
- print(f" {p1:<{w1}} {p2:<{w2}} {p3:<{w3}}")
473
+ p1 = _colorize_menu(col1[i]) if i < len(col1) else ""
474
+ p2 = _colorize_menu(col2[i]) if i < len(col2) else ""
475
+ p3 = _colorize_menu(col3[i]) if i < len(col3) else ""
476
+ # Add padding to account for ANSI escape codes
477
+ pad1 = w1 + (9 if i < len(col1) else 0)
478
+ pad2 = w2 + (9 if i < len(col2) else 0)
479
+ pad3 = w3 + (9 if i < len(col3) else 0)
480
+ print(f" {p1:<{pad1}} {p2:<{pad2}} {p3:<{pad3}}")
425
481
  print()
426
482
  def print_menu_old():
427
483
  col1 = [
@@ -1615,11 +1671,11 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax):
1615
1671
  elif cmd == 'l':
1616
1672
  # Line widths submenu for both operando and EC panes
1617
1673
  print("Line widths: set frame (spines) and tick widths for both operando and EC")
1618
- print("Enter frame/tick width (e.g., '1.5' or 'f t' for frame/tick separately)")
1674
+ print(_colorize_inline_commands("Enter frame/tick width (e.g., '1.5' or 'f t' for frame/tick separately)"))
1619
1675
  print("Format examples:")
1620
- print(" 1.5 - set both frame and ticks to 1.5")
1621
- print(" 1.5 2.5 - set frame=1.5, ticks=2.5")
1622
- print(" q - cancel")
1676
+ print(_colorize_inline_commands(" 1.5 - set both frame and ticks to 1.5"))
1677
+ print(_colorize_inline_commands(" 1.5 2.5 - set frame=1.5, ticks=2.5"))
1678
+ print(_colorize_inline_commands(" q - cancel"))
1623
1679
 
1624
1680
  inp = input("Line widths> ").strip().lower()
1625
1681
  if not inp or inp == 'q':
@@ -1756,9 +1812,9 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax):
1756
1812
  pass
1757
1813
  while True:
1758
1814
  if ec_ax is not None:
1759
- print("Choose pane: o=operando, e=ec, q=back")
1815
+ print(_colorize_inline_commands("Choose pane: o=operando, e=ec, q=back"))
1760
1816
  else:
1761
- print("Choose pane: o=operando, q=back")
1817
+ print(_colorize_inline_commands("Choose pane: o=operando, q=back"))
1762
1818
  pane = input("ot> ").strip().lower()
1763
1819
  if not pane:
1764
1820
  continue
@@ -2288,7 +2344,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax):
2288
2344
  print(" 4. cividis - Perceptually uniform, optimized for color vision deficiency")
2289
2345
  print(" 5. magma - Perceptually uniform (black→white), excellent for grayscale")
2290
2346
  print("\nOther available: " + ", ".join(base + extras))
2291
- print("Append _r to reverse (e.g., viridis_r or 1_r). Blank to cancel.")
2347
+ print(_colorize_inline_commands("Append _r to reverse (e.g., viridis_r or 1_r). Blank to cancel."))
2292
2348
  choice = input("Palette name or number (1-5): ").strip()
2293
2349
  if not choice:
2294
2350
  print_menu(); continue
@@ -231,6 +231,8 @@ def dump_session(
231
231
  sess['curve_names_visible'] = bool(getattr(fig, '_curve_names_visible', True))
232
232
  # Save stack label position preference
233
233
  sess['stack_label_at_bottom'] = bool(getattr(fig, '_stack_label_at_bottom', False))
234
+ # Save grid state
235
+ sess['grid'] = ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else False
234
236
  if skip_confirm:
235
237
  target = filename
236
238
  else:
@@ -286,6 +286,7 @@ def export_style_config(
286
286
  "x": ax.xaxis.label.get_color(),
287
287
  "y": ax.yaxis.label.get_color(),
288
288
  },
289
+ "grid": ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else False,
289
290
  "lines": [
290
291
  {
291
292
  "index": i,
@@ -777,6 +778,16 @@ def apply_style_config(
777
778
  except Exception as e:
778
779
  print(f"Warning: Could not restore rotation angle: {e}")
779
780
 
781
+ # Restore grid state
782
+ if "grid" in cfg:
783
+ try:
784
+ if bool(cfg["grid"]):
785
+ ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
786
+ else:
787
+ ax.grid(False)
788
+ except Exception as e:
789
+ print(f"Warning: Could not restore grid state: {e}")
790
+
780
791
  # Re-run label placement with current mode (no mode changes via Styles)
781
792
  stack_label_bottom = getattr(fig, '_stack_label_at_bottom', False)
782
793
  update_labels_func(ax, y_data_list, label_text_objects, args.stack, stack_label_bottom)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batplot
3
- Version: 1.4.0
3
+ Version: 1.4.2
4
4
  Summary: Interactive plotting for XRD, PDF, and XAS data (.xye, .xy, .qye, .dat, .csv, .gr, .nor, .chik, .chir)
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.4.0"
7
+ version = "1.4.2"
8
8
  description = "Interactive plotting for XRD, PDF, and XAS data (.xye, .xy, .qye, .dat, .csv, .gr, .nor, .chik, .chir)"
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