majoplot 0.1.0__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.
majoplot/gui/main.py ADDED
@@ -0,0 +1,529 @@
1
+ from __future__ import annotations
2
+
3
+ import tkinter as tk
4
+ from tkinter import ttk, filedialog, messagebox
5
+ from pathlib import Path
6
+ from collections import OrderedDict
7
+ from typing import Any
8
+
9
+ from ..app.cli import load_named_objects # reuse discovery helper
10
+ from ..domain.base import fail_signal, Figure
11
+ from ..domain.utils import pack_into_project, group_into_axes, group_into_figure
12
+ from ..infra.plotters.origin import OriginCOM, plot as oplot
13
+ from ..infra.plotters.matplot import plot as mplot
14
+
15
+
16
+ class _LRUCache:
17
+ """A tiny LRU cache for rendered matplotlib Figure previews."""
18
+
19
+ def __init__(self, maxsize: int = 3) -> None:
20
+ self.maxsize = maxsize
21
+ self._d: OrderedDict[int, Any] = OrderedDict()
22
+
23
+ def get(self, key: int) -> Any | None:
24
+ if key not in self._d:
25
+ return None
26
+ self._d.move_to_end(key)
27
+ return self._d[key]
28
+
29
+ def put(self, key: int, value: Any) -> None:
30
+ if key in self._d:
31
+ self._d.move_to_end(key)
32
+ self._d[key] = value
33
+ while len(self._d) > self.maxsize:
34
+ _, victim = self._d.popitem(last=False)
35
+ try:
36
+ import matplotlib.pyplot as plt
37
+ plt.close(victim)
38
+ except Exception:
39
+ pass
40
+
41
+
42
+ def clear(self) -> None:
43
+ try:
44
+ import matplotlib.pyplot as plt
45
+ for v in self._d.values():
46
+ plt.close(v)
47
+ except Exception:
48
+ pass
49
+ self._d.clear()
50
+
51
+
52
+
53
+ class MainWindow(ttk.Frame):
54
+ def __init__(self, master: tk.Tk) -> None:
55
+ super().__init__(master)
56
+ self.master = master
57
+
58
+ # State
59
+ self.importers: dict[str, Any] = {}
60
+ self.scenarios: dict[str, Any] = {}
61
+ self.figures: list[Figure] = []
62
+ self.raw_files_to_import: list[Path] = []
63
+
64
+ self._preview_cache = _LRUCache(maxsize=3)
65
+ self._current_preview_canvas = None # FigureCanvasTkAgg
66
+
67
+ # UI variables
68
+ self.importer_var = tk.StringVar(value="")
69
+ self.scenario_var = tk.StringVar(value="")
70
+ self.proj_dir_var = tk.StringVar(value=str(Path.cwd()))
71
+ self.save_mode_var = tk.StringVar(value="attach") # attach|overwrite
72
+
73
+ # Global archive inputs
74
+ self.global_proj_name_var = tk.StringVar(value="")
75
+ self.global_folder_path_var = tk.StringVar(value="")
76
+
77
+ # Per-figure archive editor
78
+ self.edit_proj_name_var = tk.StringVar(value="")
79
+ self.edit_folder_path_var = tk.StringVar(value="")
80
+
81
+ self._build_ui()
82
+ self._load_importers()
83
+
84
+ # ---------------- UI construction ----------------
85
+ def _build_ui(self) -> None:
86
+ self.master.title("Majoplot")
87
+ self.master.geometry("1400x720")
88
+
89
+ top = ttk.Frame(self)
90
+ top.pack(side=tk.TOP, fill=tk.X, padx=8, pady=8)
91
+
92
+ ttk.Label(top, text="Importer").pack(side=tk.LEFT)
93
+ self.importer_cb = ttk.Combobox(top, textvariable=self.importer_var, state="readonly", width=20)
94
+ self.importer_cb.pack(side=tk.LEFT, padx=(6, 12))
95
+ self.importer_cb.bind("<<ComboboxSelected>>", lambda _e: self._on_importer_selected())
96
+
97
+ ttk.Label(top, text="Scenario").pack(side=tk.LEFT)
98
+ self.scenario_cb = ttk.Combobox(top, textvariable=self.scenario_var, state="readonly", width=30)
99
+ self.scenario_cb.pack(side=tk.LEFT, padx=(6, 12))
100
+
101
+ ttk.Button(top, text="Select raw files...", command=self._pick_raw_files).pack(side=tk.LEFT, padx=(0, 8))
102
+ ttk.Button(top, text="Raw files → Figures", command=self._import_figures).pack(side=tk.LEFT, padx=(0, 24))
103
+ ttk.Button(top, text="Delete all figures", command=self._delete_all_figures).pack(side=tk.RIGHT)
104
+
105
+ # Main body: left raw files / middle preview / right save
106
+ body = ttk.PanedWindow(self, orient=tk.HORIZONTAL)
107
+ body.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=(0, 8))
108
+
109
+ # Left: raw files
110
+ left = ttk.Frame(body)
111
+ body.add(left, weight=2)
112
+ ttk.Label(left, text="Raw files").pack(anchor="w")
113
+ self.raw_file_list = tk.Listbox(left, height=10)
114
+ self.raw_file_list.pack(fill=tk.BOTH, expand=True, pady=(4, 0))
115
+
116
+ # Middle: figure list + preview
117
+ mid = ttk.PanedWindow(body, orient=tk.HORIZONTAL)
118
+ body.add(mid, weight=12)
119
+
120
+ mid_left = ttk.Frame(mid)
121
+ mid.add(mid_left, weight=2)
122
+ ttk.Label(mid_left, text="Figures").pack(anchor="w")
123
+ self.figure_list = tk.Listbox(mid_left)
124
+ self.figure_list.pack(fill=tk.BOTH, expand=True, pady=(4, 6))
125
+ self.figure_list.bind("<<ListboxSelect>>", lambda _e: self._on_figure_selected())
126
+
127
+ # Per-figure actions
128
+ action = ttk.LabelFrame(mid_left, text="Selected Figure")
129
+ action.pack(fill=tk.X)
130
+
131
+ row1 = ttk.Frame(action)
132
+ row1.pack(fill=tk.X, padx=6, pady=4)
133
+ ttk.Button(row1, text="Delete", command=self._delete_selected_figure).pack(side=tk.LEFT)
134
+ ttk.Button(row1, text="Save this...", command=self._save_selected_figure).pack(side=tk.LEFT, padx=(6, 0))
135
+
136
+ row2 = ttk.Frame(action)
137
+ row2.pack(fill=tk.X, padx=6, pady=(4, 2))
138
+ ttk.Label(row2, text="proj_name").pack(side=tk.LEFT)
139
+ ttk.Entry(row2, textvariable=self.edit_proj_name_var, width=10).pack(side=tk.LEFT, padx=(6, 12))
140
+ ttk.Label(row2, text="folder_path").pack(side=tk.LEFT)
141
+ ttk.Entry(row2, textvariable=self.edit_folder_path_var, width=16).pack(side=tk.LEFT, padx=(6, 0))
142
+
143
+ row3 = ttk.Frame(action)
144
+ row3.pack(fill=tk.X, padx=6, pady=(2, 6))
145
+ ttk.Button(row3, text="Apply (append)", command=lambda: self._apply_archive_to_selected(overwrite=False)).pack(side=tk.LEFT)
146
+ ttk.Button(row3, text="Apply (overwrite)", command=lambda: self._apply_archive_to_selected(overwrite=True)).pack(side=tk.LEFT, padx=(6, 0))
147
+
148
+ mid_right = ttk.Frame(mid)
149
+ mid.add(mid_right, weight=8)
150
+ ttk.Label(mid_right, text="Matplotlib preview").pack(anchor="w")
151
+ self.preview_host = ttk.Frame(mid_right)
152
+ self.preview_host.pack(fill=tk.BOTH, expand=True, pady=(4, 0))
153
+
154
+ # Right: save all
155
+ right = ttk.Frame(body)
156
+ body.add(right, weight=2)
157
+
158
+ save_box = ttk.LabelFrame(right, text="Save all to OPJU")
159
+ save_box.pack(fill=tk.BOTH, expand=True)
160
+
161
+ g1 = ttk.Frame(save_box)
162
+ g1.pack(fill=tk.X, padx=8, pady=(8, 4))
163
+ ttk.Label(g1, text="proj_name").pack(side=tk.LEFT)
164
+ ttk.Entry(g1, textvariable=self.global_proj_name_var, width=18).pack(side=tk.LEFT, padx=(6, 0))
165
+
166
+ g2 = ttk.Frame(save_box)
167
+ g2.pack(fill=tk.X, padx=8, pady=4)
168
+ ttk.Label(g2, text="folder_path").pack(side=tk.LEFT)
169
+ ttk.Entry(g2, textvariable=self.global_folder_path_var, width=18).pack(side=tk.LEFT, padx=(6, 0))
170
+
171
+ g3 = ttk.Frame(save_box)
172
+ g3.pack(fill=tk.X, padx=8, pady=4)
173
+ ttk.Button(g3, text="Append to all", command=lambda: self._apply_archive_to_all(overwrite=False)).pack(side=tk.LEFT)
174
+ ttk.Button(g3, text="Overwrite all", command=lambda: self._apply_archive_to_all(overwrite=True)).pack(side=tk.LEFT, padx=(6, 0))
175
+
176
+ g4 = ttk.Frame(save_box)
177
+ g4.pack(fill=tk.X, padx=8, pady=(10, 4))
178
+ ttk.Label(g4, text="OPJU directory").pack(side=tk.LEFT)
179
+ proj_entry = ttk.Entry(g4, textvariable=self.proj_dir_var, state="readonly")
180
+ proj_entry.pack(side=tk.TOP, padx=(6, 6), fill=tk.X, expand=True)
181
+ proj_entry.bind("<Button-1>", lambda _e: self._pick_proj_dir())
182
+
183
+ ttk.Button(g4, text="Choose...", command=self._pick_proj_dir).pack(side=tk.LEFT)
184
+
185
+
186
+ g5 = ttk.Frame(save_box)
187
+ g5.pack(fill=tk.X, padx=8, pady=4)
188
+ ttk.Label(g5, text="Mode").pack(side=tk.LEFT)
189
+ ttk.Radiobutton(g5, text="Attach", variable=self.save_mode_var, value="attach").pack(side=tk.LEFT, padx=(6, 0))
190
+ ttk.Radiobutton(g5, text="Overwrite", variable=self.save_mode_var, value="overwrite").pack(side=tk.LEFT, padx=(6, 0))
191
+
192
+ ttk.Button(save_box, text="Save ALL", command=self._save_all).pack(side=tk.BOTTOM, fill=tk.X, padx=8, pady=8)
193
+
194
+ self.pack(fill=tk.BOTH, expand=True)
195
+
196
+ # ---------------- Data discovery ----------------
197
+ def _load_importers(self) -> None:
198
+ self.importers = load_named_objects("majoplot.domain.importers")
199
+ names = sorted(self.importers.keys())
200
+ self.importer_cb["values"] = names
201
+ if names:
202
+ self.importer_var.set(names[0])
203
+ self._on_importer_selected()
204
+
205
+ def _on_importer_selected(self) -> None:
206
+ name = self.importer_var.get()
207
+ if not name or name not in self.importers:
208
+ return
209
+ self.scenarios = load_named_objects(f"majoplot.domain.scenarios.{name}")
210
+ snames = sorted(self.scenarios.keys())
211
+ self.scenario_cb["values"] = snames
212
+ if snames:
213
+ self.scenario_var.set(snames[0])
214
+
215
+ # ---------------- Import flow ----------------
216
+ def _pick_raw_files(self) -> None:
217
+ paths = filedialog.askopenfilenames(
218
+ title="Select raw data files",
219
+ initialdir=str(Path.cwd()),
220
+ filetypes=[("All files", "*.*")],
221
+ )
222
+ if not paths:
223
+ return
224
+ for p in paths:
225
+ pp = Path(p).resolve()
226
+ self.raw_files_to_import.append(pp)
227
+ self.raw_file_list.insert(tk.END, str(pp))
228
+
229
+ def _import_figures(self) -> None:
230
+ importer_name = self.importer_var.get()
231
+ scenario_name = self.scenario_var.get()
232
+ if not importer_name or importer_name not in self.importers:
233
+ messagebox.showerror("Error", "Please choose an importer.")
234
+ return
235
+ if not scenario_name or scenario_name not in self.scenarios:
236
+ messagebox.showerror("Error", "Please choose a scenario.")
237
+ return
238
+ if not self.raw_files_to_import:
239
+ messagebox.showwarning("No files", "Please select raw files first.")
240
+ return
241
+
242
+ importer = self.importers[importer_name]
243
+ scenario = self.scenarios[scenario_name]
244
+
245
+ # Only import files not yet imported into figures in this session
246
+ new_paths = self.raw_files_to_import
247
+ self.raw_files_to_import = []
248
+ self.raw_file_list.delete(0, tk.END)
249
+
250
+ raw_datas = []
251
+ for path in new_paths:
252
+ try:
253
+ with open(path, encoding="utf-8") as fp:
254
+ raw = importer.fetch_raw_data(fp, path.stem)
255
+ if raw is not fail_signal:
256
+ raw_datas.append(raw)
257
+ except Exception as e:
258
+ messagebox.showerror("Import error", f"Failed to read {path}:\n{e}")
259
+ return
260
+
261
+ try:
262
+ datas = scenario.preprocess(raw_datas)
263
+ except Exception as e:
264
+ messagebox.showerror("Preprocess error", f"Scenario preprocess failed:\n{e}")
265
+ return
266
+
267
+
268
+ # Match CLI logic: Data -> Axes -> Figure
269
+ try:
270
+ axes_pool = group_into_axes(datas, scenario)
271
+ figures = group_into_figure(axes_pool, scenario)
272
+ except Exception as e:
273
+ messagebox.showerror(
274
+ "Grouping error",
275
+ "Failed to group Data into Figures.\n"
276
+ f"{e}",
277
+ )
278
+ return
279
+
280
+ added = 0
281
+ for fig in figures:
282
+ if not isinstance(fig, Figure):
283
+ continue
284
+ self.figures.append(fig)
285
+ self.figure_list.insert(tk.END, fig.spec.name)
286
+ added += 1
287
+
288
+ if added == 0:
289
+ messagebox.showinfo("No figures", "No figures were produced from the selected files.")
290
+ return
291
+
292
+ self._preview_cache.clear()
293
+
294
+ # ---------------- Figure selection & preview ----------------
295
+ def _selected_index(self) -> int | None:
296
+ sel = self.figure_list.curselection()
297
+ if not sel:
298
+ return None
299
+ return int(sel[0])
300
+
301
+ def _on_figure_selected(self) -> None:
302
+ idx = self._selected_index()
303
+ if idx is None or idx >= len(self.figures):
304
+ return
305
+ fig = self.figures[idx]
306
+
307
+ # Populate editor with a single pair if exists, else blank.
308
+ if fig.proj_folder:
309
+ # choose the first (stable ordering not guaranteed)
310
+ proj_name, folder = next(iter(fig.proj_folder.items()))
311
+ self.edit_proj_name_var.set(proj_name)
312
+ self.edit_folder_path_var.set(folder)
313
+ else:
314
+ self.edit_proj_name_var.set("")
315
+ self.edit_folder_path_var.set("")
316
+
317
+ self._show_preview(fig)
318
+
319
+ def _show_preview(self, myfig: Figure) -> None:
320
+ # Render with caching
321
+ key = id(myfig)
322
+ matfig = self._preview_cache.get(key)
323
+ if matfig is None:
324
+ try:
325
+ matfig = mplot(myfig)
326
+ except Exception as e:
327
+ messagebox.showerror("Preview error", f"Failed to render preview:\n{e}")
328
+ return
329
+ matfig.set_constrained_layout(True)
330
+ self._preview_cache.put(key, matfig)
331
+
332
+ # Close previous figure bound to old canvas to avoid leaking GUI event sources
333
+ try:
334
+ import matplotlib.pyplot as plt
335
+ if self._current_preview_canvas is not None:
336
+ old_fig = self._current_preview_canvas.figure
337
+ plt.close(old_fig)
338
+ except Exception:
339
+ pass
340
+ self._current_preview_canvas = None
341
+
342
+ # Replace preview canvas
343
+ for child in self.preview_host.winfo_children():
344
+ child.destroy()
345
+
346
+ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
347
+
348
+ canvas = FigureCanvasTkAgg(matfig, master=self.preview_host)
349
+ canvas.draw()
350
+
351
+ # Add toolbar (pan/zoom/home/save)
352
+ toolbar = NavigationToolbar2Tk(canvas, self.preview_host)
353
+ toolbar.update()
354
+
355
+ # Pack order matters: toolbar first, then canvas widget fills the rest
356
+ toolbar.pack(side=tk.TOP, fill=tk.X)
357
+ canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True)
358
+
359
+ self._current_preview_canvas = canvas
360
+
361
+
362
+ # ---------------- Figure operations ----------------
363
+ def _delete_selected_figure(self) -> None:
364
+ idx = self._selected_index()
365
+ if idx is None or idx >= len(self.figures):
366
+ return
367
+ fig = self.figures.pop(idx)
368
+ self.figure_list.delete(idx)
369
+ self._preview_cache.clear()
370
+ # Clear preview if nothing selected
371
+ for child in self.preview_host.winfo_children():
372
+ child.destroy()
373
+
374
+ def _delete_all_figures(self) -> None:
375
+ # 0) If there is an active preview canvas, close the bound matplotlib Figure
376
+ # to avoid leaking GUI event sources.
377
+ try:
378
+ import matplotlib.pyplot as plt
379
+ if self._current_preview_canvas is not None:
380
+ try:
381
+ old_fig = self._current_preview_canvas.figure
382
+ plt.close(old_fig)
383
+ except Exception:
384
+ pass
385
+ except Exception:
386
+ pass
387
+ self._current_preview_canvas = None
388
+
389
+ # 1) Clear the in-memory model
390
+ self.figures.clear()
391
+
392
+ # 2) Clear the listbox UI and selection highlight
393
+ try:
394
+ self.figure_list.selection_clear(0, tk.END)
395
+ except Exception:
396
+ pass
397
+ self.figure_list.delete(0, tk.END)
398
+
399
+ # 3) Clear per-figure archive editor fields
400
+ self.edit_proj_name_var.set("")
401
+ self.edit_folder_path_var.set("")
402
+
403
+ # 4) Clear preview cache (LRUCache.clear() closes cached figures)
404
+ self._preview_cache.clear()
405
+
406
+ # 5) Remove any preview widgets from the host frame
407
+ for child in self.preview_host.winfo_children():
408
+ child.destroy()
409
+
410
+
411
+ def _apply_archive_to_selected(self, overwrite: bool) -> None:
412
+ idx = self._selected_index()
413
+ if idx is None or idx >= len(self.figures):
414
+ messagebox.showwarning("No selection", "Please select a figure.")
415
+ return
416
+ proj = self.edit_proj_name_var.get().strip()
417
+ folder = self.edit_folder_path_var.get().strip()
418
+ if not proj or not folder:
419
+ messagebox.showwarning("Invalid", "proj_name and folder_path are required.")
420
+ return
421
+ fig = self.figures[idx]
422
+ if overwrite:
423
+ fig.proj_folder = {proj: folder}
424
+ else:
425
+ fig.proj_folder[proj] = folder
426
+ messagebox.showinfo("Applied", "Archive info applied to selected figure.")
427
+
428
+ def _apply_archive_to_all(self, overwrite: bool) -> None:
429
+ proj = self.global_proj_name_var.get().strip()
430
+ folder = self.global_folder_path_var.get().strip()
431
+ if not proj or not folder:
432
+ messagebox.showwarning("Invalid", "proj_name and folder_path are required.")
433
+ return
434
+ for fig in self.figures:
435
+ if overwrite:
436
+ fig.proj_folder = {proj: folder}
437
+ else:
438
+ fig.proj_folder[proj] = folder
439
+ messagebox.showinfo("Applied", "Archive info applied to all figures.")
440
+
441
+ # ---------------- Save operations ----------------
442
+ def _pick_proj_dir(self) -> None:
443
+ d = filedialog.askdirectory(title="Select OPJU directory",initialdir=".", mustexist=True)
444
+ if d:
445
+ self.proj_dir_var.set(str(Path(d).resolve()))
446
+
447
+ def _save_selected_figure(self) -> None:
448
+ idx = self._selected_index()
449
+ if idx is None or idx >= len(self.figures):
450
+ messagebox.showwarning("No selection", "Please select a figure.")
451
+ return
452
+ if not self.figures[idx].proj_folder:
453
+ messagebox.showwarning("Missing archive", "Selected figure has no proj_name:folder_path mapping.")
454
+ return
455
+ target = filedialog.askdirectory(title="Select save directory", mustexist=True)
456
+ if not target:
457
+ return
458
+ proj_dir = Path(target).resolve()
459
+ overwrite = self.save_mode_var.get() == "overwrite"
460
+
461
+ projs = pack_into_project([self.figures[idx]])
462
+
463
+ try:
464
+ with OriginCOM(visible=False) as og:
465
+ for proj_name, proj in projs.items():
466
+ oplot(proj, proj_name, og, proj_dir=proj_dir, overwrite=overwrite)
467
+ except Exception as e:
468
+ messagebox.showerror("Save error", f"Failed to save to OPJU:\n{e}")
469
+ return
470
+
471
+ messagebox.showinfo("Saved", "Selected figure saved.")
472
+
473
+ def _save_all(self) -> None:
474
+ if not self.figures:
475
+ messagebox.showwarning("No figures", "Nothing to save.")
476
+ return
477
+ raw = self.proj_dir_var.get().strip()
478
+ if not raw:
479
+ messagebox.showwarning("Missing directory", "Please choose an OPJU directory first.")
480
+ return
481
+
482
+ proj_dir = Path(raw).expanduser().resolve()
483
+ if not proj_dir.exists():
484
+ messagebox.showwarning("Invalid directory", "Please choose a valid OPJU directory.")
485
+ return
486
+ overwrite = self.save_mode_var.get() == "overwrite"
487
+
488
+ projs = pack_into_project(self.figures)
489
+
490
+ try:
491
+ with OriginCOM(visible=False) as og:
492
+ for proj_name, proj in projs.items():
493
+ oplot(proj, proj_name, og, proj_dir=proj_dir, overwrite=overwrite)
494
+ except Exception as e:
495
+ messagebox.showerror("Save error", f"Failed to save to OPJU:\n{e}")
496
+ return
497
+
498
+ messagebox.showinfo("Saved", "All figures saved.")
499
+
500
+
501
+ def main() -> None:
502
+ root = tk.Tk()
503
+ root.attributes("-topmost", False)
504
+
505
+ style = ttk.Style(root)
506
+ try:
507
+ style.theme_use("clam")
508
+ except Exception:
509
+ pass
510
+
511
+ app = MainWindow(root)
512
+
513
+ def _on_close():
514
+ # 1) Close all matplotlib figures (previews)
515
+ try:
516
+ import matplotlib.pyplot as plt
517
+ plt.close("all")
518
+ except Exception:
519
+ pass
520
+
521
+ # 2) Destroy tkinter root
522
+ try:
523
+ root.quit()
524
+ except Exception:
525
+ pass
526
+ root.destroy()
527
+
528
+ root.protocol("WM_DELETE_WINDOW", _on_close)
529
+ root.mainloop()