GUI#
GalCubeCraft GUI
Compact Tkinter-based GUI to interactively configure and run the
GalCubeCraft generator. Provides a three-column layout of parameter
frames, crisp LaTeX-rendered labels, convenience sliders, and utility
buttons (Generate, Slice, Moments, Spectrum, Save, New). Plotting and file
I/O are intentionally kept out of the generator core; the GUI imports
top-level visualisation helpers (moment0, moment1, spectrum,
slice_view) to display results.
Design notes#
- Lightweight: the GUI focuses on inspection and quick interactive
experimentation, not production batch runs.
- Threading: generation runs in a background thread so the UI remains
responsive; generated figures are produced by the visualise helpers.
- Cleanup: LaTeX labels are rendered to temporary PNG files (via
matplotlib) and tracked in
_MATH_TEMPFILESfor removal when the application exits.
Usage#
Run the module as a script to display the GUI:
python -m GalCubeCraft.gui
Or instantiate GalCubeCraftGUI and call mainloop(). The GUI
expects the package to be importable (it will try a fallback path insertion
when executed as a script).
- class GalCubeCraft.gui.GalCubeCraftGUI[source]#
Bases:
TkMain GUI application for interactively configuring and running
GalCubeCraftsimulations.This class implements a compact, self-contained Tk application that exposes the most commonly-used parameters of the generator via a three-column layout of parameter panels. Controls include numeric sliders, textual inputs and convenience buttons that invoke high-level visualisation helpers (
moment0,moment1,spectrum) or persist generated results to disk.Key behaviour#
- The generator is constructed from the current UI values and stored
on
self.generator. CallingGenerateruns the generator in a background daemon thread so the UI remains responsive; generated results become available viaself.generator.results.
- Visualisation buttons call into functions defined in
GalCubeCraft.visualisewhich create Matplotlib figures; these functions are intentionally separate from the generator core so the GUI remains a thin orchestration layer.
- Temporary files created by
latex_label()are tracked in the module-level
_MATH_TEMPFILESlist and cleaned up when the GUI is closed via_on_close.
- Temporary files created by
Threading and shutdown#
- Generation and save operations spawn background daemon threads. The
UI schedules finalisation callbacks back on the main thread using
self.after(...)when worker threads complete.
- Closing the main window triggers a cleanup of temporary files and
forces process termination to avoid orphaned interpreters. If you prefer a softer shutdown that joins worker threads, modify
_on_closeaccordingly.
Usage example#
Run the GUI as a script:
python -m GalCubeCraft.gui
Or instantiate from Python:
from GalCubeCraft.gui import GalCubeCraftGUI app = GalCubeCraftGUI() app.mainloop()
- create_generator()[source]#
Instantiate a
GalCubeCraftobject from current UI values.The method calls
_collect_parameters()to assemble a parameter dictionary and then constructs a single-cube generator instance with sensible defaults for fields not exposed directly in the GUI. After construction the per-galaxy attributes on the generator are filled from the collected parameters so the generator is ready to run.
- make_slider(parent, label, var, from_, to, resolution=0.01, fmt='{:.2f}', integer=False)[source]#
Create a labelled slider widget with snapping and a value label.
Returns a small frame containing a horizontal
ttk.Scaleand a right-aligned textual value display. The function attaches a trace tovarso programmatic updates are reflected in the slider and vice versa.
- reset_instance()[source]#
Reset the GUI to a fresh state and disable visualisation/save.
This clears the in-memory
self.generatorreference so that the next generate action will create a new instance from current UI values. Buttons that depend on generated results are disabled.
- save_sim()[source]#
Generate (if needed) and save the sim tuple (cube, params).
This runs generation in a background thread and then opens a Save-As dialog on the main thread to let the user choose where to store the result. We support .npz (numpy savez) and .pkl (pickle) formats; complex parameter dicts fall back to pickle.
- class GalCubeCraft.gui.LogWindow(master)[source]#
Bases:
ToplevelTop-level log window that captures and displays stdout/stderr.
LogWindowcreates a simple resizable Toplevel containing a TkTextwidget and installsTextRedirectorinstances onsys.stdoutandsys.stderrso that all subsequentprintoutput and uncaught exception tracebacks are visible in the GUI. The window restores the original streams when closed.Behaviour#
- Creating an instance replaces
sys.stdoutandsys.stderrin the running interpreter until the window is closed (
on_close).
- Creating an instance replaces
- The window configures a separate text tag for
stderrso error messages are coloured differently.
- The window configures a separate text tag for
Example
>>> log = LogWindow(root) >>> log.deiconify() # show the window
- class GalCubeCraft.gui.TextRedirector(widget, tag='stdout')[source]#
Bases:
objectRedirect writes into a Tk
Textwidget behaving like a stream.Use this helper to capture and display program output inside the GUI (for example, to show progress logs, exceptions, or print() output).
TextRedirectorimplements a minimal stream interface (writeandflush) so it can be assigned directly tosys.stdoutorsys.stderr; written text is inserted into the provided Tk Text widget and scrolled to the end so the latest output is visible.Threading note#
The class itself is not thread-safe: writes coming from background threads should be marshalled to the Tk mainloop (e.g. via
widget.after(...)) if there is a risk of concurrent access.
- param widget:
The Tk Text widget where text will be appended.
- type widget:
tk.Text
- param tag:
Optional text tag name to apply to inserted text (default
'stdout').- type tag:
str, optional
Example
Redirect stdout into a Text widget:
txt = tk.Text(root) txt.pack() sys.stdout = TextRedirector(txt, tag='log')
- GalCubeCraft.gui.latex_label(parent, latex, font_size=2)[source]#
Render a LaTeX string to a crisp Tkinter
Labelusing Matplotlib.The routine renders the supplied LaTeX expression using Matplotlib’s mathtext renderer to a high-DPI temporary PNG, crops the image tightly around the rendered text and returns a Tk
Labelcontaining the resulting image. This approach yields sharp text on high-DPI displays without requiring a full TeX installation.Important behaviour and performance notes#
Each call creates a temporary PNG file; filenames are appended to the module-level
_MATH_TEMPFILESlist so they can be removed when the application exits. Callers should ensure the GUI’s cleanup routine callsos.removeon these files (the main GUI does this in_on_close).Rendering is moderately expensive (Matplotlib figure creation and rasterisation). Cache or reuse labels for static text where possible.
The function forces a very high DPI (default 500) and crops the image tightly which keeps runtime acceptable while producing crisp output.
- param parent:
Parent widget to attach the returned
Labelto.- type parent:
tk.Widget
- param latex:
The LaTeX expression (without surrounding dollar signs) to render.
- type latex:
str
- param font_size:
Point-size used for rendering text (passed to Matplotlib).
- type font_size:
int, optional
- returns:
A Tk
Labelwidget containing the rendered LaTeX as an image.- rtype:
tk.Label
Example
>>> lbl = latex_label(frame, r"\alpha + \beta = \gamma", font_size=12) >>> lbl.pack()
- GalCubeCraft.gui.param_frame(parent, padding=8, border_color='#797979', bg='#303030', width=None, height=80)[source]#
Create a framed parameter panel used throughout the GUI.
This helper centralises a common visual pattern used across the application: a thin outer border (contrasting colour) with an inner content frame that holds parameter widgets. The outer frame is packed for convenience; callers receive both frames so they may add labels, sliders, or more complex layouts into the
innerframe while the outer provides a consistent visual outline.Notes
When
widthorheightare provided the inner frame will have its requested size set andpack_propagate(False)will be used to prevent the frame from resizing to its children. This is useful for creating compact, fixed-size parameter panels.The helper packs the
outerframe immediately; this simplifies call-sites but means callers should not re-pack theouter.
- Parameters:
parent (tk.Widget) – Parent widget to attach the frames to (typically a
tk.Frame).padding (int, optional) – Internal padding inside the inner frame (default: 8).
border_color (str, optional) – Colour used for the outer border area (default:
"#797979").bg (str, optional) – Background colour for the inner content frame (default:
"#303030").width (int or None, optional) – When provided, these set fixed dimensions on the inner frame. Use
Noneto allow the inner frame to size to its children.height (int or None, optional) – When provided, these set fixed dimensions on the inner frame. Use
Noneto allow the inner frame to size to its children.
- Returns:
(outer, inner)whereouteris the bordered container Frame (already packed) andinneris the content Frame where widgets should be placed.- Return type:
tuple
Examples
>>> outer, inner = param_frame(parent, padding=10, width=300, height=80) >>> ttk.Label(inner, text='My parameter').pack(anchor='w')
Show source#
Below is the source for the GUI module (kept for reference). The Sphinx viewcode extension also provides cross-linked, highlighted source views.
1"""GalCubeCraft GUI
2
3Compact Tkinter-based GUI to interactively configure and run the
4``GalCubeCraft`` generator. Provides a three-column layout of parameter
5frames, crisp LaTeX-rendered labels, convenience sliders, and utility
6buttons (Generate, Slice, Moments, Spectrum, Save, New). Plotting and file
7I/O are intentionally kept out of the generator core; the GUI imports
8top-level visualisation helpers (``moment0``, ``moment1``, ``spectrum``,
9``slice_view``) to display results.
10
11Design notes
12------------
13- Lightweight: the GUI focuses on inspection and quick interactive
14 experimentation, not production batch runs.
15- Threading: generation runs in a background thread so the UI remains
16 responsive; generated figures are produced by the visualise helpers.
17- Cleanup: LaTeX labels are rendered to temporary PNG files (via
18 matplotlib) and tracked in ``_MATH_TEMPFILES`` for removal when the
19 application exits.
20
21Usage
22-----
23Run the module as a script to display the GUI::
24
25 python -m GalCubeCraft.gui
26
27Or instantiate :class:`GalCubeCraftGUI` and call ``mainloop()``. The GUI
28expects the package to be importable (it will try a fallback path insertion
29when executed as a script).
30"""
31
32import tkinter as tk
33from tkinter import ttk, messagebox, filedialog
34import pickle
35import threading
36import numpy as np
37import matplotlib
38# Use Agg backend to avoid Tkinter threading issues
39# Figures will still display properly when show() is called
40matplotlib.use('Agg')
41import matplotlib.pyplot as plt
42import tempfile
43import os
44import sys
45from PIL import Image, ImageTk
46
47# Track latex PNG tempfiles for cleanup
48_MATH_TEMPFILES = []
49
50import warnings
51
52# Or suppress ALL UserWarnings if you prefer a cleaner log
53warnings.filterwarnings("ignore", category=UserWarning)
54
55# ---------------------------
56# Tweakable parameter frames
57# ---------------------------
58def param_frame(parent, padding=8, border_color="#797979", bg="#303030", width=None, height=80):
59 """Create a framed parameter panel used throughout the GUI.
60
61 This helper centralises a common visual pattern used across the
62 application: a thin outer border (contrasting colour) with an inner
63 content frame that holds parameter widgets. The outer frame is packed
64 for convenience; callers receive both frames so they may add labels,
65 sliders, or more complex layouts into the ``inner`` frame while the
66 outer provides a consistent visual outline.
67
68 Notes
69 -----
70 - When ``width`` or ``height`` are provided the inner frame will have
71 its requested size set and ``pack_propagate(False)`` will be used to
72 prevent the frame from resizing to its children. This is useful for
73 creating compact, fixed-size parameter panels.
74 - The helper packs the ``outer`` frame immediately; this simplifies
75 call-sites but means callers should not re-pack the ``outer``.
76
77 Parameters
78 ----------
79 parent : tk.Widget
80 Parent widget to attach the frames to (typically a :class:`tk.Frame`).
81 padding : int, optional
82 Internal padding inside the inner frame (default: 8).
83 border_color : str, optional
84 Colour used for the outer border area (default: ``"#797979"``).
85 bg : str, optional
86 Background colour for the inner content frame (default: ``"#303030"``).
87 width, height : int or None, optional
88 When provided, these set fixed dimensions on the inner frame. Use
89 ``None`` to allow the inner frame to size to its children.
90
91 Returns
92 -------
93 tuple
94 ``(outer, inner)`` where ``outer`` is the bordered container Frame
95 (already packed) and ``inner`` is the content Frame where widgets
96 should be placed.
97
98 Examples
99 --------
100 >>> outer, inner = param_frame(parent, padding=10, width=300, height=80)
101 >>> ttk.Label(inner, text='My parameter').pack(anchor='w')
102
103 """
104
105 outer = tk.Frame(parent, bg=border_color)
106 outer.pack(padx=4, pady=4) # <--- pack the outer here
107 inner = tk.Frame(outer, bg=bg, padx=padding, pady=padding)
108 if width or height:
109 inner.config(width=width, height=height)
110 inner.pack_propagate(False)
111 inner.pack(fill='both', expand=True)
112 return outer, inner
113
114
115
116
117def latex_label(parent, latex, font_size=2):
118 """Render a LaTeX string to a crisp Tkinter ``Label`` using Matplotlib.
119
120 The routine renders the supplied LaTeX expression using Matplotlib's
121 mathtext renderer to a high-DPI temporary PNG, crops the image tightly
122 around the rendered text and returns a Tk ``Label`` containing the
123 resulting image. This approach yields sharp text on high-DPI displays
124 without requiring a full TeX installation.
125
126 Important behaviour and performance notes
127 -----------------------------------------
128 - Each call creates a temporary PNG file; filenames are appended to the
129 module-level ``_MATH_TEMPFILES`` list so they can be removed when the
130 application exits. Callers should ensure the GUI's cleanup routine
131 calls ``os.remove`` on these files (the main GUI does this in
132 ``_on_close``).
133 - Rendering is moderately expensive (Matplotlib figure creation and
134 rasterisation). Cache or reuse labels for static text where possible.
135 - The function forces a very high DPI (default 500) and crops the
136 image tightly which keeps runtime acceptable while producing crisp
137 output.
138
139 Parameters
140 ----------
141 parent : tk.Widget
142 Parent widget to attach the returned ``Label`` to.
143 latex : str
144 The LaTeX expression (without surrounding dollar signs) to render.
145 font_size : int, optional
146 Point-size used for rendering text (passed to Matplotlib).
147
148 Returns
149 -------
150 tk.Label
151 A Tk ``Label`` widget containing the rendered LaTeX as an image.
152
153 Example
154 -------
155 >>> lbl = latex_label(frame, r"\\alpha + \\beta = \\gamma", font_size=12)
156 >>> lbl.pack()
157
158 """
159 import tkinter as tk
160 import matplotlib.pyplot as plt
161 from PIL import Image, ImageTk
162 import tempfile
163
164 # Render at high DPI
165 DPI = 500
166
167 # Minimal figure; we will crop
168 fig = plt.figure(figsize=(1, 1), dpi=DPI)
169 fig.patch.set_alpha(0.0)
170
171 text = fig.text(0.5, 0.5, f"${latex}$",
172 fontsize=font_size,
173 ha="center", va="center",
174 color="white")
175
176 # Draw and compute tight bounding box
177 fig.canvas.draw()
178 renderer = fig.canvas.get_renderer()
179 bbox = text.get_window_extent(renderer).expanded(1.1, 1.2)
180
181 # Save tightly-cropped
182 tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
183 fig.savefig(tmp.name, dpi=DPI, transparent=True,
184 bbox_inches=bbox.transformed(fig.dpi_scale_trans.inverted()),
185 pad_inches=0.0)
186 plt.close(fig)
187
188 # Load image → convert to RGBA
189 img = Image.open(tmp.name).convert("RGBA")
190
191 # Keep the PIL image in memory to avoid file access issues
192 img.load()
193
194 # Direct Tk image (no scaling)
195 photo = ImageTk.PhotoImage(img)
196
197 label = tk.Label(parent, image=photo, borderwidth=0)
198 # Store both the PhotoImage AND the PIL image to prevent premature GC
199 label.image = photo
200 label._pil_image = img
201 _MATH_TEMPFILES.append(tmp.name)
202
203 label.pack()
204
205 return label
206
207
208# Import core
209try:
210 from .core import GalCubeCraft_Phy
211except Exception:
212 pkg_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
213 if pkg_root not in sys.path:
214 sys.path.insert(0, pkg_root)
215 from GalCubeCraft.core import GalCubeCraft_Phy
216
217# Import visualise helpers (module provides moment0, moment1, spectrum)
218try:
219 from .visualise import *
220except Exception:
221 pkg_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
222 if pkg_root not in sys.path:
223 sys.path.insert(0, pkg_root)
224 from GalCubeCraft.visualise import *
225
226import sys
227import tkinter as tk
228from tkinter import ttk
229
230class TextRedirector:
231 """Redirect writes into a Tk ``Text`` widget behaving like a stream.
232
233 Use this helper to capture and display program output inside the GUI
234 (for example, to show progress logs, exceptions, or print() output).
235 ``TextRedirector`` implements a minimal stream interface (``write`` and
236 ``flush``) so it can be assigned directly to ``sys.stdout`` or
237 ``sys.stderr``; written text is inserted into the provided Tk Text
238 widget and scrolled to the end so the latest output is visible.
239
240 Threading note
241 --------------
242 - The class itself is not thread-safe: writes coming from background
243 threads should be marshalled to the Tk mainloop (e.g. via
244 ``widget.after(...)``) if there is a risk of concurrent access.
245
246 Parameters
247 ----------
248 widget : tk.Text
249 The Tk Text widget where text will be appended.
250 tag : str, optional
251 Optional text tag name to apply to inserted text (default ``'stdout'``).
252
253 Example
254 -------
255 Redirect stdout into a Text widget::
256
257 txt = tk.Text(root)
258 txt.pack()
259 sys.stdout = TextRedirector(txt, tag='log')
260
261 """
262
263 def __init__(self, widget, tag="stdout"):
264 self.widget = widget
265 self.tag = tag
266
267 def write(self, string):
268 self.widget.configure(state="normal")
269 self.widget.insert("end", string, (self.tag,))
270 self.widget.see("end")
271 self.widget.configure(state="disabled")
272
273 def flush(self):
274 pass # Needed for compatibility with sys.stdout
275
276class LogWindow(tk.Toplevel):
277 """Top-level log window that captures and displays stdout/stderr.
278
279 ``LogWindow`` creates a simple resizable Toplevel containing a Tk
280 ``Text`` widget and installs ``TextRedirector`` instances on
281 ``sys.stdout`` and ``sys.stderr`` so that all subsequent ``print``
282 output and uncaught exception tracebacks are visible in the GUI. The
283 window restores the original streams when closed.
284
285 Behaviour
286 ---------
287 - Creating an instance replaces ``sys.stdout`` and ``sys.stderr`` in
288 the running interpreter until the window is closed (``on_close``).
289 - The window configures a separate text tag for ``stderr`` so error
290 messages are coloured differently.
291
292 Example
293 -------
294 >>> log = LogWindow(root)
295 >>> log.deiconify() # show the window
296
297 """
298
299 def __init__(self, master):
300 super().__init__(master)
301 self.title("Logs")
302 self.text = tk.Text(self)
303 self.text.pack(fill="both", expand=True)
304 self.text.tag_configure("stderr", foreground="#e55b5b")
305 # Redirect stdout and stderr
306 sys.stdout = TextRedirector(self.text, "stdout")
307 sys.stderr = TextRedirector(self.text, "stderr")
308 self.protocol("WM_DELETE_WINDOW", self.on_close)
309
310 def on_close(self):
311 # Optionally restore stdout/stderr
312 sys.stdout = sys.__stdout__
313 sys.stderr = sys.__stderr__
314 self.destroy()
315
316
317class GalCubeCraftGUI(tk.Tk):
318 """Main GUI application for interactively configuring and running
319 ``GalCubeCraft`` simulations.
320
321 This class implements a compact, self-contained Tk application that
322 exposes the most commonly-used parameters of the generator via a
323 three-column layout of parameter panels. Controls include numeric
324 sliders, textual inputs and convenience buttons that invoke high-level
325 visualisation helpers (``moment0``, ``moment1``, ``spectrum``) or
326 persist generated results to disk.
327
328 Key behaviour
329 --------------
330 - The generator is constructed from the current UI values and stored
331 on ``self.generator``. Calling ``Generate`` runs the generator in a
332 background daemon thread so the UI remains responsive; generated
333 results become available via ``self.generator.results``.
334 - Visualisation buttons call into functions defined in
335 :mod:`GalCubeCraft.visualise` which create Matplotlib figures; these
336 functions are intentionally separate from the generator core so the
337 GUI remains a thin orchestration layer.
338 - Temporary files created by :func:`latex_label` are tracked in the
339 module-level ``_MATH_TEMPFILES`` list and cleaned up when the GUI is
340 closed via ``_on_close``.
341
342 Threading and shutdown
343 ----------------------
344 - Generation and save operations spawn background daemon threads. The
345 UI schedules finalisation callbacks back on the main thread using
346 ``self.after(...)`` when worker threads complete.
347 - Closing the main window triggers a cleanup of temporary files and
348 forces process termination to avoid orphaned interpreters. If you
349 prefer a softer shutdown that joins worker threads, modify
350 ``_on_close`` accordingly.
351
352 Usage example
353 -------------
354 Run the GUI as a script::
355
356 python -m GalCubeCraft.gui
357
358 Or instantiate from Python::
359
360 from GalCubeCraft.gui import GalCubeCraftGUI
361 app = GalCubeCraftGUI()
362 app.mainloop()
363
364 """
365
366 def __init__(self):
367 super().__init__()
368 self.title('GalCubeCraft GUI')
369 self.WINDOW_WIDTH = 650
370 self.WINDOW_HEIGHT = 810
371 self.geometry(f"{self.WINDOW_WIDTH}x{self.WINDOW_HEIGHT}")
372 self.resizable(False, False)
373 # Create a hidden log window immediately
374 self.log_window = LogWindow(self)
375 self.log_window.withdraw() # Hide it until "Logs" button clicked
376 # Track if we're closing to prevent thread issues
377 self._is_closing = False
378
379
380
381 # Banner image: load assets/cubecraft.png (fallback to text label)
382 try:
383 banner_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'assets', 'cubecraft.png'))
384 if not os.path.exists(banner_path):
385 banner_path = os.path.abspath(os.path.join(os.path.dirname(__file__), 'assets', 'cubecraft.png'))
386 original_img = Image.open(banner_path).convert("RGBA")
387 target_width = self.WINDOW_WIDTH - 0
388 aspect = original_img.height / original_img.width
389 resized = original_img.resize((target_width, int(target_width * aspect)), Image.LANCZOS)
390 self.banner_image = ImageTk.PhotoImage(resized)
391 banner_lbl = ttk.Label(self, image=self.banner_image)
392 banner_lbl.pack(pady=(8,6))
393 except Exception:
394 ttk.Label(self, text="GalCubeCraft", font=('Helvetica', 18, 'bold')).pack(pady=(8,6))
395
396
397 # Scrollable canvas + container frame for a compact, scrollable UI
398 self.main_canvas = tk.Canvas(self, highlightthickness=0)
399 self.scrollbar = ttk.Scrollbar(self, orient='vertical', command=self.main_canvas.yview)
400 self.main_canvas.configure(yscrollcommand=self.scrollbar.set)
401 self.scrollbar.pack(side='right', fill='y')
402 self.main_canvas.pack(fill='both', expand=True)
403 self.container = ttk.Frame(self.main_canvas)
404 self.window = self.main_canvas.create_window((0,0), window=self.container, anchor='nw')
405 self.container.bind('<Configure>', lambda e: self.main_canvas.configure(scrollregion=self.main_canvas.bbox('all')))
406 self.main_canvas.bind('<Configure>', lambda e: self.main_canvas.itemconfig(self.window, width=e.width))
407
408
409
410 # Generator
411 self.generator = None
412
413 # Build 3-column layout (parameter panels + controls)
414 self._build_widgets()
415
416 self.protocol('WM_DELETE_WINDOW', self._on_close)
417
418
419
420 # ---------------------------
421 # Slider helper
422 # ---------------------------
423 def make_slider(self, parent, label, var, from_, to,
424 resolution=0.01, fmt="{:.2f}", integer=False):
425 """Create a labelled slider widget with snapping and a value label.
426
427 Returns a small frame containing a horizontal ``ttk.Scale`` and a
428 right-aligned textual value display. The function attaches a trace
429 to ``var`` so programmatic updates are reflected in the slider and
430 vice versa.
431 """
432
433 fr = ttk.Frame(parent)
434 if label:
435 ttk.Label(fr, text=label).pack(anchor='w', pady=(0,2))
436 slider_row = ttk.Frame(fr)
437 slider_row.pack(fill='x')
438 val_lbl = ttk.Label(slider_row, text=fmt.format(var.get()), width=6, anchor="e")
439 val_lbl.pack(side='right', padx=(4,0))
440 scale = ttk.Scale(slider_row, from_=from_, to=to, orient='horizontal')
441 scale.pack(side='left', fill='x', expand=True)
442 step = resolution if resolution else 0.01
443 busy = {'val':False}
444 def snap(v):
445 if integer:
446 return int(round(float(v)))
447 nsteps = round((float(v)-from_)/step)
448 return from_ + nsteps*step
449 def update(v):
450 if busy['val']: return
451 busy['val']=True
452 v_snap = snap(v)
453 try: var.set(v_snap)
454 except Exception: pass
455 try: val_lbl.config(text=fmt.format(v_snap))
456 except Exception: val_lbl.config(text=str(v_snap))
457 try: scale.set(v_snap)
458 except Exception: pass
459 busy['val']=False
460 scale.configure(command=update)
461 try: scale.set(var.get())
462 except Exception: scale.set(from_)
463 try:
464 def _var_trace(*_):
465 if busy['val']: return
466 busy['val']=True
467 v = var.get()
468 try: val_lbl.config(text=fmt.format(v))
469 except Exception: val_lbl.config(text=str(v))
470 try: scale.set(v)
471 except Exception: pass
472 busy['val']=False
473 if hasattr(var, 'trace_add'):
474 var.trace_add('write', _var_trace)
475 else:
476 var.trace('w', _var_trace)
477 except Exception: pass
478 return fr
479
480
481 # ---------------------------
482 # Button callback methods
483 # ---------------------------
484 def show_logs(self):
485 if hasattr(self, 'log_window') and self.log_window.winfo_exists():
486 self.log_window.lift()
487 else:
488 self.log_window = LogWindow(self)
489
490
491
492 def _popup_figure(self, title, fig):
493 """Utility to put a matplotlib figure into a new popup window"""
494 new_win = tk.Toplevel(self)
495 new_win.title(title)
496
497 # Use the FigureCanvasTkAgg to embed the plot
498 from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
499 canvas = FigureCanvasTkAgg(fig, master=new_win)
500 canvas.draw()
501 canvas.get_tk_widget().pack(fill='both', expand=True)
502
503 def show_moments(self):
504 if not self.generator:
505 return
506 try:
507 # Generate the figures using the 'Agg' backend (already set)
508 fig0, _ = moment0(self.generator.results, idx=0, save=False)
509 self._popup_figure("Moment 0", fig0)
510
511 fig1, _ = moment1(self.generator.results, idx=0, save=False)
512 self._popup_figure("Moment 1", fig1)
513 except Exception as e:
514 print(f"Error displaying moments: {e}")
515
516 def show_spectra(self):
517 if not self.generator:
518 return
519 try:
520 fig, _ = spectrum(self.generator.results, idx=0, save=False)
521 self._popup_figure("Integrated Spectrum", fig)
522 except Exception as e:
523 print(f"Error displaying spectrum: {e}")
524
525
526 def show_slice(self):
527 """Display an interactive spectral-slice viewer for the first cube.
528
529 Uses the helper in ``visualise.slice_view`` which provides an
530 interactive Matplotlib Slider to step through spectral channels.
531 """
532 if self.generator:
533 try:
534 # Temporarily switch to TkAgg for interactive display
535 import matplotlib
536 matplotlib.use('TkAgg')
537 # Pass the main window as parent so the viewer is a child Toplevel
538 # Do not force channel=0 here; allow the viewer to choose its
539 # default (central slice) when channel is None.
540 fig, ax = slice_view(self.generator.results, idx=0, channel=None, parent=self)
541 matplotlib.use('Agg')
542 except Exception as e:
543 import matplotlib
544 matplotlib.use('Agg')
545 messagebox.showerror('Slice viewer error', str(e))
546
547 def show_mom1(self):
548 if self.generator:
549 fig, ax = moment1(self.generator.results, idx=0, save=False)
550 try:
551 import matplotlib
552 matplotlib.use('TkAgg')
553 plt.figure(fig.number)
554 plt.show(block=False)
555 matplotlib.use('Agg')
556 except Exception:
557 pass
558
559 '''def show_spectra(self):
560 if self.generator:
561 fig, ax = spectrum(self.generator.results, idx=0, save=False)
562 try:
563 import matplotlib
564 matplotlib.use('TkAgg')
565 plt.figure(fig.number)
566 plt.show(block=False)
567 matplotlib.use('Agg')
568 except Exception:
569 pass'''
570
571 def reset_instance(self):
572 """Reset the GUI to a fresh state and disable visualisation/save.
573
574 This clears the in-memory ``self.generator`` reference so that the
575 next generate action will create a new instance from current UI
576 values. Buttons that depend on generated results are disabled.
577 """
578 # Disable all except generate
579 try:
580 self.moments_btn.config(state='disabled')
581 except Exception:
582 # Fallback: older versions may have separate buttons
583 try:
584 self.mom0_btn.config(state='disabled')
585 except Exception:
586 pass
587 try:
588 self.mom1_btn.config(state='disabled')
589 except Exception:
590 pass
591 self.spectra_btn.config(state='disabled')
592 try:
593 self.slice_btn.config(state='disabled')
594 except Exception:
595 pass
596 # Also disable Save when starting a fresh instance
597 try:
598 self.save_btn.config(state='disabled')
599 except Exception:
600 pass
601 for child in self.winfo_children():
602 if isinstance(child, tk.Toplevel):
603 child.destroy()
604
605 self.generator = None
606
607 def _find_scale_in(self, widget):
608 """Recursively find a ttk.Scale inside a widget tree.
609
610 Returns the first found Scale or None.
611 """
612 if isinstance(widget, ttk.Scale):
613 return widget
614 for c in widget.winfo_children():
615 found = self._find_scale_in(c)
616 if found is not None:
617 return found
618 return None
619
620 def _set_sliders_enabled(self, enabled=True):
621 """Enable or disable all slider widgets present in the GUI.
622
623 This toggles the internal ttk.Scale widget state for each slider
624 frame we create in :meth:`_build_widgets`.
625 """
626 names = [
627 'r_slider', 'n_slider', 'hz_slider', 'sigma_slider',
628 'grid_slider', 'spec_slider', 'angle_x_slider', 'angle_y_slider',
629 'sat_offset_slider_frame'
630 ]
631 for name in names:
632 w = getattr(self, name, None)
633 if w is None:
634 continue
635 try:
636 scale = self._find_scale_in(w)
637 if scale is None:
638 continue
639 if enabled:
640 try:
641 scale.state(['!disabled'])
642 except Exception:
643 scale.configure(state=tk.NORMAL)
644 else:
645 try:
646 scale.state(['disabled'])
647 except Exception:
648 scale.configure(state=tk.DISABLED)
649 except Exception:
650 # Best-effort: ignore any widget-specific errors
651 pass
652
653
654
655
656 # ---------------------------
657 # Build all widgets
658 # ---------------------------
659 def _build_widgets(self):
660
661 """Build and layout all GUI widgets.
662
663 This method assembles the complete UI inside the scrollable
664 container: it defines Tk variables, creates the three-column
665 parameter panels (rows 1--6), the slider widgets, and the bottom
666 utility buttons (Generate, Moment0, Moment1, Spectra, Save, New).
667
668 The method also hooks variable traces to an auto-update helper so
669 that changing parameters in the UI will keep an internal
670 ``GalCubeCraft`` generator in sync for quick inspection.
671
672 Notes
673 -----
674 - This method focuses on layout and widget creation; no heavy
675 computation is performed here.
676 - For clarity we keep layout logic (pack) local to this helper so
677 other methods can assume the widgets exist after this call.
678 """
679
680 # ---------------------------
681 # Variables
682 # ---------------------------
683 self.bmin_var = tk.DoubleVar(value=11.0)
684 self.bmaj_var = tk.DoubleVar(value=13.0)
685 self.bpa_var = tk.DoubleVar(value=20.0)
686 self.spatial_resolution = tk.DoubleVar(value=3.8)
687 self.n_var = tk.DoubleVar(value=1.0)
688 self.hz_var = tk.DoubleVar(value=0.8)
689 self.Se_var = tk.DoubleVar(value=0.1)
690 self.sigma_v_var = tk.DoubleVar(value=40.0)
691 self.fov = tk.IntVar(value=275)
692 self.spectral_resolution = tk.IntVar(value=20)
693 self.angle_x_var = tk.IntVar(value=45)
694 self.angle_y_var = tk.IntVar(value=30)
695 self.n_gals_var = tk.IntVar(value=1)
696
697 col_width = 310 # column width
698
699 # ---------------------------
700 # Row 1: Number of galaxies + Satellite offset
701 # ---------------------------
702 r1 = ttk.Frame(self.container)
703 r1.pack(fill='x', pady=4)
704
705 # Number of galaxies frame (radio buttons 1–6)
706 outer1, fr1 = param_frame(r1, width=col_width)
707 outer1.pack(side='left', padx=6, fill='y')
708 latex_label(fr1, r"\text{Number of galaxies}").pack(anchor='w', pady=(0,6))
709 rb_frame = ttk.Frame(fr1)
710 rb_frame.pack(anchor='w')
711 for val in range(1, 7):
712 rb = ttk.Radiobutton(rb_frame, text=str(val), variable=self.n_gals_var, value=val)
713 rb.pack(side='left', padx=4)
714
715
716 # Spatial resolution frame (kpc per pixel)
717 outer2, fr2 = param_frame(r1, width=col_width)
718 outer2.pack(side='left', padx=6, fill='y')
719 latex_label(fr2, r"\text{Spatial Resolution } (\Delta_{X,Y}) \: {\rm [kpc\;px^{-1}]}").pack(anchor='w')
720 self.pix_scale_var_slider = self.make_slider(fr2, "", self.spatial_resolution, 0.72, 9.0, resolution=0.01, fmt="{:.2f}")
721 self.pix_scale_var_slider.pack(fill='x')
722
723
724
725
726 # ---------------------------
727 # Row 2: FOV + Beam
728 # ---------------------------
729 r2 = ttk.Frame(self.container)
730 r2.pack(fill='x', pady=4)
731
732 # --- FOV frame ---
733 outer1, fr1 = param_frame(r2, width=col_width)
734 outer1.pack(side='left', padx=6, fill='y')
735
736 # LaTeX-style label for the section
737 latex_label(fr1, r"\text{Field of View [kpc]}").pack(anchor='w', pady=(0,6))
738
739 # --- Input row (pixel values) ---
740 fov_row = ttk.Frame(fr1)
741 fov_row.pack(anchor='w', pady=2)
742
743 entry_width = 4 # width for entry boxes
744
745 # Variables: bmin/bmaj (kpc), BPA (deg), spatial resolution already defined
746
747 # Pixel inputs
748 for text, var in [
749 (r"FOV_{X} \:\:;\:\: FOV_{Y}\:", self.fov),
750 ]:
751 lbl = latex_label(fov_row, text)
752 lbl.pack(side='left', padx=(0,2))
753 e = ttk.Entry(fov_row, textvariable=var, width=entry_width)
754 e.pack(side='left', padx=(0,6))
755
756
757 # --- Beam frame ---
758 outer2, fr2 = param_frame(r2, width=col_width)
759 outer2.pack(side='left', padx=4, fill='y')
760
761 # LaTeX-style label for the section
762 latex_label(fr2, r"\text{Beam Information [kpc , kpc , deg]}").pack(anchor='w', pady=(0,6))
763
764 # --- Input row (pixel values) ---
765 beam_row = ttk.Frame(fr2)
766 beam_row.pack(anchor='w', pady=2)
767
768 entry_width = 3 # width for entry boxes
769
770 # Variables: bmin/bmaj (kpc), BPA (deg)
771
772 # Pixel inputs
773 for text, var in [
774 (r"B_{\rm min}", self.bmin_var),
775 (r"B_{\rm maj}", self.bmaj_var),
776 (r"\rm BPA", self.bpa_var)
777 ]:
778 lbl = latex_label(beam_row, text)
779 lbl.pack(side='left', padx=(0,2))
780 e = ttk.Entry(beam_row, textvariable=var, width=entry_width)
781 e.pack(side='left', padx=(0,6))
782
783
784
785
786 # ---------------------------
787 # Row 3: Sérsic n + Scale height
788 # ---------------------------
789 r3 = ttk.Frame(self.container)
790 r3.pack(fill='x', pady=4)
791
792 outer1, fr1 = param_frame(r3, width=col_width)
793 outer1.pack(side='left', padx=6, fill='y')
794 latex_label(fr1, r"\text{Sérsic index } (n) \: [-]").pack(anchor='w')
795 self.n_slider = self.make_slider(fr1, "", self.n_var, 0.5, 1.5, resolution=0.01, fmt="{:.3f}")
796 self.n_slider.pack(fill='x')
797
798 outer2, fr2 = param_frame(r3, width=col_width)
799 outer2.pack(side='left', padx=6, fill='y')
800 latex_label(fr2, r"\text{Scale height } (h_z) \ [\text{kpc}]").pack(anchor='w')
801 self.hz_slider = self.make_slider(fr2, "", self.hz_var, 0.4, 9.0, resolution=0.01, fmt="{:.3f}")
802 self.hz_slider.pack(fill='x')
803
804 # ---------------------------
805 # Row 4: Central effective flux density (S_e) + Satellite offset
806 # ---------------------------
807 r4 = ttk.Frame(self.container)
808 r4.pack(fill='x', pady=4)
809
810 outer1, fr1 = param_frame(r4, width=col_width)
811 outer1.pack(side='left', padx=6, fill='y')
812 latex_label(fr1, r"\text{Central effective flux density } (S_e) \ [\text{Jy}]").pack(anchor='w')
813 ttk.Entry(fr1, textvariable=self.Se_var).pack(fill='x')
814
815 # Satellite offset frame (distance from primary centre in kpc)
816 outer2, fr2 = param_frame(r4, width=col_width)
817 outer2.pack(side='left', padx=6, fill='y')
818 latex_label(fr2, r"\text{Satellite offset from centre [kpc]}").pack(anchor='w', pady=(0,6))
819 # Create slider and keep a reference to the underlying ttk.Scale
820 self.sat_offset_var = tk.DoubleVar(value=5.0)
821 self.sat_offset_slider_frame = self.make_slider(
822 fr2, "", self.sat_offset_var, 5.0, 100.0, resolution=0.1, fmt="{:.1f}"
823 )
824 self.sat_offset_slider_frame.pack(fill='x')
825
826 # Find the ttk.Scale inside the composed slider frame
827 def find_scale(widget):
828 if isinstance(widget, ttk.Scale):
829 return widget
830 for child in widget.winfo_children():
831 result = find_scale(child)
832 if result is not None:
833 return result
834 return None
835
836 self.sat_offset_scale = find_scale(self.sat_offset_slider_frame)
837
838 # Disable satellite offset when only 1 galaxy is selected
839 if self.n_gals_var.get() == 1:
840 self.sat_offset_scale.state(['disabled'])
841
842 # Auto-enable/disable satellite offset slider when n_gals changes
843 def _update_sat_offset(*args):
844 active = self.n_gals_var.get() > 1
845 if active:
846 self.sat_offset_scale.state(['!disabled'])
847 else:
848 self.sat_offset_scale.state(['disabled'])
849
850 if hasattr(self.n_gals_var, 'trace_add'):
851 self.n_gals_var.trace_add('write', _update_sat_offset)
852 else:
853 self.n_gals_var.trace('w', _update_sat_offset)
854
855
856 # ---------------------------
857 # Row 5: Spectral resolution + velocity dispersion
858 # ---------------------------
859 r5 = ttk.Frame(self.container)
860 r5.pack(fill='x', pady=4)
861
862
863 outer1, fr1 = param_frame(r5, width=col_width)
864 outer1.pack(side='left', padx=6, fill='y')
865 latex_label(fr1, r"\text{Spectral Resolution }(\Delta_{v_z})\ [km\;s^{-1}]").pack(anchor='w')
866 self.spec_slider = self.make_slider(fr1, "", self.spectral_resolution, 5, 40, resolution=5, fmt="{:d}", integer=True)
867 self.spec_slider.pack(fill='x')
868
869 outer2, fr2 = param_frame(r5, width=col_width)
870 outer2.pack(side='left', padx=6, fill='y')
871 latex_label(fr2, r"\text{Velocity dispersion }(\sigma_{v_z})\ [km\;s^{-1}]").pack(anchor='w')
872 self.sigma_slider = self.make_slider(fr2, "", self.sigma_v_var, 30.0, 60.0, resolution=0.1, fmt="{:.1f}")
873 self.sigma_slider.pack(fill='x')
874
875 # ---------------------------
876 # Row 6: Inclination angle (θ_X) + Azimuthal angle (ϕ_Y)
877 # ---------------------------
878 r6 = ttk.Frame(self.container)
879 r6.pack(fill='x', pady=4)
880
881 outer1, fr1 = param_frame(r6, width=col_width)
882 outer1.pack(side='left', padx=6, fill='y')
883 latex_label(fr1, r"\text{Inclination angle }(\theta_X) \text{ [deg]}").pack(anchor='w')
884 self.angle_x_slider = self.make_slider(fr1, "", self.angle_x_var, 0, 359, resolution=1, fmt="{:d}", integer=True)
885 self.angle_x_slider.pack(fill='x')
886
887 outer2, fr2 = param_frame(r6, width=col_width)
888 outer2.pack(side='left', padx=6, fill='y')
889 latex_label(fr2, r"\text{Azimuthal angle }(\phi_Y) \text{ [deg]}").pack(anchor='w')
890 self.angle_y_slider = self.make_slider(fr2, "", self.angle_y_var, 0, 359, resolution=1, fmt="{:d}", integer=True)
891 self.angle_y_slider.pack(fill='x')
892
893 # ---------------------------
894 # Generate & utility buttons (Generate, Slice, Moments, Spectrum, Save, New)
895 # ---------------------------
896 btn_frame = ttk.Frame(self)
897 btn_frame.pack(side='bottom', pady=8, fill='x')
898
899 # Button height (bigger than normal)
900 btn_height = 2
901 # Create buttons as ttk with a compact dark style so we don't change
902 # the global theme but render dark buttons reliably on macOS.
903 btn_fg = 'white'
904 btn_disabled_fg = '#8c8c8c'
905 btn_bg = '#222222'
906 btn_active_bg = '#2f2f2f'
907
908 style = ttk.Style()
909 # Do not change the global theme; just define a local style
910 style.configure('Dark.TButton', background=btn_bg, foreground=btn_fg, height=btn_height, padding=(0,0))
911 style.map('Dark.TButton',
912 background=[('active', btn_active_bg), ('disabled', btn_bg), ('!disabled', btn_bg)],
913 foreground=[('disabled', btn_disabled_fg), ('!disabled', btn_fg)])
914
915 # Create as ttk.Button with the dark style (keeps rest of theme intact)
916 self.generate_btn = ttk.Button(btn_frame, text='Generate', command=self.generate, style='Dark.TButton', width=5)
917 self.slice_btn = ttk.Button(btn_frame, text='Slice', command=self.show_slice, state='disabled', style='Dark.TButton', width=5)
918 # Combined Moments button: shows both Moment0 and Moment1 windows
919 self.moments_btn = ttk.Button(btn_frame, text='Moments', command=self.show_moments, state='disabled', style='Dark.TButton', width=5)
920 self.spectra_btn = ttk.Button(btn_frame, text='Spectrum', command=self.show_spectra, state='disabled', style='Dark.TButton', width=5)
921 # The "New" button resets the GUI to a fresh instance. Make it
922 # visible by default (enabled) so users can quickly clear state.
923 # It will be disabled by reset_instance when appropriate.
924 self.new_instance_btn = ttk.Button(btn_frame, text='Reset', command=self.reset_instance, state='disabled', style='Dark.TButton', width=5)
925
926 # Pack buttons side by side with padding; Save before New (New last)
927 self.save_btn = ttk.Button(btn_frame, text='Save', command=self.save_sim, state='disabled', style='Dark.TButton', width=5)
928 for btn in [self.generate_btn, self.slice_btn, self.moments_btn, self.spectra_btn, self.save_btn, self.new_instance_btn]:
929 btn.pack(side='left', padx=4, pady=2, expand=True, fill='x')
930
931
932
933
934 # Auto-create/refresh generator when variables change (fast preview)
935 def _auto_update_generator(*args):
936 try:
937 self.create_generator()
938 except Exception as e:
939 print("Auto-create generator failed:", e)
940
941 for var in [self.bmin_var, self.bmaj_var, self.bpa_var, self.spatial_resolution, self.n_var,
942 self.hz_var, self.Se_var, self.sigma_v_var, self.fov,
943 self.spectral_resolution, self.angle_x_var, self.angle_y_var]:
944 if hasattr(var, 'trace_add'):
945 var.trace_add('write', _auto_update_generator)
946 else:
947 var.trace('w', _auto_update_generator)
948
949
950 # ---------------------------
951 # Parameter collection & generator
952 # ---------------------------
953
954
955 def _collect_parameters(self):
956 """Read current UI controls and return a parameter dict.
957
958 The returned dictionary mirrors the small set of fields used by the
959 :class:`GalCubeCraft` constructor and the GUI. Values are converted
960 to plain Python / NumPy types where appropriate.
961
962 Returns
963 -------
964 params : dict
965 Dictionary containing keys like ``beam_info``, ``n_gals``,
966 ``grid_size``, ``n_spectral_slices``, ``all_Re``, ``all_hz``,
967 ``all_Se``, ``all_n``, and ``sigma_v``. This dict is consumed by
968 :meth:`create_generator` and used when saving.
969 """
970
971 bmin = float(self.bmin_var.get())
972 bmaj = float(self.bmaj_var.get())
973 bpa = float(self.bpa_var.get())
974 n_gals = int(self.n_gals_var.get())
975 fov = int(self.fov.get())
976 spectral_resolution = int(self.spectral_resolution.get())
977 spatial_resolution = int(self.spatial_resolution.get())
978 central_n = float(self.n_var.get())
979 central_hz = float(self.hz_var.get())
980 central_Se = float(self.Se_var.get())
981 central_gal_x_angle = int(self.angle_x_var.get())
982 central_gal_y_angle = int(self.angle_y_var.get())
983 offset_gals = float(self.sat_offset_var.get())
984 sigma_v = float(self.sigma_v_var.get())
985
986 # Create per-galaxy lists. For a single galaxy we keep the
987 # specified central values. For multiple galaxies we generate
988 # satellite properties using simple random draws so the
989 # generator receives arrays of length ``n_gals`` (primary + satellites).
990 all_Re = [5/spatial_resolution]
991 all_hz = [central_hz]
992 all_Se = [central_Se]
993 all_gal_x_angles = [central_gal_x_angle]
994 all_gal_y_angles = [central_gal_y_angle]
995 all_n = [central_n]
996
997 if n_gals > 1:
998 n_sat = n_gals - 1
999 rng = np.random.default_rng()
1000
1001 # Satellites are smaller and fainter than the primary
1002 sat_Re = list(rng.uniform(all_Re[0] / 3.0, all_Re[0] / 2.0, n_sat))
1003 sat_hz = list(rng.uniform(all_hz[0] / 3.0, all_hz[0] / 2.0, n_sat))
1004 sat_Se = list(rng.uniform(all_Se[0] / 3.0, all_Se[0] / 2.0, n_sat))
1005
1006 # Random Sérsic indices for satellites
1007 sat_n = list(rng.uniform(0.5, 1.5, n_sat))
1008
1009 # Random orientations for satellites (degrees)
1010 sat_x_angles = list(rng.uniform(-180.0, 180.0, n_sat))
1011 sat_y_angles = list(rng.uniform(-180.0, 180.0, n_sat))
1012
1013 all_Re += sat_Re
1014 all_hz += sat_hz
1015 all_Se += sat_Se
1016 all_n += sat_n
1017 all_gal_x_angles += sat_x_angles
1018 all_gal_y_angles += sat_y_angles
1019
1020 # Convert lists to NumPy arrays to match generator expectations
1021 all_Re = np.array(all_Re)
1022 all_hz = np.array(all_hz)
1023 all_Se = np.array(all_Se)
1024 all_n = np.array(all_n)
1025 all_gal_x_angles = np.array(all_gal_x_angles)
1026 all_gal_y_angles = np.array(all_gal_y_angles)
1027
1028 params = dict(
1029 beam_info=[bmin,bmaj,bpa],
1030 n_gals=n_gals,
1031 fov=fov,
1032 spectral_resolution=spectral_resolution,
1033 spatial_resolution=spatial_resolution,
1034 all_Re=np.array(all_Re),
1035 all_hz=np.array(all_hz),
1036 all_Se=np.array(all_Se),
1037 all_n=np.array(all_n),
1038 all_gal_x_angles=np.array(all_gal_x_angles),
1039 all_gal_y_angles=np.array(all_gal_y_angles),
1040 sigma_v=sigma_v,
1041 offset_gals=offset_gals,
1042 )
1043 return params
1044
1045 def create_generator(self):
1046 """Instantiate a :class:`GalCubeCraft` object from current UI values.
1047
1048 The method calls :meth:`_collect_parameters` to assemble a parameter
1049 dictionary and then constructs a single-cube generator instance with
1050 sensible defaults for fields not exposed directly in the GUI. After
1051 construction the per-galaxy attributes on the generator are filled
1052 from the collected parameters so the generator is ready to run.
1053 """
1054
1055 params = self._collect_parameters()
1056 try:
1057 g = GalCubeCraft_Phy(
1058 n_gals=params['n_gals'],
1059 n_cubes=1,
1060 spatial_resolution=params['spatial_resolution'],
1061 spectral_resolution=params['spectral_resolution'],
1062 offset_gals=params['offset_gals'],
1063 beam_info=params['beam_info'],
1064 fov=params['fov'],
1065 verbose=True,
1066 seed=None
1067 )
1068 except Exception as e:
1069 messagebox.showerror('Error', f'Failed to create GalCubeCraft: {e}')
1070 return
1071
1072 # Fill the galaxy-specific properties
1073 n_g = params['n_gals']
1074 g.all_Re = [params['all_Re']]
1075 g.all_hz = [params['all_hz']]
1076 g.all_Se = [params['all_Se']]
1077 g.all_n = [params['all_n']]
1078 g.all_gal_x_angles = [params['all_gal_x_angles']]
1079 g.all_gal_y_angles = [params['all_gal_y_angles']]
1080 g.all_gal_vz_sigmas = [np.full(n_g, params['sigma_v'])]
1081 #g.all_pix_spatial_scales = [np.full(n_g, params['spatial_resolution'])]
1082 g.all_gal_v_0 = [np.full(n_g, 200.0)] # default systemic velocity
1083
1084 self.generator = g
1085
1086
1087 def _run_generate(self):
1088 # Disable garbage collection in this thread to prevent cleanup
1089 # of Tkinter objects from the wrong thread
1090 import gc
1091 gc_was_enabled = gc.isenabled()
1092 gc.disable()
1093
1094 try:
1095 # Check if closing before doing expensive work
1096 if self._is_closing:
1097 return
1098
1099 # Auto-show log window
1100 if hasattr(self, 'log_window') and self.log_window.winfo_exists():
1101 self.log_window.deiconify()
1102 self.log_window.lift()
1103 else:
1104 self.log_window = LogWindow(self)
1105
1106 try:
1107 results = self.generator.generate_cubes()
1108 # Check again before scheduling UI updates
1109 if self._is_closing:
1110 return
1111 # Enable buttons on main thread
1112 self.after(0, lambda: [
1113 self.moments_btn.config(state='normal'),
1114 self.spectra_btn.config(state='normal'),
1115 self.slice_btn.config(state='normal'),
1116 self.save_btn.config(state='normal'),
1117 self.new_instance_btn.config(state='normal'),
1118 ])
1119 except Exception as e:
1120 if not self._is_closing:
1121 self.after(0, lambda e=e: messagebox.showerror('Error during generation', str(e)))
1122 finally:
1123 # Re-enable garbage collection if it was enabled
1124 if gc_was_enabled:
1125 gc.enable()
1126
1127
1128 def generate(self):
1129 # Always create a fresh generator from current UI values
1130 # so that changes to n_gals or sliders are captured
1131 self.create_generator()
1132
1133 if self.generator is None:
1134 return
1135
1136 t = threading.Thread(target=self._run_generate, daemon=True)
1137 t.start()
1138
1139 # ---------------------------
1140 # Save simulation (cube + params)
1141 # ---------------------------
1142 def save_sim(self):
1143 """Generate (if needed) and save the sim tuple (cube, params).
1144
1145 This runs generation in a background thread and then opens a
1146 Save-As dialog on the main thread to let the user choose where
1147 to store the result. We support .npz (numpy savez) and .pkl
1148 (pickle) formats; complex parameter dicts fall back to pickle.
1149 """
1150 # If we already have generated results, save them directly without
1151 # re-running the (potentially expensive) generation. Otherwise,
1152 # fall back to running generation in background and then prompting
1153 # the user to save.
1154 try:
1155 has_results = bool(self.generator and getattr(self.generator, 'results', None))
1156 except Exception:
1157 has_results = False
1158
1159 if has_results:
1160 # Use existing results (do not re-run generation)
1161 results = self.generator.results
1162 # extract first cube/meta
1163 cube = None
1164 meta = None
1165 if isinstance(results, (list, tuple)) and len(results) > 0:
1166 first = results[0]
1167 if isinstance(first, tuple) and len(first) >= 2:
1168 cube, meta = first[0], first[1]
1169 else:
1170 cube = first
1171 else:
1172 cube = results
1173
1174 params = self._collect_parameters()
1175 # Prompt on main thread
1176 self.after(0, lambda: self._save_sim_prompt(cube, params, meta))
1177 return
1178
1179 # No existing results: run generation in background then prompt to save
1180 if self.generator is None:
1181 # create generator from current GUI values
1182 self.create_generator()
1183 if self.generator is None:
1184 return
1185
1186 t = threading.Thread(target=self._save_sim_thread, daemon=True)
1187 t.start()
1188
1189 def _save_sim_thread(self):
1190 """Background worker that runs generation and then prompts to save.
1191
1192 Runs ``self.generator.generate_cubes()`` in the background thread and
1193 then schedules :meth:`_save_sim_prompt` on the main thread to show the
1194 Save-As dialog. Errors are displayed via a messagebox scheduled on
1195 the main thread.
1196 """
1197 # Disable garbage collection in this thread to prevent cleanup
1198 # of Tkinter objects from the wrong thread
1199 import gc
1200 gc_was_enabled = gc.isenabled()
1201 gc.disable()
1202
1203 try:
1204 # Check if closing before doing expensive work
1205 if self._is_closing:
1206 return
1207
1208 try:
1209 results = self.generator.generate_cubes()
1210 except Exception as e:
1211 if not self._is_closing:
1212 self.after(0, lambda e=e: messagebox.showerror('Error during generation', str(e)))
1213 return
1214
1215 # Check again after generation completes
1216 if self._is_closing:
1217 return
1218
1219 # extract first cube and params
1220 cube = None
1221 meta = None
1222 if isinstance(results, (list, tuple)) and len(results) > 0:
1223 first = results[0]
1224 if isinstance(first, tuple) and len(first) >= 2:
1225 cube, meta = first[0], first[1]
1226 else:
1227 cube = first
1228 else:
1229 cube = results
1230
1231 params = self._collect_parameters()
1232
1233 # prompt/save on main thread
1234 if not self._is_closing:
1235 self.after(0, lambda: self._save_sim_prompt(cube, params, meta))
1236 finally:
1237 # Re-enable garbage collection if it was enabled
1238 if gc_was_enabled:
1239 gc.enable()
1240
1241 def _save_sim_prompt(self, cube, params, meta=None):
1242 """Prompt the user for a filename and save the provided cube/params.
1243
1244 Parameters
1245 ----------
1246 cube : ndarray
1247 Spectral cube array to save.
1248 params : dict
1249 Parameters dictionary produced by :meth:`_collect_parameters`.
1250 meta : dict or None
1251 Optional metadata returned by the generator.
1252 """
1253
1254 # Ask for filename
1255 fname = filedialog.asksaveasfilename(defaultextension='.npz', filetypes=[('NumPy archive', '.npz'), ('Pickled Python object', '.pkl')])
1256 if not fname:
1257 return
1258
1259 try:
1260 if fname.lower().endswith('.npz'):
1261 # try to prepare a flat dict for savez
1262 save_dict = {}
1263 save_dict['cube'] = cube
1264 # flatten params into arrays where possible
1265 for k, v in params.items():
1266 try:
1267 if isinstance(v, (list, tuple)):
1268 save_dict[k] = np.array(v)
1269 else:
1270 save_dict[k] = v
1271 except Exception:
1272 save_dict[k] = v
1273 # include meta if available
1274 if meta is not None:
1275 try:
1276 save_dict['meta'] = meta
1277 except Exception:
1278 pass
1279 np.savez(fname, **save_dict)
1280 else:
1281 with open(fname, 'wb') as fh:
1282 pickle.dump((cube, params, meta), fh)
1283 except Exception as e:
1284 messagebox.showerror('Save error', f'Failed to save simulation: {e}')
1285 return
1286
1287 messagebox.showinfo('Saved', f'Simulation saved to {fname}')
1288
1289 # ---------------------------
1290 # Cleanup
1291 # ---------------------------
1292 def _on_close(self):
1293 """Cleanup temporary files created for LaTeX rendering and exit.
1294
1295 Sets a flag to stop background threads from scheduling UI updates,
1296 removes any temporary PNG files recorded in ``_MATH_TEMPFILES``,
1297 and performs a graceful shutdown of the Tkinter application.
1298 """
1299 # Signal threads to stop scheduling UI updates
1300 self._is_closing = True
1301
1302 # Clean up temporary files
1303 for p in list(_MATH_TEMPFILES):
1304 try:
1305 os.remove(p)
1306 except:
1307 pass
1308
1309 # Graceful Tkinter shutdown
1310 try:
1311 self.quit() # Stop the mainloop
1312 except Exception:
1313 pass
1314
1315 try:
1316 self.destroy() # Destroy all widgets
1317 except Exception:
1318 pass
1319
1320
1321def main():
1322 app = GalCubeCraftGUI()
1323 try:
1324 app.mainloop()
1325 except KeyboardInterrupt:
1326 pass
1327 finally:
1328 # Ensure cleanup happens
1329 try:
1330 app._is_closing = True
1331 app.quit()
1332 except:
1333 pass
1334 try:
1335 app.destroy()
1336 except:
1337 pass
1338
1339if __name__ == '__main__':
1340 main()