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