batplot 1.8.1__py3-none-any.whl → 1.8.3__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 (42) hide show
  1. batplot/__init__.py +1 -1
  2. batplot/args.py +2 -0
  3. batplot/batch.py +23 -0
  4. batplot/batplot.py +101 -12
  5. batplot/cpc_interactive.py +25 -3
  6. batplot/electrochem_interactive.py +20 -4
  7. batplot/interactive.py +19 -15
  8. batplot/modes.py +12 -12
  9. batplot/operando_ec_interactive.py +4 -4
  10. batplot/session.py +218 -0
  11. batplot/style.py +21 -2
  12. batplot/version_check.py +1 -1
  13. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/METADATA +1 -1
  14. batplot-1.8.3.dist-info/RECORD +75 -0
  15. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/top_level.txt +1 -0
  16. batplot_backup_20251221_101150/__init__.py +5 -0
  17. batplot_backup_20251221_101150/args.py +625 -0
  18. batplot_backup_20251221_101150/batch.py +1176 -0
  19. batplot_backup_20251221_101150/batplot.py +3589 -0
  20. batplot_backup_20251221_101150/cif.py +823 -0
  21. batplot_backup_20251221_101150/cli.py +149 -0
  22. batplot_backup_20251221_101150/color_utils.py +547 -0
  23. batplot_backup_20251221_101150/config.py +198 -0
  24. batplot_backup_20251221_101150/converters.py +204 -0
  25. batplot_backup_20251221_101150/cpc_interactive.py +4409 -0
  26. batplot_backup_20251221_101150/electrochem_interactive.py +4520 -0
  27. batplot_backup_20251221_101150/interactive.py +3894 -0
  28. batplot_backup_20251221_101150/manual.py +323 -0
  29. batplot_backup_20251221_101150/modes.py +799 -0
  30. batplot_backup_20251221_101150/operando.py +603 -0
  31. batplot_backup_20251221_101150/operando_ec_interactive.py +5487 -0
  32. batplot_backup_20251221_101150/plotting.py +228 -0
  33. batplot_backup_20251221_101150/readers.py +2607 -0
  34. batplot_backup_20251221_101150/session.py +2951 -0
  35. batplot_backup_20251221_101150/style.py +1441 -0
  36. batplot_backup_20251221_101150/ui.py +790 -0
  37. batplot_backup_20251221_101150/utils.py +1046 -0
  38. batplot_backup_20251221_101150/version_check.py +253 -0
  39. batplot-1.8.1.dist-info/RECORD +0 -52
  40. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/WHEEL +0 -0
  41. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/entry_points.txt +0 -0
  42. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,149 @@
1
+ """CLI entry for batplot.
2
+
3
+ This module provides the main entry point for the batplot command-line interface.
4
+ It handles version checking, argument delegation, and error handling.
5
+
6
+ Design principles:
7
+ - Lazy imports: imports happen inside main() to avoid side effects during module load
8
+ - Clean separation: delegates actual work to batplot_main() in batplot.py
9
+ - Non-intrusive: version check is silent and non-blocking (max 2s timeout)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from typing import Optional
16
+
17
+
18
+ def main(argv: Optional[list] = None) -> int:
19
+ """
20
+ Main CLI entry point for batplot.
21
+
22
+ This function is the entry point when users run 'batplot' from the command line.
23
+ It's also the function called by the 'batplot' command installed via pip/setuptools.
24
+
25
+ HOW IT WORKS:
26
+ -----------
27
+ When you type 'batplot file.xy --interactive' in terminal:
28
+
29
+ 1. Python's setuptools calls this main() function
30
+ 2. main() performs version check (non-blocking, cached)
31
+ 3. main() parses command-line arguments
32
+ 4. main() delegates to batplot_main() which routes to appropriate mode
33
+ 5. Errors are caught and displayed cleanly (no scary tracebacks)
34
+
35
+ WHY LAZY IMPORTS?
36
+ ----------------
37
+ We import batplot_main() inside the try block, not at module level.
38
+ This is called "lazy importing" and has benefits:
39
+ - Faster startup: Only imports what's needed when needed
40
+ - Avoids side effects: Some modules might do things at import time
41
+ - Better error handling: Import errors happen in try/except block
42
+
43
+ Args:
44
+ argv: Optional command line arguments for testing/programmatic use.
45
+ If None, uses sys.argv (normal CLI usage).
46
+ Format: ['--mode', 'file.ext', ...]
47
+ Note: Do NOT include 'batplot' itself, it's added automatically.
48
+
49
+ Returns:
50
+ Exit code: 0 for success, non-zero for error.
51
+ This follows Unix convention where 0 = success, non-zero = error.
52
+
53
+ Examples:
54
+ >>> # Testing mode (programmatic use)
55
+ >>> main(['--cv', 'data.mpt'])
56
+ 0
57
+
58
+ >>> # Normal CLI usage (reads from sys.argv)
59
+ >>> main()
60
+ 0
61
+ """
62
+ # ====================================================================
63
+ # STEP 1: VERSION CHECK (NON-BLOCKING)
64
+ # ====================================================================
65
+ # Check PyPI for newer versions of batplot.
66
+ # This is done asynchronously and doesn't block the main program.
67
+ #
68
+ # Features:
69
+ # - Non-blocking: 2 second timeout (won't slow down startup)
70
+ # - Cached: Only checks once per day (saves network requests)
71
+ # - Optional: Can be disabled with BATPLOT_NO_VERSION_CHECK=1 environment variable
72
+ # - Silent failure: If check fails, program continues normally
73
+ #
74
+ # Why check for updates?
75
+ # - Users get notified of bug fixes and new features
76
+ # - Helps maintain an up-to-date installation
77
+ # ====================================================================
78
+ try:
79
+ from . import __version__
80
+ from .version_check import check_for_updates
81
+ check_for_updates(__version__)
82
+ except Exception:
83
+ # Silently ignore any errors in version checking.
84
+ # We NEVER want version checking to crash the main program.
85
+ # If PyPI is down, network is slow, or any other error occurs,
86
+ # the program should continue normally.
87
+ pass
88
+
89
+ # ====================================================================
90
+ # STEP 2: PREPARE ARGUMENTS (TESTING MODE SUPPORT)
91
+ # ====================================================================
92
+ # If argv is provided (testing/programmatic mode), temporarily replace
93
+ # sys.argv so argparse reads from our test arguments instead of real
94
+ # command line.
95
+ #
96
+ # Why? This allows unit tests to call main() with fake arguments without
97
+ # actually running from command line.
98
+ #
99
+ # Example:
100
+ # main(['--cv', 'test.mpt']) # Test with fake arguments
101
+ # # sys.argv temporarily becomes ['batplot', '--cv', 'test.mpt']
102
+ # ====================================================================
103
+ if argv is not None:
104
+ old_argv = sys.argv # Save original for restoration
105
+ sys.argv = ['batplot'] + list(argv) # Replace with test arguments
106
+
107
+ try:
108
+ # ====================================================================
109
+ # STEP 3: DELEGATE TO MAIN FUNCTION
110
+ # ====================================================================
111
+ # Import here (lazy import) to avoid side effects at module import time.
112
+ # batplot_main() will:
113
+ # 1. Parse command-line arguments using args.parse_args()
114
+ # 2. Route to appropriate mode handler (XY, EC, Operando, Batch)
115
+ # 3. Handle mode-specific logic
116
+ # ====================================================================
117
+ from .batplot import batplot_main
118
+ return batplot_main()
119
+
120
+ except ValueError as e:
121
+ # ====================================================================
122
+ # STEP 4: ERROR HANDLING
123
+ # ====================================================================
124
+ # ValueError is used for user-facing errors:
125
+ # - Bad command-line arguments
126
+ # - File not found
127
+ # - Invalid file format
128
+ # - Missing required parameters (e.g., --mass for .mpt files)
129
+ #
130
+ # We print a clean error message without scary Python traceback.
131
+ # This makes errors more user-friendly.
132
+ #
133
+ # Other exceptions (unexpected bugs) will still show full traceback
134
+ # for debugging purposes.
135
+ # ====================================================================
136
+ print(f"Error: {e}", file=sys.stderr)
137
+ return 1 # Non-zero exit code indicates error
138
+
139
+ finally:
140
+ # ====================================================================
141
+ # CLEANUP: RESTORE ORIGINAL sys.argv
142
+ # ====================================================================
143
+ # If we modified sys.argv for testing, restore it now.
144
+ # This ensures tests don't affect each other.
145
+ # ====================================================================
146
+ if argv is not None:
147
+ sys.argv = old_argv
148
+
149
+ __all__ = ["main"]
@@ -0,0 +1,547 @@
1
+ """Shared helpers for color previews and user-defined color management.
2
+
3
+ COLOR PALETTE SYSTEM OVERVIEW:
4
+ ==============================
5
+ This module manages how colors are assigned to multiple curves/lines in batplot plots.
6
+
7
+ HOW COLOR PALETTES WORK:
8
+ ------------------------
9
+ When you have many curves (e.g., 100 files in XY mode, or 50 cycles in EC mode), you want
10
+ each one to have a different color that smoothly transitions across a color palette.
11
+
12
+ Example: Using 'viridis' palette with 5 curves:
13
+ Curve 1 → Dark purple (start of viridis)
14
+ Curve 2 → Blue-purple
15
+ Curve 3 → Green
16
+ Curve 4 → Yellow-green
17
+ Curve 5 → Bright yellow (end of viridis)
18
+
19
+ The system works by:
20
+ 1. Getting a continuous colormap (e.g., 'viridis')
21
+ 2. Sampling colors at evenly spaced points along the colormap
22
+ 3. Assigning each sampled color to a different curve
23
+
24
+ For 100 curves, we sample 100 evenly spaced points from the colormap, ensuring each
25
+ curve gets a unique, smoothly varying color.
26
+
27
+ COLORMAP SOURCES:
28
+ ----------------
29
+ 1. Matplotlib built-in: 'viridis', 'plasma', 'inferno', 'magma', etc.
30
+ 2. cmcrameri scientific colormaps: 'batlow', 'batlowk', 'batloww' (if installed)
31
+ 3. Custom colormaps: Defined in _CUSTOM_CMAPS dictionary below
32
+
33
+ REVERSED COLORMAPS:
34
+ ------------------
35
+ Colormaps can be reversed by adding '_r' suffix:
36
+ 'viridis' → normal (dark to bright)
37
+ 'viridis_r' → reversed (bright to dark)
38
+
39
+ This is useful when you want the color order flipped.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ from typing import List, Optional, Sequence
45
+
46
+ import matplotlib.pyplot as plt
47
+ from matplotlib import colors as mcolors
48
+ from matplotlib.colors import LinearSegmentedColormap
49
+
50
+ from .config import get_user_colors as _cfg_get_user_colors
51
+ from .config import save_user_colors as _cfg_save_user_colors
52
+
53
+ # ====================================================================================
54
+ # CUSTOM COLORMAP DEFINITIONS
55
+ # ====================================================================================
56
+ # These are custom color palettes designed for scientific visualization.
57
+ # Each colormap is defined as a list of hex color codes that smoothly transition
58
+ # from one color to the next.
59
+ #
60
+ # Format: List of hex color strings, ordered from start to end of colormap
61
+ # Example: ['#02121d', '#053061', ...] means start with very dark blue, end with light yellow
62
+ #
63
+ # These colormaps are registered with matplotlib so they can be used like built-in ones.
64
+ # ====================================================================================
65
+ _CUSTOM_CMAPS = {
66
+ # 'batlow' - Scientific colormap optimized for colorblind accessibility
67
+ # Colors transition: dark blue → teal → green → yellow
68
+ 'batlow': ['#02121d', '#053061', '#2b7a8b', '#7cbf7b', '#c7e6a2', '#f9f0c3'],
69
+
70
+ # 'batlowk' - Variant with more purple tones
71
+ # Colors transition: dark purple → purple → brown → yellow
72
+ 'batlowk': ['#150b2d', '#3d2e63', '#5f4f85', '#81718f', '#a6938e', '#cbb58f', '#efd78d'],
73
+
74
+ # 'batloww' - Variant with more blue-green tones
75
+ # Colors transition: dark blue → blue → teal → green → yellow
76
+ 'batloww': ['#0a1427', '#17385d', '#295f8d', '#4f8fa3', '#7db7a1', '#b2d39a', '#e3e6a8'],
77
+ }
78
+
79
+
80
+ def ensure_colormap(name: Optional[str]) -> bool:
81
+ """
82
+ Ensure that a named colormap exists and is registered with matplotlib.
83
+
84
+ HOW IT WORKS:
85
+ ------------
86
+ This function checks if a colormap is available, and if not, tries to register it.
87
+ It searches in this order:
88
+ 1. Built-in matplotlib colormaps (viridis, plasma, etc.)
89
+ 2. cmcrameri scientific colormaps (if package is installed)
90
+ 3. Custom colormaps defined in _CUSTOM_CMAPS
91
+ 4. Any other matplotlib-compatible colormap
92
+
93
+ WHY THIS IS NEEDED:
94
+ -------------------
95
+ Different colormap sources need to be registered with matplotlib before they can
96
+ be used. This function ensures the colormap is available regardless of its source.
97
+
98
+ Args:
99
+ name: Colormap name (e.g., 'viridis', 'batlow', 'viridis_r')
100
+ '_r' suffix indicates reversed colormap
101
+
102
+ Returns:
103
+ True if colormap exists and is registered, False otherwise
104
+ """
105
+ if not name:
106
+ return False
107
+
108
+ # Handle reversed colormaps (remove '_r' suffix to get base name)
109
+ # Example: 'viridis_r' → base = 'viridis', we'll reverse it later if needed
110
+ base = name[:-2] if name.lower().endswith('_r') else name
111
+ base_lower = base.lower()
112
+
113
+ # STEP 1: Check if it's already a registered matplotlib colormap
114
+ if base_lower in plt.colormaps():
115
+ return True
116
+
117
+ # STEP 2: Try to load from cmcrameri package (scientific colormaps)
118
+ # cmcrameri is an optional package with colorblind-friendly colormaps
119
+ try:
120
+ import cmcrameri.cm as cmc
121
+ if hasattr(cmc, base_lower):
122
+ cmap_obj = getattr(cmc, base_lower)
123
+ try:
124
+ # Register it with matplotlib so it can be used like built-in colormaps
125
+ plt.register_cmap(name=base_lower, cmap=cmap_obj)
126
+ except ValueError:
127
+ # Already registered, that's fine
128
+ pass
129
+ return True
130
+ except Exception:
131
+ # cmcrameri not installed or colormap not found, continue to next step
132
+ pass
133
+
134
+ # STEP 3: Check if it's a custom colormap defined in this module
135
+ custom = _CUSTOM_CMAPS.get(base_lower)
136
+ if custom:
137
+ try:
138
+ # Create a LinearSegmentedColormap from the list of colors
139
+ # N=256 means create 256 intermediate colors by interpolating between the given colors
140
+ # This creates a smooth gradient
141
+ cmap_obj = LinearSegmentedColormap.from_list(base_lower, custom, N=256)
142
+ try:
143
+ # Register with matplotlib
144
+ plt.register_cmap(name=base_lower, cmap=cmap_obj)
145
+ except ValueError:
146
+ # Already registered, that's fine
147
+ pass
148
+ return True
149
+ except Exception:
150
+ return False
151
+
152
+ # STEP 4: Final fallback - try to get it directly from matplotlib
153
+ # This handles any other matplotlib-compatible colormap
154
+ try:
155
+ plt.get_cmap(base_lower)
156
+ return True
157
+ except Exception:
158
+ return False
159
+
160
+
161
+ def _ansi_color_block_from_rgba(rgba) -> str:
162
+ """Return a two-space block with the given RGBA color."""
163
+ try:
164
+ r, g, b, _ = rgba
165
+ r_i = max(0, min(255, int(round(r * 255))))
166
+ g_i = max(0, min(255, int(round(g * 255))))
167
+ b_i = max(0, min(255, int(round(b * 255))))
168
+ return f"\033[48;2;{r_i};{g_i};{b_i}m \033[0m"
169
+ except Exception:
170
+ return "[??]"
171
+
172
+
173
+ def color_block(color: Optional[str]) -> str:
174
+ """Return a colored block (ANSI) for the supplied color string."""
175
+ if not color:
176
+ return "[--]"
177
+ try:
178
+ rgba = mcolors.to_rgba(color)
179
+ return _ansi_color_block_from_rgba(rgba)
180
+ except Exception:
181
+ return "[??]"
182
+
183
+
184
+ def color_bar(colors: Sequence[str]) -> str:
185
+ """Return a string of adjacent color blocks."""
186
+ blocks = [color_block(col) for col in colors if col]
187
+ return " ".join(blocks)
188
+
189
+
190
+ def palette_preview(name: str, steps: int = 8) -> str:
191
+ """
192
+ Return a visual preview of a colormap as colored blocks in the terminal.
193
+
194
+ HOW IT WORKS:
195
+ ------------
196
+ This function samples colors from a colormap at evenly spaced intervals and
197
+ displays them as colored blocks. This lets users see what the colormap looks
198
+ like before applying it to their data.
199
+
200
+ Example output for 'viridis' with 8 steps:
201
+ [dark purple block] [purple block] [blue block] [green block] [yellow block] ...
202
+
203
+ SAMPLING METHOD:
204
+ ---------------
205
+ For a colormap with N steps:
206
+ - Step 0: Sample at position 0.0 (start of colormap)
207
+ - Step 1: Sample at position 1/(N-1)
208
+ - Step 2: Sample at position 2/(N-1)
209
+ - ...
210
+ - Step N-1: Sample at position 1.0 (end of colormap)
211
+
212
+ This gives evenly distributed colors across the entire colormap range.
213
+
214
+ Args:
215
+ name: Colormap name (e.g., 'viridis', 'plasma', 'batlow')
216
+ steps: Number of color samples to show (default 8)
217
+ More steps = more detailed preview but longer output
218
+
219
+ Returns:
220
+ String of ANSI color codes that display as colored blocks in terminal
221
+ Empty string if colormap not found
222
+ """
223
+ # Ensure colormap is registered
224
+ ensure_colormap(name)
225
+
226
+ # Try to get the colormap from matplotlib
227
+ try:
228
+ cmap = plt.get_cmap(name)
229
+ except Exception:
230
+ cmap = None
231
+ # Fallback: try cmcrameri if it's a batlow variant
232
+ lower = name.lower()
233
+ if lower.startswith('batlow'):
234
+ try:
235
+ import cmcrameri.cm as cmc
236
+ if hasattr(cmc, lower):
237
+ cmap = getattr(cmc, lower)
238
+ elif hasattr(cmc, 'batlow'):
239
+ cmap = getattr(cmc, 'batlow')
240
+ except Exception:
241
+ return ""
242
+ else:
243
+ return ""
244
+
245
+ # Ensure steps is at least 1 (avoid division by zero)
246
+ if steps < 1:
247
+ steps = 1
248
+
249
+ # Special handling for tab10 to use hardcoded colors (matching EC and CPC interactive)
250
+ if name.lower() == 'tab10':
251
+ default_tab10_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
252
+ '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
253
+ # Use first 'steps' colors from tab10
254
+ samples = [default_tab10_colors[i % len(default_tab10_colors)] for i in range(steps)]
255
+ else:
256
+ # Sample colors at evenly spaced positions along the colormap
257
+ # Position ranges from 0.0 (start) to 1.0 (end)
258
+ samples = [
259
+ mcolors.to_hex(cmap(i / max(steps - 1, 1))) # Convert to hex color code
260
+ for i in range(steps)
261
+ ]
262
+
263
+ # Convert color codes to visual blocks and return as string
264
+ return color_bar(samples)
265
+
266
+
267
+ def _set_cached_colors(fig, colors: List[str]):
268
+ """
269
+ Store user colors in figure object for fast access (caching).
270
+
271
+ HOW CACHING WORKS:
272
+ -----------------
273
+ Instead of reading from disk every time, we store colors in the figure object.
274
+ This is faster because:
275
+ - Reading from disk is slow (file I/O)
276
+ - Figure object is already in memory (fast access)
277
+
278
+ WHY STORE IN FIGURE OBJECT?
279
+ ---------------------------
280
+ The figure object persists throughout the interactive session, so we can
281
+ cache colors there. This avoids repeated file reads.
282
+
283
+ Args:
284
+ fig: Matplotlib figure object (where we store the cache)
285
+ colors: List of color codes to cache
286
+ """
287
+ if fig is not None:
288
+ # setattr() dynamically adds an attribute to an object
289
+ # This is like: fig._user_colors_cache = list(colors)
290
+ # We use list() to create a copy (not a reference to original list)
291
+ setattr(fig, '_user_colors_cache', list(colors))
292
+
293
+
294
+ def get_user_color_list(fig=None) -> List[str]:
295
+ """
296
+ Return cached user colors (persisted to ~/.batplot).
297
+
298
+ HOW IT WORKS:
299
+ ------------
300
+ 1. First check if colors are cached in figure object (fast path)
301
+ 2. If not cached, load from disk (~/.batplot/config.json)
302
+ 3. Cache the loaded colors in figure object for next time
303
+ 4. Return the color list
304
+
305
+ WHY CACHING?
306
+ -----------
307
+ - Fast: Memory access is much faster than disk I/O
308
+ - Efficient: Only reads from disk once per session
309
+ - Persistent: Colors are saved to disk, so they persist between sessions
310
+
311
+ Args:
312
+ fig: Matplotlib figure object (optional, for caching)
313
+
314
+ Returns:
315
+ List of color codes (hex strings like '#FF0000' or named colors like 'red')
316
+ """
317
+ # Check if colors are already cached in figure object
318
+ if fig is not None and hasattr(fig, '_user_colors_cache'):
319
+ # Return cached colors (fast path - no disk access)
320
+ # list() creates a copy so caller can't modify the cached version
321
+ return list(getattr(fig, '_user_colors_cache'))
322
+
323
+ # Not cached - load from disk
324
+ colors = list(_cfg_get_user_colors())
325
+ # Cache for next time
326
+ _set_cached_colors(fig, colors)
327
+ return colors
328
+
329
+
330
+ def _save_user_colors(colors: List[str], fig=None) -> List[str]:
331
+ """
332
+ Save user colors to disk and cache, removing duplicates and empty entries.
333
+
334
+ HOW IT WORKS:
335
+ ------------
336
+ 1. Remove empty/None colors (filter out invalid entries)
337
+ 2. Remove duplicates (keep only first occurrence of each color)
338
+ 3. Save cleaned list to disk (~/.batplot/config.json)
339
+ 4. Update cache in figure object
340
+ 5. Return cleaned list
341
+
342
+ WHY CLEAN THE LIST?
343
+ ------------------
344
+ - Empty strings would cause errors when trying to use them as colors
345
+ - Duplicates waste space and confuse users
346
+ - Clean data = better user experience
347
+
348
+ Args:
349
+ colors: List of color codes (may contain duplicates or empty strings)
350
+ fig: Matplotlib figure object (optional, for caching)
351
+
352
+ Returns:
353
+ Cleaned list (no duplicates, no empty entries)
354
+ """
355
+ cleaned: List[str] = [] # Type annotation: cleaned is a list of strings
356
+ for col in colors:
357
+ # Skip empty/None colors
358
+ if not col:
359
+ continue
360
+ # Only add if not already in cleaned list (removes duplicates)
361
+ if col not in cleaned:
362
+ cleaned.append(col)
363
+ # Save to disk (persists between sessions)
364
+ _cfg_save_user_colors(cleaned)
365
+ # Update cache (fast access for current session)
366
+ _set_cached_colors(fig, cleaned)
367
+ return cleaned
368
+
369
+
370
+ def add_user_color(color: str, fig=None) -> List[str]:
371
+ """Append a user color (if not already present)."""
372
+ colors = get_user_color_list(fig)
373
+ if color and color not in colors:
374
+ colors.append(color)
375
+ colors = _save_user_colors(colors, fig)
376
+ return colors
377
+
378
+
379
+ def remove_user_color(index: int, fig=None) -> List[str]:
380
+ """Remove a user color by 0-based index."""
381
+ colors = get_user_color_list(fig)
382
+ if 0 <= index < len(colors):
383
+ colors.pop(index)
384
+ colors = _save_user_colors(colors, fig)
385
+ return colors
386
+
387
+
388
+ def clear_user_colors(fig=None) -> None:
389
+ _save_user_colors([], fig)
390
+
391
+
392
+ def resolve_color_token(token: str, fig=None) -> str:
393
+ """
394
+ Translate color references like '2' or 'u3' into actual color codes.
395
+
396
+ HOW IT WORKS:
397
+ ------------
398
+ Users can reference saved colors in two ways:
399
+ 1. By number: '2' means the 2nd saved color (1-based indexing)
400
+ 2. By 'u' prefix: 'u3' means the 3rd saved color (1-based indexing)
401
+
402
+ Examples:
403
+ '2' → Returns colors[1] (2nd color, but 0-based index is 1)
404
+ 'u3' → Returns colors[2] (3rd color, but 0-based index is 2)
405
+ 'red' → Returns 'red' (not a reference, so return as-is)
406
+ '#FF0000' → Returns '#FF0000' (not a reference, so return as-is)
407
+
408
+ WHY TWO FORMATS?
409
+ ---------------
410
+ - '2' is shorter and easier to type
411
+ - 'u3' is more explicit (clearly indicates user color)
412
+ - Both are 1-based (user-friendly) but converted to 0-based (Python indexing)
413
+
414
+ Args:
415
+ token: Color reference string (e.g., '2', 'u3', 'red', '#FF0000')
416
+ fig: Matplotlib figure object (optional, for accessing cached colors)
417
+
418
+ Returns:
419
+ Actual color code (hex string or named color), or original token if not a reference
420
+ """
421
+ # Empty token - return as-is
422
+ if not token:
423
+ return token
424
+
425
+ # Remove whitespace
426
+ stripped = token.strip()
427
+ idx = None # Will hold the 0-based index if token is a reference
428
+
429
+ # Check if token is 'u' prefix format (e.g., 'u3')
430
+ # stripped[1:] gets everything after the first character
431
+ # .isdigit() checks if it's all digits
432
+ if stripped.lower().startswith('u') and stripped[1:].isdigit():
433
+ # Convert 'u3' → index 2 (3rd color, but 0-based)
434
+ idx = int(stripped[1:]) - 1
435
+ # Check if token is just a number (e.g., '2')
436
+ elif stripped.isdigit():
437
+ # Convert '2' → index 1 (2nd color, but 0-based)
438
+ idx = int(stripped) - 1
439
+
440
+ # If we found a valid index, look up the color
441
+ if idx is not None:
442
+ colors = get_user_color_list(fig)
443
+ # Check if index is valid (within bounds of color list)
444
+ if 0 <= idx < len(colors):
445
+ return colors[idx] # Return the actual color code
446
+
447
+ # Not a reference, or invalid index - return token as-is
448
+ return token
449
+
450
+
451
+ def print_user_colors(fig=None) -> None:
452
+ """Print saved colors with indices and color blocks."""
453
+ colors = get_user_color_list(fig)
454
+ if not colors:
455
+ print("No saved user colors.")
456
+ return
457
+ print("Saved colors:")
458
+ for idx, color in enumerate(colors, 1):
459
+ print(f" {idx}: {color_block(color)} {color}")
460
+
461
+
462
+ def manage_user_colors(fig=None) -> None:
463
+ """Interactive submenu for editing user-defined colors."""
464
+ while True:
465
+ colors = get_user_color_list(fig)
466
+ print("\n\033[1mUser color list:\033[0m")
467
+ if colors:
468
+ for idx, color in enumerate(colors, 1):
469
+ print(f" {idx}: {color_block(color)} {color}")
470
+ else:
471
+ print(" (empty)")
472
+ print("Options: a=add colors, d=delete numbers, c=clear, q=back")
473
+ choice = input("User colors> ").strip().lower()
474
+ if not choice:
475
+ continue
476
+ if choice == 'q':
477
+ break
478
+ if choice == 'a':
479
+ line = input("Enter colors (space-separated names/hex codes) or q: ").strip()
480
+ if not line or line.lower() == 'q':
481
+ continue
482
+ # List comprehension: splits line by spaces, keeps only non-empty tokens
483
+ # Example: "red blue #FF0000" → ['red', 'blue', '#FF0000']
484
+ # The 'if tok' part filters out empty strings (from multiple spaces)
485
+ new_colors = [tok for tok in line.split() if tok]
486
+ if new_colors:
487
+ colors = get_user_color_list(fig)
488
+ added = 0
489
+ for col in new_colors:
490
+ if col not in colors:
491
+ colors.append(col)
492
+ added += 1
493
+ _save_user_colors(colors, fig)
494
+ print(f"Added {added} color(s).")
495
+ continue
496
+ if choice == 'd':
497
+ if not colors:
498
+ print("No colors to delete.")
499
+ continue
500
+ line = input("Enter number(s) to delete (e.g., 1 or 1,3,5): ").strip()
501
+ if not line:
502
+ continue
503
+ tokens = line.replace(',', ' ').split()
504
+ indices = []
505
+ for tok in tokens:
506
+ if tok.isdigit():
507
+ idx = int(tok) - 1
508
+ if 0 <= idx < len(colors):
509
+ indices.append(idx)
510
+ else:
511
+ print(f"Index out of range: {tok}")
512
+ else:
513
+ print(f"Invalid entry: {tok}")
514
+ if indices:
515
+ # Sort indices in reverse order (largest first)
516
+ # WHY? When deleting multiple items, we must delete from end to start.
517
+ # If we delete index 1 first, then index 3 becomes index 2, and we'd delete the wrong item!
518
+ # Example: colors = ['red', 'blue', 'green', 'yellow']
519
+ # Delete indices [1, 3] (blue and yellow)
520
+ # If we delete 1 first: ['red', 'green', 'yellow'] (index 3 is now out of bounds!)
521
+ # If we delete 3 first: ['red', 'blue', 'green'] (then delete 1: ['red', 'green'] ✓)
522
+ for idx in sorted(indices, reverse=True):
523
+ colors.pop(idx) # Remove color at this index
524
+ _save_user_colors(colors, fig)
525
+ print("Updated color list.")
526
+ continue
527
+ if choice == 'c':
528
+ confirm = input("Clear all saved colors? (y/n): ").strip().lower()
529
+ if confirm == 'y':
530
+ clear_user_colors(fig)
531
+ print("Cleared saved colors.")
532
+ continue
533
+ print("Unknown option.")
534
+
535
+
536
+ __all__ = [
537
+ 'add_user_color',
538
+ 'clear_user_colors',
539
+ 'color_bar',
540
+ 'color_block',
541
+ 'manage_user_colors',
542
+ 'palette_preview',
543
+ 'print_user_colors',
544
+ 'remove_user_color',
545
+ 'resolve_color_token',
546
+ 'get_user_color_list',
547
+ ]