batplot 1.8.1__py3-none-any.whl → 1.8.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of batplot might be problematic. Click here for more details.

Files changed (38) hide show
  1. batplot/__init__.py +1 -1
  2. batplot/args.py +2 -0
  3. batplot/batplot.py +44 -4
  4. batplot/cpc_interactive.py +10 -0
  5. batplot/interactive.py +10 -0
  6. batplot/modes.py +12 -12
  7. batplot/operando_ec_interactive.py +4 -4
  8. batplot/session.py +17 -0
  9. batplot/version_check.py +1 -1
  10. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/METADATA +1 -1
  11. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/RECORD +38 -15
  12. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/top_level.txt +1 -0
  13. batplot_backup_20251221_101150/__init__.py +5 -0
  14. batplot_backup_20251221_101150/args.py +625 -0
  15. batplot_backup_20251221_101150/batch.py +1176 -0
  16. batplot_backup_20251221_101150/batplot.py +3589 -0
  17. batplot_backup_20251221_101150/cif.py +823 -0
  18. batplot_backup_20251221_101150/cli.py +149 -0
  19. batplot_backup_20251221_101150/color_utils.py +547 -0
  20. batplot_backup_20251221_101150/config.py +198 -0
  21. batplot_backup_20251221_101150/converters.py +204 -0
  22. batplot_backup_20251221_101150/cpc_interactive.py +4409 -0
  23. batplot_backup_20251221_101150/electrochem_interactive.py +4520 -0
  24. batplot_backup_20251221_101150/interactive.py +3894 -0
  25. batplot_backup_20251221_101150/manual.py +323 -0
  26. batplot_backup_20251221_101150/modes.py +799 -0
  27. batplot_backup_20251221_101150/operando.py +603 -0
  28. batplot_backup_20251221_101150/operando_ec_interactive.py +5487 -0
  29. batplot_backup_20251221_101150/plotting.py +228 -0
  30. batplot_backup_20251221_101150/readers.py +2607 -0
  31. batplot_backup_20251221_101150/session.py +2951 -0
  32. batplot_backup_20251221_101150/style.py +1441 -0
  33. batplot_backup_20251221_101150/ui.py +790 -0
  34. batplot_backup_20251221_101150/utils.py +1046 -0
  35. batplot_backup_20251221_101150/version_check.py +253 -0
  36. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/WHEEL +0 -0
  37. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/entry_points.txt +0 -0
  38. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1046 @@
1
+ """Utility helpers for batplot.
2
+
3
+ This module provides file organization and text formatting utilities used
4
+ throughout batplot. It handles cross-platform file dialogs, directory management,
5
+ and text formatting.
6
+
7
+ MAIN FUNCTIONS:
8
+ --------------
9
+ 1. **Directory Management**: Create and manage subdirectories (e.g., Figures/)
10
+ for organized output when batch processing files.
11
+
12
+ 2. **File Dialogs**: Cross-platform folder/file picker dialogs:
13
+ - macOS: Uses AppleScript (native, no dependencies)
14
+ - Windows/Linux: Uses tkinter (if available)
15
+ - Linux fallback: Uses zenity/kdialog (if available)
16
+
17
+ 3. **Text Normalization**: Format labels for matplotlib rendering:
18
+ - Handles LaTeX/mathtext syntax
19
+ - Escapes special characters
20
+ - Ensures proper rendering in plots
21
+
22
+ 4. **Overwrite Protection**: Ask user before overwriting existing files
23
+ (prevents accidental data loss).
24
+
25
+ CROSS-PLATFORM COMPATIBILITY:
26
+ ----------------------------
27
+ This module handles differences between operating systems:
28
+ - macOS: Uses AppleScript (avoids tkinter crashes)
29
+ - Windows: Uses tkinter (standard GUI library)
30
+ - Linux: Tries tkinter first, falls back to zenity/kdialog
31
+
32
+ All dialogs gracefully degrade: if GUI dialogs aren't available, functions
33
+ return None and calling code can fall back to manual input.
34
+ """
35
+
36
+ import os
37
+ import sys
38
+ import shutil
39
+ import subprocess
40
+ import time
41
+ from typing import Optional, List, Tuple
42
+
43
+
44
+ def _ask_directory_dialog(initialdir: Optional[str] = None) -> Optional[str]:
45
+ """Open a folder picker dialog with per-platform helpers.
46
+
47
+ On macOS it uses AppleScript (osascript), avoiding the fragile Tk backend.
48
+ On other platforms it tries tkinter first, then optional desktop helpers.
49
+ Returns ``None`` if the dialog isn't available or the user cancels.
50
+ """
51
+ initialdir = os.path.abspath(initialdir or os.getcwd())
52
+ if not os.path.isdir(initialdir):
53
+ initialdir = os.path.expanduser("~")
54
+
55
+ # macOS: ONLY use osascript dialog to avoid Tk crashes (never use tkinter on macOS)
56
+ if sys.platform.startswith("darwin"):
57
+ try:
58
+ path = _ask_directory_dialog_macos(initialdir)
59
+ # path will be None if user canceled, dialog failed, or path invalid
60
+ # This is expected behavior - will fall back to manual input
61
+ return path
62
+ except Exception:
63
+ # If AppleScript fails with an exception, return None (will fall back to manual input)
64
+ return None
65
+
66
+ # Windows/Linux: Try tkinter first
67
+ if not sys.platform.startswith("darwin"):
68
+ try:
69
+ path = _ask_directory_dialog_tk(initialdir)
70
+ if path:
71
+ return path
72
+ except Exception:
73
+ # If tkinter fails, continue to other methods
74
+ pass
75
+
76
+ # Linux desktop fallback via zenity/kdialog if available
77
+ if sys.platform.startswith("linux"):
78
+ try:
79
+ path = _ask_directory_dialog_zenity(initialdir)
80
+ if path:
81
+ return path
82
+ except Exception:
83
+ pass
84
+
85
+ return None
86
+
87
+
88
+ def _ask_directory_dialog_macos(initialdir: str) -> Optional[str]:
89
+ """Use AppleScript (osascript) to show the native folder picker on macOS.
90
+
91
+ Returns the selected folder path, or None if user cancels or if any error occurs.
92
+ """
93
+ if not shutil.which("osascript"):
94
+ return None
95
+
96
+ prompt = "Select a folder"
97
+ # Build AppleScript - use a single error handler to avoid syntax issues
98
+ # Error -128 is user cancel, which is expected behavior
99
+ if os.path.isdir(initialdir):
100
+ # Use a variable for the path to avoid quoting issues
101
+ script_parts = [
102
+ f'set initialPath to "{initialdir}"',
103
+ "try",
104
+ " set defaultLocation to POSIX file initialPath",
105
+ f' set theFolder to choose folder with prompt "{prompt}" default location defaultLocation',
106
+ " return POSIX path of theFolder",
107
+ "on error errMsg number errNum",
108
+ " if errNum is -128 then",
109
+ ' return ""',
110
+ " else",
111
+ ' return ""',
112
+ " end if",
113
+ "end try"
114
+ ]
115
+ script = "\n".join(script_parts)
116
+ else:
117
+ script_parts = [
118
+ "try",
119
+ f' set theFolder to choose folder with prompt "{prompt}"',
120
+ " return POSIX path of theFolder",
121
+ "on error errMsg number errNum",
122
+ " if errNum is -128 then",
123
+ ' return ""',
124
+ " else",
125
+ ' return ""',
126
+ " end if",
127
+ "end try"
128
+ ]
129
+ script = "\n".join(script_parts)
130
+
131
+ try:
132
+ # Run AppleScript - pass script via stdin instead of -e for better multi-line support
133
+ # The dialog should appear and block until user responds
134
+ res = subprocess.run(
135
+ ["osascript"],
136
+ input=script,
137
+ capture_output=True,
138
+ text=True,
139
+ check=False,
140
+ timeout=300, # 5 minute timeout (user might take time to navigate)
141
+ )
142
+
143
+ # Check return code
144
+ if res.returncode == 0:
145
+ selection = res.stdout.strip()
146
+ # Empty string means user canceled (error -128 was caught and returned "")
147
+ if not selection:
148
+ return None
149
+ # Validate that the selected path exists and is a directory
150
+ if os.path.isdir(selection):
151
+ return selection
152
+ # Path doesn't exist (shouldn't happen, but be safe)
153
+ return None
154
+ else:
155
+ # AppleScript returned an error code (non-zero)
156
+ # This could be a syntax error or other issue
157
+ # The dialog might not have appeared at all
158
+ # Return None to fall back to manual input
159
+ return None
160
+ except subprocess.TimeoutExpired:
161
+ # Dialog timed out (user took too long or dialog didn't appear)
162
+ return None
163
+ except FileNotFoundError:
164
+ # osascript not found (shouldn't happen since we check with shutil.which)
165
+ return None
166
+ except Exception:
167
+ # Any other error (permission issues, etc.)
168
+ return None
169
+
170
+
171
+ def _ask_directory_dialog_tk(initialdir: str) -> Optional[str]:
172
+ """Tkinter-based folder picker (Windows/Linux only - never used on macOS)."""
173
+ # Never use tkinter on macOS to avoid crashes
174
+ if sys.platform.startswith("darwin"):
175
+ return None
176
+
177
+ root = None
178
+ try:
179
+ import tkinter as tk
180
+ from tkinter import filedialog
181
+ except Exception:
182
+ return None
183
+
184
+ try:
185
+ root = tk.Tk()
186
+ root.withdraw()
187
+ # Suppress any window updates that might cause issues
188
+ root.update_idletasks()
189
+ try:
190
+ root.attributes('-topmost', True)
191
+ except Exception:
192
+ pass
193
+ folder = filedialog.askdirectory(
194
+ title="Select a folder",
195
+ initialdir=initialdir,
196
+ mustexist=False,
197
+ )
198
+ result = folder if folder else None
199
+ return result
200
+ except Exception as e:
201
+ # Silently fail - will fall back to manual input
202
+ return None
203
+ finally:
204
+ if root is not None:
205
+ try:
206
+ root.quit()
207
+ except Exception:
208
+ pass
209
+ try:
210
+ root.destroy()
211
+ except Exception:
212
+ pass
213
+
214
+
215
+ def _ask_directory_dialog_zenity(initialdir: str) -> Optional[str]:
216
+ """Use zenity/kdialog on Linux if available."""
217
+ cmd = None
218
+ if shutil.which("zenity"):
219
+ cmd = [
220
+ "zenity",
221
+ "--file-selection",
222
+ "--directory",
223
+ f"--filename={initialdir.rstrip(os.sep) + os.sep}",
224
+ "--title=Select a folder",
225
+ ]
226
+ elif shutil.which("kdialog"):
227
+ cmd = [
228
+ "kdialog",
229
+ "--getexistingdirectory",
230
+ initialdir,
231
+ "--title",
232
+ "Select a folder",
233
+ ]
234
+ if not cmd:
235
+ return None
236
+ try:
237
+ res = subprocess.run(cmd, capture_output=True, text=True, check=False)
238
+ if res.returncode == 0:
239
+ selection = res.stdout.strip()
240
+ return selection or None
241
+ except Exception:
242
+ pass
243
+ return None
244
+
245
+
246
+ def _ask_file_dialog(initialdir: Optional[str] = None, filetypes: Optional[Tuple[str, ...]] = None) -> Optional[str]:
247
+ """Open a platform-aware file picker dialog."""
248
+ initialdir = os.path.abspath(initialdir or os.getcwd())
249
+ if not os.path.isdir(initialdir):
250
+ initialdir = os.path.expanduser("~")
251
+
252
+ # macOS: use AppleScript for file selection
253
+ if sys.platform.startswith("darwin"):
254
+ return _ask_file_dialog_macos(initialdir)
255
+
256
+ # Windows/Linux: try tkinter first
257
+ if not sys.platform.startswith("darwin"):
258
+ try:
259
+ path = _ask_file_dialog_tk(initialdir, filetypes=filetypes)
260
+ if path:
261
+ return path
262
+ except Exception:
263
+ pass
264
+
265
+ # Linux desktop fallback via zenity/kdialog if available
266
+ if sys.platform.startswith("linux"):
267
+ try:
268
+ path = _ask_file_dialog_zenity(initialdir, filetypes=filetypes)
269
+ if path:
270
+ return path
271
+ except Exception:
272
+ pass
273
+
274
+ return None
275
+
276
+
277
+ def _ask_file_dialog_macos(initialdir: str) -> Optional[str]:
278
+ if not shutil.which("osascript"):
279
+ return None
280
+
281
+ script_parts = [
282
+ f'set initialPath to "{initialdir}"',
283
+ "try",
284
+ " set defaultLocation to POSIX file initialPath",
285
+ ' set theFile to choose file with prompt "Select a style file" default location defaultLocation',
286
+ " return POSIX path of theFile",
287
+ "on error errMsg number errNum",
288
+ " if errNum is -128 then",
289
+ ' return ""',
290
+ " else",
291
+ ' return ""',
292
+ " end if",
293
+ "end try"
294
+ ]
295
+ script = "\n".join(script_parts)
296
+ try:
297
+ res = subprocess.run(
298
+ ["osascript"],
299
+ input=script,
300
+ capture_output=True,
301
+ text=True,
302
+ check=False,
303
+ timeout=300,
304
+ )
305
+ if res.returncode == 0:
306
+ selection = res.stdout.strip()
307
+ if selection and os.path.isfile(selection):
308
+ return selection
309
+ return None
310
+ return None
311
+ except Exception:
312
+ return None
313
+
314
+
315
+ def _ask_file_dialog_tk(initialdir: str, filetypes: Optional[Tuple[str, ...]] = None) -> Optional[str]:
316
+ if sys.platform.startswith("darwin"):
317
+ return None
318
+ root = None
319
+ try:
320
+ import tkinter as tk
321
+ from tkinter import filedialog
322
+ except Exception:
323
+ return None
324
+ try:
325
+ root = tk.Tk()
326
+ root.withdraw()
327
+ root.update_idletasks()
328
+ try:
329
+ root.attributes('-topmost', True)
330
+ except Exception:
331
+ pass
332
+ tk_filetypes = [("All files", "*.*")]
333
+ if filetypes:
334
+ patterns = " ".join(f"*{ext}" if ext.startswith('.') else f"*.{ext}" for ext in filetypes)
335
+ tk_filetypes.insert(0, ("Style files", patterns))
336
+ file_path = filedialog.askopenfilename(
337
+ title="Select a style file",
338
+ initialdir=initialdir,
339
+ filetypes=tk_filetypes,
340
+ )
341
+ return file_path or None
342
+ except Exception:
343
+ return None
344
+ finally:
345
+ if root is not None:
346
+ try:
347
+ root.quit()
348
+ except Exception:
349
+ pass
350
+ try:
351
+ root.destroy()
352
+ except Exception:
353
+ pass
354
+
355
+
356
+ def _ask_file_dialog_zenity(initialdir: str, filetypes: Optional[Tuple[str, ...]] = None) -> Optional[str]:
357
+ cmd = None
358
+ if shutil.which("zenity"):
359
+ filename_arg = f"--filename={os.path.join(initialdir.rstrip(os.sep), '')}"
360
+ zenity_cmd = [
361
+ "zenity",
362
+ "--file-selection",
363
+ "--title=Select a style file",
364
+ filename_arg,
365
+ ]
366
+ if filetypes:
367
+ patterns = " ".join(f"*{ext}" if ext.startswith('.') else f"*.{ext}" for ext in filetypes)
368
+ zenity_cmd.append(f"--file-filter=Style files | {patterns}")
369
+ cmd = zenity_cmd
370
+ elif shutil.which("kdialog"):
371
+ pattern = " ".join(f"*{ext}" if ext.startswith('.') else f"*.{ext}" for ext in (filetypes or ()))
372
+ if not pattern:
373
+ pattern = "*"
374
+ cmd = [
375
+ "kdialog",
376
+ "--getopenfilename",
377
+ initialdir,
378
+ pattern,
379
+ "--title",
380
+ "Select a style file",
381
+ ]
382
+ if cmd is None:
383
+ return None
384
+ try:
385
+ res = subprocess.run(cmd, capture_output=True, text=True, check=False)
386
+ if res.returncode == 0:
387
+ selection = res.stdout.strip()
388
+ if selection and os.path.isfile(selection):
389
+ return selection
390
+ return None
391
+ except Exception:
392
+ return None
393
+
394
+
395
+ def ensure_subdirectory(subdir_name: str, base_path: str = None) -> str:
396
+ """Ensure subdirectory exists and return its path.
397
+
398
+ Creates a subdirectory if it doesn't exist. Used to organize output files
399
+ into Figures/, Styles/, and Projects/ folders.
400
+
401
+ Args:
402
+ subdir_name: Name of subdirectory ('Figures', 'Styles', or 'Projects')
403
+ base_path: Base directory (defaults to current working directory)
404
+
405
+ Returns:
406
+ Full path to the subdirectory (or base_path if creation fails)
407
+
408
+ Example:
409
+ >>> ensure_subdirectory('Figures', '/home/user/data')
410
+ '/home/user/data/Figures'
411
+ """
412
+ # Use current directory if no base path specified
413
+ if base_path is None:
414
+ base_path = os.getcwd()
415
+
416
+ # Build full path to subdirectory
417
+ subdir_path = os.path.join(base_path, subdir_name)
418
+
419
+ # Create directory if it doesn't exist
420
+ # exist_ok=True prevents error if directory already exists
421
+ try:
422
+ os.makedirs(subdir_path, exist_ok=True)
423
+ except Exception as e:
424
+ # If creation fails (permissions, etc.), warn and fall back to base directory
425
+ print(f"Warning: Could not create {subdir_name} directory: {e}")
426
+ return base_path
427
+
428
+ return subdir_path
429
+
430
+
431
+ def get_organized_path(filename: str, file_type: str, base_path: str = None) -> str:
432
+ """Get the appropriate path for a file based on its type.
433
+
434
+ This function helps organize output files into subdirectories:
435
+ - Figures go into Figures/
436
+ - Styles go into Styles/
437
+ - Projects go into Projects/
438
+
439
+ If the filename already contains a directory path, it's used as-is.
440
+
441
+ Args:
442
+ filename: The filename (can include path like 'output/fig.svg')
443
+ file_type: 'figure', 'style', or 'project'
444
+ base_path: Base directory (defaults to current working directory)
445
+
446
+ Returns:
447
+ Full path with appropriate subdirectory
448
+
449
+ Example:
450
+ >>> get_organized_path('plot.svg', 'figure')
451
+ './Figures/plot.svg'
452
+ >>> get_organized_path('/tmp/plot.svg', 'figure')
453
+ '/tmp/plot.svg' # Already has path, use as-is
454
+ """
455
+ # If filename already has a directory component, respect user's choice
456
+ # os.path.dirname returns '' for bare filenames, non-empty for paths
457
+ if os.path.dirname(filename):
458
+ return filename
459
+
460
+ # Map file type to subdirectory name
461
+ subdir_map = {
462
+ 'figure': 'Figures',
463
+ 'style': 'Styles',
464
+ 'project': 'Projects'
465
+ }
466
+
467
+ subdir_name = subdir_map.get(file_type)
468
+ if not subdir_name:
469
+ # Unknown file type, just use current directory without subdirectory
470
+ if base_path is None:
471
+ base_path = os.getcwd()
472
+ return os.path.join(base_path, filename)
473
+
474
+ # Ensure subdirectory exists and get its path
475
+ subdir_path = ensure_subdirectory(subdir_name, base_path)
476
+ return os.path.join(subdir_path, filename)
477
+
478
+
479
+ STYLE_FILE_EXTENSIONS = ('.bps', '.bpsg', '.bpcfg')
480
+
481
+
482
+ def list_files_in_subdirectory(extensions: tuple, file_type: str, base_path: str = None) -> list:
483
+ """List files with given extensions in the appropriate subdirectory.
484
+
485
+ Used by interactive menus to show available files for import/load operations.
486
+ For example, listing all .json style files in Styles/ directory.
487
+
488
+ Args:
489
+ extensions: Tuple of file extensions (e.g., ('.svg', '.png', '.pdf'))
490
+ Case-insensitive matching
491
+ file_type: 'figure', 'style', or 'project' - determines which subdirectory
492
+ base_path: Base directory (defaults to current working directory)
493
+
494
+ Returns:
495
+ List of (filename, full_path) tuples sorted alphabetically by filename
496
+ Empty list if directory doesn't exist or can't be read
497
+
498
+ Example:
499
+ >>> list_files_in_subdirectory(('.json',), 'style')
500
+ [('mystyle.json', './Styles/mystyle.json'), ...]
501
+ """
502
+ if base_path is None:
503
+ base_path = os.getcwd()
504
+
505
+ # Map file type to subdirectory name (same as get_organized_path)
506
+ subdir_map = {
507
+ 'figure': 'Figures',
508
+ 'style': 'Styles',
509
+ 'project': 'Projects'
510
+ }
511
+
512
+ subdir_name = subdir_map.get(file_type)
513
+ if not subdir_name:
514
+ # Unknown type, list from current directory
515
+ folder = base_path
516
+ else:
517
+ # Build path to subdirectory
518
+ folder = os.path.join(base_path, subdir_name)
519
+ # Create directory if it doesn't exist (for first-time users)
520
+ try:
521
+ os.makedirs(folder, exist_ok=True)
522
+ except Exception:
523
+ # If creation fails, fall back to base directory
524
+ folder = base_path
525
+
526
+ # Scan directory for matching files
527
+ files = []
528
+ try:
529
+ all_files = os.listdir(folder)
530
+ for f in all_files:
531
+ # Case-insensitive extension matching
532
+ if f.lower().endswith(extensions):
533
+ files.append((f, os.path.join(folder, f)))
534
+ except Exception:
535
+ # If directory can't be read, return empty list
536
+ # Don't crash - user can still work without listing files
537
+ pass
538
+
539
+ # Sort alphabetically by filename for consistent display
540
+ return sorted(files, key=lambda x: x[0])
541
+
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
+
583
+ def normalize_label_text(text: str) -> str:
584
+ """Normalize axis label text for proper matplotlib rendering.
585
+
586
+ Converts various representations of superscripts and special characters
587
+ into matplotlib-compatible LaTeX format. Primarily handles Angstrom units
588
+ with inverse exponents (Å⁻¹ → Å$^{-1}$).
589
+
590
+ Args:
591
+ text: Raw label text that may contain Unicode or LaTeX notation
592
+
593
+ Returns:
594
+ Normalized text with proper matplotlib math mode formatting
595
+
596
+ Example:
597
+ >>> normalize_label_text("Q (Å⁻¹)")
598
+ "Q (Å$^{-1}$)"
599
+ """
600
+ if not text:
601
+ return text
602
+
603
+ # Convert Unicode superscript minus to LaTeX math mode
604
+ text = text.replace("Å⁻¹", "Å$^{-1}$")
605
+ # Handle various spacing variations
606
+ text = text.replace("Å ^-1", "Å$^{-1}$")
607
+ text = text.replace("Å^-1", "Å$^{-1}$")
608
+ # Handle LaTeX \AA command variations
609
+ text = text.replace(r"\AA⁻¹", r"\AA$^{-1}$")
610
+
611
+ return text
612
+
613
+
614
+ def _confirm_overwrite(path: str, auto_suffix: bool = True):
615
+ """Ask user before overwriting an existing file.
616
+
617
+ Provides three behaviors depending on context:
618
+ 1. File doesn't exist → return path as-is
619
+ 2. Interactive terminal → ask user for confirmation or alternative filename
620
+ 3. Non-interactive (pipe/script) → auto-append suffix to avoid overwrite
621
+
622
+ This prevents accidental data loss while allowing automation in scripts.
623
+
624
+ Args:
625
+ path: Full path to the file that might be overwritten
626
+ auto_suffix: If True, automatically add _1, _2, etc. in non-interactive mode
627
+ If False, return None to cancel in non-interactive mode
628
+
629
+ Returns:
630
+ - Path to use (original or modified)
631
+ - None to cancel the operation
632
+
633
+ Example:
634
+ >>> _confirm_overwrite('plot.svg')
635
+ # If file exists and user is interactive: prompts "Overwrite? [y/N]:"
636
+ # If file exists and running in script: returns 'plot_1.svg'
637
+ """
638
+ try:
639
+ # If file doesn't exist, no confirmation needed
640
+ if not os.path.exists(path):
641
+ return path
642
+
643
+ # Check if running in non-interactive context (pipe, script, background)
644
+ if not sys.stdin.isatty():
645
+ # Non-interactive: can't ask user, so auto-suffix or cancel
646
+ if not auto_suffix:
647
+ return None
648
+
649
+ # Generate unique filename by appending _1, _2, etc.
650
+ base, ext = os.path.splitext(path)
651
+ k = 1
652
+ new_path = f"{base}_{k}{ext}"
653
+ # Keep incrementing until we find an unused name (max 1000 to prevent infinite loop)
654
+ while os.path.exists(new_path) and k < 1000:
655
+ k += 1
656
+ new_path = f"{base}_{k}{ext}"
657
+ return new_path
658
+
659
+ # Interactive mode: ask user what to do
660
+ ans = input(f"File '{path}' exists. Overwrite? [y/N]: ").strip().lower()
661
+ if ans == 'y':
662
+ return path
663
+
664
+ # User said no, ask for alternative filename
665
+ alt = input("Enter new filename (blank=cancel): ").strip()
666
+ if not alt:
667
+ # User wants to cancel
668
+ return None
669
+
670
+ # If user didn't provide extension, copy from original
671
+ if not os.path.splitext(alt)[1] and os.path.splitext(path)[1]:
672
+ alt += os.path.splitext(path)[1]
673
+
674
+ # Check if alternative also exists
675
+ if os.path.exists(alt):
676
+ print("Chosen alternative also exists; action canceled.")
677
+ return None
678
+
679
+ return alt
680
+
681
+ except Exception:
682
+ # If anything goes wrong (KeyboardInterrupt, etc.), just use original path
683
+ # Better to risk overwrite than crash
684
+ return path
685
+
686
+
687
+ def choose_save_path(file_paths: list, purpose: str = "saving") -> Optional[str]:
688
+ """Prompt user to choose a base directory for saving artifacts.
689
+
690
+ Always shows the current working directory and every unique directory that
691
+ contains an input file. The user can pick from the numbered list or type a
692
+ custom path manually. Returning ``None`` indicates the caller should cancel
693
+ the pending save/export operation.
694
+
695
+ Args:
696
+ file_paths: List of file paths associated with the current figure/session.
697
+ Only existing files contribute directory options.
698
+ purpose: Short description used in prompts (e.g., "figure export").
699
+
700
+ Returns:
701
+ Absolute path chosen by the user, or ``None`` if the selection
702
+ was canceled. Defaults to the current working directory if the
703
+ user simply presses Enter.
704
+ """
705
+ try:
706
+ cwd = os.getcwd()
707
+ file_paths = file_paths or []
708
+
709
+ # Build ordered mapping of directories → input files originating there
710
+ dir_map = {}
711
+ for fpath in file_paths:
712
+ try:
713
+ if not fpath:
714
+ continue
715
+ abs_path = os.path.abspath(fpath)
716
+ if not os.path.exists(abs_path):
717
+ continue
718
+ fdir = os.path.dirname(abs_path)
719
+ if not fdir:
720
+ continue
721
+ dir_map.setdefault(fdir, [])
722
+ dir_map[fdir].append(os.path.basename(abs_path) or abs_path)
723
+ except Exception:
724
+ continue
725
+
726
+ cwd_files = dir_map.pop(cwd, [])
727
+ options = [{
728
+ 'path': cwd,
729
+ 'label': "Current directory (terminal)",
730
+ 'files': cwd_files,
731
+ }]
732
+ for dir_path, files in sorted(dir_map.items()):
733
+ options.append({
734
+ 'path': dir_path,
735
+ 'label': "Input file directory",
736
+ 'files': files,
737
+ })
738
+
739
+ print(f"\nSave location options for {purpose}:")
740
+ for idx, opt in enumerate(options, start=1):
741
+ extra = ""
742
+ if opt['files']:
743
+ preview = ", ".join(opt['files'][:2])
744
+ if len(opt['files']) > 2:
745
+ preview += ", ..."
746
+ extra = f" (input files: {preview})"
747
+ label = f"{opt['label']}: {opt['path']}"
748
+ print(f" {idx}. {label}{extra}")
749
+ print(" c. Custom path")
750
+ print(" q. Cancel (return to menu)")
751
+
752
+ max_choice = len(options)
753
+ while True:
754
+ try:
755
+ choice = input(f"Choose path for {purpose} (1-{max_choice}, Enter=1): ").strip()
756
+ except KeyboardInterrupt:
757
+ print("\nCanceled path selection.")
758
+ return None
759
+
760
+ if not choice:
761
+ return cwd
762
+
763
+ low = choice.lower()
764
+ if low == 'q':
765
+ print("Canceled path selection.")
766
+ return None
767
+ if low == 'c':
768
+ # Try to open folder picker dialog first
769
+ dialog_path = None
770
+ try:
771
+ dialog_path = _ask_directory_dialog(initialdir=cwd)
772
+ except Exception as e:
773
+ # Dialog failed - fall back to manual input
774
+ dialog_path = None
775
+
776
+ if dialog_path:
777
+ # User selected a folder via dialog
778
+ try:
779
+ os.makedirs(dialog_path, exist_ok=True)
780
+ return dialog_path
781
+ except Exception as e:
782
+ print(f"Could not use directory: {e}")
783
+ # Fall through to manual input
784
+
785
+ # Fallback to manual input if dialog unavailable or canceled
786
+ print("(Dialog unavailable or canceled, enter path manually)")
787
+ try:
788
+ manual = input("Enter directory path (q=cancel): ").strip()
789
+ except (KeyboardInterrupt, EOFError):
790
+ print("\nCanceled path selection.")
791
+ return None
792
+ if not manual or manual.lower() == 'q':
793
+ continue
794
+ manual_path = os.path.abspath(os.path.expanduser(manual))
795
+ try:
796
+ os.makedirs(manual_path, exist_ok=True)
797
+ except Exception as e:
798
+ print(f"Could not use directory: {e}")
799
+ continue
800
+ return manual_path
801
+ if choice.isdigit():
802
+ num = int(choice)
803
+ if 1 <= num <= max_choice:
804
+ return options[num - 1]['path']
805
+ print(f"Invalid number. Enter between 1 and {max_choice}.")
806
+ continue
807
+ # Treat any other input as a manual path entry
808
+ manual_path = os.path.abspath(os.path.expanduser(choice))
809
+ try:
810
+ os.makedirs(manual_path, exist_ok=True)
811
+ except Exception as e:
812
+ print(f"Could not use directory: {e}")
813
+ continue
814
+ return manual_path
815
+ except Exception as e:
816
+ print(f"Error in path selection: {e}. Using current directory.")
817
+ return os.getcwd()
818
+
819
+
820
+ def ensure_exact_case_filename(target_path: str) -> str:
821
+ """Ensure a file is saved with the exact case specified, even on case-insensitive filesystems.
822
+
823
+ This function handles case-insensitive filesystems (macOS, Windows) by ensuring that
824
+ if a file exists with different case, it is removed first so the new file can be created
825
+ with the exact case specified by the user.
826
+
827
+ On case-sensitive filesystems (Linux, Unix), this function is safe but has no effect
828
+ since files with different case are treated as different files.
829
+
830
+ Args:
831
+ target_path: The desired file path with exact case
832
+
833
+ Returns:
834
+ The same path (for compatibility)
835
+ """
836
+ folder = os.path.dirname(target_path)
837
+ desired_basename = os.path.basename(target_path)
838
+
839
+ if not folder or not desired_basename:
840
+ return target_path
841
+
842
+ try:
843
+ # Check if file already exists with exact case
844
+ if os.path.exists(target_path):
845
+ # Check if the actual filename on disk matches the desired case
846
+ existing_files = os.listdir(folder)
847
+ for existing_file in existing_files:
848
+ # If same name (case-insensitive) but different case, we need to fix it
849
+ if existing_file.lower() == desired_basename.lower() and existing_file != desired_basename:
850
+ existing_path = os.path.join(folder, existing_file)
851
+ # Delete the existing file with wrong case
852
+ # This is safe on case-insensitive filesystems and has no effect on case-sensitive ones
853
+ try:
854
+ if os.path.exists(existing_path):
855
+ os.remove(existing_path)
856
+ except Exception:
857
+ # Ignore errors (e.g., permission issues, file in use)
858
+ pass
859
+ break
860
+ except Exception:
861
+ # If we can't check/list the directory, just return the path as-is
862
+ # This is safe and ensures we don't break on permission errors
863
+ pass
864
+
865
+ return target_path
866
+
867
+
868
+ def _normalize_extension(ext: str) -> str:
869
+ if not ext:
870
+ return ext
871
+ ext = ext.strip().lower()
872
+ if not ext.startswith('.'):
873
+ ext = '.' + ext
874
+ return ext
875
+
876
+
877
+ def _has_valid_extension(filename: str, extensions: Tuple[str, ...]) -> bool:
878
+ name = filename.lower()
879
+ return any(name.endswith(ext) for ext in extensions)
880
+
881
+
882
+ def choose_style_file(file_paths: List[str], purpose: str = "style import", extensions: Optional[Tuple[str, ...]] = None) -> Optional[str]:
883
+ """Select a style file (.bps/.bpsg/.bpcfg) from known directories or via dialog."""
884
+ extensions = tuple(_normalize_extension(ext) for ext in (extensions or STYLE_FILE_EXTENSIONS))
885
+ if not extensions:
886
+ extensions = STYLE_FILE_EXTENSIONS
887
+
888
+ search_dirs: List[str] = []
889
+ seen_dirs = set()
890
+
891
+ def _add_dir(path: str):
892
+ if not path:
893
+ return
894
+ abs_path = os.path.abspath(path)
895
+ if abs_path in seen_dirs:
896
+ return
897
+ if os.path.isdir(abs_path):
898
+ seen_dirs.add(abs_path)
899
+ search_dirs.append(abs_path)
900
+
901
+ _add_dir(os.getcwd())
902
+ for fpath in file_paths or []:
903
+ try:
904
+ if not fpath:
905
+ continue
906
+ abs_path = os.path.abspath(fpath)
907
+ if not os.path.exists(abs_path):
908
+ continue
909
+ directory = os.path.dirname(abs_path)
910
+ _add_dir(directory)
911
+ except Exception:
912
+ continue
913
+ if not search_dirs:
914
+ search_dirs.append(os.getcwd())
915
+
916
+ style_candidates = []
917
+ seen_files = set()
918
+
919
+ def _collect_from_directory(directory: str):
920
+ if not os.path.isdir(directory):
921
+ return
922
+ try:
923
+ entries = sorted(os.listdir(directory))
924
+ except Exception:
925
+ return
926
+ for entry in entries:
927
+ full_path = os.path.join(directory, entry)
928
+ if not os.path.isfile(full_path):
929
+ continue
930
+ if not _has_valid_extension(entry, extensions):
931
+ continue
932
+ norm = os.path.abspath(full_path)
933
+ if norm in seen_files:
934
+ continue
935
+ seen_files.add(norm)
936
+ style_candidates.append({
937
+ 'name': entry,
938
+ 'path': norm,
939
+ 'location': directory,
940
+ })
941
+
942
+ for base_dir in search_dirs:
943
+ _collect_from_directory(base_dir)
944
+ styles_dir = os.path.join(base_dir, 'Styles')
945
+ if styles_dir != base_dir:
946
+ _collect_from_directory(styles_dir)
947
+
948
+ print(f"\nSearching for style files for {purpose} in:")
949
+ for dir_path in search_dirs:
950
+ print(f" - {dir_path}")
951
+ def _format_file_timestamp(filepath: str) -> str:
952
+ """Format file modification time for display."""
953
+ try:
954
+ mtime = os.path.getmtime(filepath)
955
+ return time.strftime("%Y-%m-%d %H:%M", time.localtime(mtime))
956
+ except Exception:
957
+ return ""
958
+
959
+ if style_candidates:
960
+ print("\nAvailable style files:")
961
+ for idx, cand in enumerate(style_candidates, start=1):
962
+ timestamp = _format_file_timestamp(cand['path'])
963
+ if timestamp:
964
+ print(f" {idx}. {cand['name']} ({timestamp}) (in {cand['location']})")
965
+ else:
966
+ print(f" {idx}. {cand['name']} (in {cand['location']})")
967
+ else:
968
+ print("\nNo style files found in scanned directories.")
969
+
970
+ search_locations = []
971
+ added_locations = set()
972
+ for base_dir in search_dirs:
973
+ if os.path.isdir(base_dir) and base_dir not in added_locations:
974
+ search_locations.append(base_dir)
975
+ added_locations.add(base_dir)
976
+ styles_dir = os.path.join(base_dir, 'Styles')
977
+ if os.path.isdir(styles_dir) and styles_dir not in added_locations:
978
+ search_locations.append(styles_dir)
979
+ added_locations.add(styles_dir)
980
+
981
+ def _resolve_manual_path(user_input: str) -> Optional[str]:
982
+ raw = os.path.expanduser(user_input.strip())
983
+ candidate_paths = []
984
+ if os.path.isabs(raw):
985
+ candidate_paths.append(os.path.abspath(raw))
986
+ else:
987
+ for loc in search_locations or [os.getcwd()]:
988
+ candidate_paths.append(os.path.abspath(os.path.join(loc, raw)))
989
+ resolved: List[str] = []
990
+ seen = set()
991
+ for cand in candidate_paths:
992
+ if cand not in seen:
993
+ seen.add(cand)
994
+ resolved.append(cand)
995
+ needs_ext = not _has_valid_extension(cand, extensions)
996
+ if needs_ext:
997
+ for ext in extensions:
998
+ if cand.lower().endswith(ext):
999
+ continue
1000
+ alt = cand + ext
1001
+ if alt not in seen:
1002
+ seen.add(alt)
1003
+ resolved.append(alt)
1004
+ for path in resolved:
1005
+ if os.path.isfile(path) and _has_valid_extension(path, extensions):
1006
+ return path
1007
+ return None
1008
+
1009
+ while True:
1010
+ try:
1011
+ choice = input("Select style file (number/path/c=custom/q=cancel): ").strip()
1012
+ except (KeyboardInterrupt, EOFError):
1013
+ print("\nStyle import canceled.")
1014
+ return None
1015
+
1016
+ if not choice:
1017
+ print("Style import canceled.")
1018
+ return None
1019
+
1020
+ low = choice.lower()
1021
+ if low == 'q':
1022
+ print("Style import canceled.")
1023
+ return None
1024
+ if low == 'c':
1025
+ dialog_path = _ask_file_dialog(initialdir=search_dirs[0], filetypes=extensions)
1026
+ if not dialog_path:
1027
+ print("No file selected.")
1028
+ continue
1029
+ dialog_path = os.path.abspath(dialog_path)
1030
+ if not os.path.isfile(dialog_path):
1031
+ print("Selected file does not exist.")
1032
+ continue
1033
+ if not _has_valid_extension(dialog_path, extensions):
1034
+ print("Selected file is not a recognized style file.")
1035
+ continue
1036
+ return dialog_path
1037
+ if choice.isdigit() and style_candidates:
1038
+ idx = int(choice)
1039
+ if 1 <= idx <= len(style_candidates):
1040
+ return style_candidates[idx - 1]['path']
1041
+ print("Invalid number. Try again.")
1042
+ continue
1043
+ path = _resolve_manual_path(choice)
1044
+ if path:
1045
+ return path
1046
+ print("File not found. Enter another value or use 'c' for custom dialog.")