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, DEFAULT_DIFFUSE_PARAMS
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, DEFAULT_DIFFUSE_PARAMS
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 # Cross-platform mouse-wheel scrolling. macOS sends small ±1..±n
409 # deltas, Windows sends multiples of 120, Linux sends Button-4/-5.
410 # Bind only while the cursor is over the canvas so other widgets
411 # (e.g. sliders, comboboxes) keep their own wheel behaviour.
412 def _on_mousewheel(event):
413 delta = getattr(event, 'delta', 0)
414 if delta:
415 step = -int(delta / 120) if abs(delta) >= 120 else -int(delta)
416 if step == 0:
417 step = -1 if delta > 0 else 1
418 elif getattr(event, 'num', None) == 4:
419 step = -1
420 elif getattr(event, 'num', None) == 5:
421 step = 1
422 else:
423 return
424 self.main_canvas.yview_scroll(step, 'units')
425
426 def _bind_wheel(_):
427 self.main_canvas.bind_all('<MouseWheel>', _on_mousewheel)
428 self.main_canvas.bind_all('<Button-4>', _on_mousewheel)
429 self.main_canvas.bind_all('<Button-5>', _on_mousewheel)
430
431 def _unbind_wheel(_):
432 self.main_canvas.unbind_all('<MouseWheel>')
433 self.main_canvas.unbind_all('<Button-4>')
434 self.main_canvas.unbind_all('<Button-5>')
435
436 self.main_canvas.bind('<Enter>', _bind_wheel)
437 self.main_canvas.bind('<Leave>', _unbind_wheel)
438
439
440
441 # Generator
442 self.generator = None
443
444 # Build 3-column layout (parameter panels + controls)
445 self._build_widgets()
446
447 self.protocol('WM_DELETE_WINDOW', self._on_close)
448
449
450
451 # ---------------------------
452 # Slider helper
453 # ---------------------------
454 def make_slider(self, parent, label, var, from_, to,
455 resolution=0.01, fmt="{:.2f}", integer=False):
456 """Create a labelled slider widget with snapping and a value label.
457
458 Returns a small frame containing a horizontal ``ttk.Scale`` and a
459 right-aligned textual value display. The function attaches a trace
460 to ``var`` so programmatic updates are reflected in the slider and
461 vice versa.
462 """
463
464 fr = ttk.Frame(parent)
465 if label:
466 ttk.Label(fr, text=label).pack(anchor='w', pady=(0,2))
467 slider_row = ttk.Frame(fr)
468 slider_row.pack(fill='x')
469 val_lbl = ttk.Label(slider_row, text=fmt.format(var.get()), width=6, anchor="e")
470 val_lbl.pack(side='right', padx=(4,0))
471 scale = ttk.Scale(slider_row, from_=from_, to=to, orient='horizontal')
472 scale.pack(side='left', fill='x', expand=True)
473 step = resolution if resolution else 0.01
474 busy = {'val':False}
475 def snap(v):
476 if integer:
477 return int(round(float(v)))
478 nsteps = round((float(v)-from_)/step)
479 return from_ + nsteps*step
480 def update(v):
481 if busy['val']: return
482 busy['val']=True
483 v_snap = snap(v)
484 try: var.set(v_snap)
485 except Exception: pass
486 try: val_lbl.config(text=fmt.format(v_snap))
487 except Exception: val_lbl.config(text=str(v_snap))
488 try: scale.set(v_snap)
489 except Exception: pass
490 busy['val']=False
491 scale.configure(command=update)
492 try: scale.set(var.get())
493 except Exception: scale.set(from_)
494 try:
495 def _var_trace(*_):
496 if busy['val']: return
497 busy['val']=True
498 v = var.get()
499 try: val_lbl.config(text=fmt.format(v))
500 except Exception: val_lbl.config(text=str(v))
501 try: scale.set(v)
502 except Exception: pass
503 busy['val']=False
504 if hasattr(var, 'trace_add'):
505 var.trace_add('write', _var_trace)
506 else:
507 var.trace('w', _var_trace)
508 except Exception: pass
509 return fr
510
511
512 # ---------------------------
513 # Button callback methods
514 # ---------------------------
515 def show_logs(self):
516 if hasattr(self, 'log_window') and self.log_window.winfo_exists():
517 self.log_window.lift()
518 else:
519 self.log_window = LogWindow(self)
520
521
522
523 def _popup_figure(self, title, fig):
524 """Utility to put a matplotlib figure into a new popup window"""
525 new_win = tk.Toplevel(self)
526 new_win.title(title)
527
528 # Use the FigureCanvasTkAgg to embed the plot
529 from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
530 canvas = FigureCanvasTkAgg(fig, master=new_win)
531 canvas.draw()
532 canvas.get_tk_widget().pack(fill='both', expand=True)
533
534 def show_moments(self):
535 if not self.generator:
536 return
537 try:
538 # Generate the figures using the 'Agg' backend (already set)
539 fig0, _ = moment0(self.generator.results, idx=0, save=False)
540 self._popup_figure("Moment 0", fig0)
541
542 fig1, _ = moment1(self.generator.results, idx=0, save=False)
543 self._popup_figure("Moment 1", fig1)
544 except Exception as e:
545 print(f"Error displaying moments: {e}")
546
547 def show_spectra(self):
548 if not self.generator:
549 return
550 try:
551 fig, _ = spectrum(self.generator.results, idx=0, save=False)
552 self._popup_figure("Integrated Spectrum", fig)
553 except Exception as e:
554 print(f"Error displaying spectrum: {e}")
555
556
557 def show_slice(self):
558 """Display an interactive spectral-slice viewer for the first cube.
559
560 Uses the helper in ``visualise.slice_view`` which provides an
561 interactive Matplotlib Slider to step through spectral channels.
562 """
563 if self.generator:
564 try:
565 # Temporarily switch to TkAgg for interactive display
566 import matplotlib
567 matplotlib.use('TkAgg')
568 # Pass the main window as parent so the viewer is a child Toplevel
569 # Do not force channel=0 here; allow the viewer to choose its
570 # default (central slice) when channel is None.
571 fig, ax = slice_view(self.generator.results, idx=0, channel=None, parent=self)
572 matplotlib.use('Agg')
573 except Exception as e:
574 import matplotlib
575 matplotlib.use('Agg')
576 messagebox.showerror('Slice viewer error', str(e))
577
578 def show_mom1(self):
579 if self.generator:
580 fig, ax = moment1(self.generator.results, idx=0, save=False)
581 try:
582 import matplotlib
583 matplotlib.use('TkAgg')
584 plt.figure(fig.number)
585 plt.show(block=False)
586 matplotlib.use('Agg')
587 except Exception:
588 pass
589
590 '''def show_spectra(self):
591 if self.generator:
592 fig, ax = spectrum(self.generator.results, idx=0, save=False)
593 try:
594 import matplotlib
595 matplotlib.use('TkAgg')
596 plt.figure(fig.number)
597 plt.show(block=False)
598 matplotlib.use('Agg')
599 except Exception:
600 pass'''
601
602 def reset_instance(self):
603 """Reset the GUI to a fresh state and disable visualisation/save.
604
605 This clears the in-memory ``self.generator`` reference so that the
606 next generate action will create a new instance from current UI
607 values. Buttons that depend on generated results are disabled.
608 """
609 # Disable all except generate
610 try:
611 self.moments_btn.config(state='disabled')
612 except Exception:
613 # Fallback: older versions may have separate buttons
614 try:
615 self.mom0_btn.config(state='disabled')
616 except Exception:
617 pass
618 try:
619 self.mom1_btn.config(state='disabled')
620 except Exception:
621 pass
622 self.spectra_btn.config(state='disabled')
623 try:
624 self.slice_btn.config(state='disabled')
625 except Exception:
626 pass
627 # Also disable Save when starting a fresh instance
628 try:
629 self.save_btn.config(state='disabled')
630 except Exception:
631 pass
632 for child in self.winfo_children():
633 if isinstance(child, tk.Toplevel):
634 child.destroy()
635
636 self.generator = None
637
638 def _find_scale_in(self, widget):
639 """Recursively find a ttk.Scale inside a widget tree.
640
641 Returns the first found Scale or None.
642 """
643 if isinstance(widget, ttk.Scale):
644 return widget
645 for c in widget.winfo_children():
646 found = self._find_scale_in(c)
647 if found is not None:
648 return found
649 return None
650
651 def _set_sliders_enabled(self, enabled=True):
652 """Enable or disable all slider widgets present in the GUI.
653
654 This toggles the internal ttk.Scale widget state for each slider
655 frame we create in :meth:`_build_widgets`.
656 """
657 names = [
658 'r_slider', 'n_slider', 'hz_slider', 'sigma_slider',
659 'grid_slider', 'spec_slider', 'angle_x_slider', 'angle_y_slider',
660 'sat_offset_slider_frame'
661 ]
662 for name in names:
663 w = getattr(self, name, None)
664 if w is None:
665 continue
666 try:
667 scale = self._find_scale_in(w)
668 if scale is None:
669 continue
670 if enabled:
671 try:
672 scale.state(['!disabled'])
673 except Exception:
674 scale.configure(state=tk.NORMAL)
675 else:
676 try:
677 scale.state(['disabled'])
678 except Exception:
679 scale.configure(state=tk.DISABLED)
680 except Exception:
681 # Best-effort: ignore any widget-specific errors
682 pass
683
684
685
686
687 # ---------------------------
688 # Build all widgets
689 # ---------------------------
690 def _build_widgets(self):
691
692 """Build and layout all GUI widgets.
693
694 This method assembles the complete UI inside the scrollable
695 container: it defines Tk variables, creates the three-column
696 parameter panels (rows 1--6), the slider widgets, and the bottom
697 utility buttons (Generate, Moment0, Moment1, Spectra, Save, New).
698
699 The method also hooks variable traces to an auto-update helper so
700 that changing parameters in the UI will keep an internal
701 ``GalCubeCraft`` generator in sync for quick inspection.
702
703 Notes
704 -----
705 - This method focuses on layout and widget creation; no heavy
706 computation is performed here.
707 - For clarity we keep layout logic (pack) local to this helper so
708 other methods can assume the widgets exist after this call.
709 """
710
711 # ---------------------------
712 # Variables
713 # ---------------------------
714 self.bmin_var = tk.DoubleVar(value=11.0)
715 self.bmaj_var = tk.DoubleVar(value=13.0)
716 self.bpa_var = tk.DoubleVar(value=20.0)
717 self.spatial_resolution = tk.DoubleVar(value=3.8)
718 self.n_var = tk.DoubleVar(value=1.0)
719 self.hz_var = tk.DoubleVar(value=0.8)
720 self.Se_var = tk.DoubleVar(value=0.1)
721 self.sigma_v_var = tk.DoubleVar(value=40.0)
722 self.fov = tk.IntVar(value=275)
723 self.spectral_resolution = tk.IntVar(value=20)
724 self.angle_x_var = tk.IntVar(value=45)
725 self.angle_y_var = tk.IntVar(value=30)
726 self.n_gals_var = tk.IntVar(value=1)
727
728 # --- Diffuse-emission knobs (defaults pulled from core's DEFAULT_DIFFUSE_PARAMS) ---
729 dp = DEFAULT_DIFFUSE_PARAMS
730 self.diffuse_enabled_var = tk.BooleanVar(value=bool(dp.get('enabled', True)))
731 # Halo
732 self.halo_Se_factor_var = tk.DoubleVar(value=float(dp.get('halo_Se_factor', 0.065)))
733 self.halo_Re_factor_var = tk.DoubleVar(value=float(dp.get('halo_Re_factor', 3.0)))
734 self.halo_hz_factor_var = tk.DoubleVar(value=float(dp.get('halo_hz_factor', 2.0)))
735 self.halo_sigma_vz_var = tk.DoubleVar(value=float(dp.get('halo_sigma_vz', 70.0)))
736 # Bridges
737 self.bridge_Se_factor_var = tk.DoubleVar(value=float(dp.get('bridge_Se_factor', 0.05)))
738 self.bridge_width_start_factor_var = tk.DoubleVar(value=float(dp.get('bridge_width_start_factor', 1.5)))
739 self.bridge_width_end_factor_var = tk.DoubleVar(value=float(dp.get('bridge_width_end_factor', 1.0)))
740 # Tails
741 self.tail_Se_factor_var = tk.DoubleVar(value=float(dp.get('tail_Se_factor', 0.4)))
742 self.tail_curvature_var = tk.DoubleVar(value=float(dp.get('tail_curvature', 0.5)))
743 self.tail_length_factor_var = tk.DoubleVar(value=float(dp.get('tail_length_factor', 1.5)))
744
745 # New: satellite size fraction (max satellite-to-central ratio for Re,
746 # hz, Se). Greyed out when only one galaxy is requested.
747 self.sat_frac_var = tk.DoubleVar(value=0.7)
748
749 col_width = 310 # column width
750
751 # Helper used multiple times below to find the underlying ttk.Scale
752 # inside a slider frame (so we can grey it out when n_gals == 1).
753 def find_scale(widget):
754 if isinstance(widget, ttk.Scale):
755 return widget
756 for child in widget.winfo_children():
757 result = find_scale(child)
758 if result is not None:
759 return result
760 return None
761
762 # ---------------------------
763 # Row 1: Number of galaxies | Spatial resolution
764 # ---------------------------
765 r1 = ttk.Frame(self.container)
766 r1.pack(fill='x', pady=4)
767
768 outer1, fr1 = param_frame(r1, width=col_width)
769 outer1.pack(side='left', padx=6, fill='y')
770 latex_label(fr1, r"\text{Number of galaxies}").pack(anchor='w', pady=(0,6))
771 rb_frame = ttk.Frame(fr1)
772 rb_frame.pack(anchor='w')
773 for val in range(1, 7):
774 rb = ttk.Radiobutton(rb_frame, text=str(val), variable=self.n_gals_var, value=val)
775 rb.pack(side='left', padx=4)
776
777 outer2, fr2 = param_frame(r1, width=col_width)
778 outer2.pack(side='left', padx=6, fill='y')
779 latex_label(fr2, r"\text{Spatial Resolution } (\Delta_{X,Y}) \: {\rm [kpc\;px^{-1}]}").pack(anchor='w')
780 self.pix_scale_var_slider = self.make_slider(fr2, "", self.spatial_resolution, 0.72, 9.0, resolution=0.01, fmt="{:.2f}")
781 self.pix_scale_var_slider.pack(fill='x')
782
783 # ---------------------------
784 # Row 2: FOV | Beam
785 # ---------------------------
786 r2 = ttk.Frame(self.container)
787 r2.pack(fill='x', pady=4)
788
789 outer1, fr1 = param_frame(r2, width=col_width)
790 outer1.pack(side='left', padx=6, fill='y')
791 latex_label(fr1, r"\text{Field of View [kpc]}").pack(anchor='w', pady=(0,6))
792 fov_row = ttk.Frame(fr1)
793 fov_row.pack(anchor='w', pady=2)
794 for text, var in [(r"FOV_{X} \:\:;\:\: FOV_{Y}\:", self.fov)]:
795 lbl = latex_label(fov_row, text); lbl.pack(side='left', padx=(0,2))
796 e = ttk.Entry(fov_row, textvariable=var, width=4)
797 e.pack(side='left', padx=(0,6))
798
799 outer2, fr2 = param_frame(r2, width=col_width)
800 outer2.pack(side='left', padx=4, fill='y')
801 latex_label(fr2, r"\text{Beam Information [kpc , kpc , deg]}").pack(anchor='w', pady=(0,6))
802 beam_row = ttk.Frame(fr2)
803 beam_row.pack(anchor='w', pady=2)
804 for text, var in [
805 (r"B_{\rm min}", self.bmin_var),
806 (r"B_{\rm maj}", self.bmaj_var),
807 (r"\rm BPA", self.bpa_var)
808 ]:
809 lbl = latex_label(beam_row, text); lbl.pack(side='left', padx=(0,2))
810 e = ttk.Entry(beam_row, textvariable=var, width=3)
811 e.pack(side='left', padx=(0,6))
812
813 # ---------------------------
814 # Row 3: Sérsic index | Scale height
815 # ---------------------------
816 r3 = ttk.Frame(self.container)
817 r3.pack(fill='x', pady=4)
818
819 outer1, fr1 = param_frame(r3, width=col_width)
820 outer1.pack(side='left', padx=6, fill='y')
821 latex_label(fr1, r"\text{Sérsic index } (n) \: [-]").pack(anchor='w')
822 self.n_slider = self.make_slider(fr1, "", self.n_var, 0.5, 1.5, resolution=0.01, fmt="{:.3f}")
823 self.n_slider.pack(fill='x')
824
825 outer2, fr2 = param_frame(r3, width=col_width)
826 outer2.pack(side='left', padx=6, fill='y')
827 latex_label(fr2, r"\text{Scale height } (h_z) \ [\text{kpc}]").pack(anchor='w')
828 self.hz_slider = self.make_slider(fr2, "", self.hz_var, 0.4, 9.0, resolution=0.01, fmt="{:.3f}")
829 self.hz_slider.pack(fill='x')
830
831 # ---------------------------
832 # Row 4: Central S_e | Spectral resolution
833 # ---------------------------
834 r4 = ttk.Frame(self.container)
835 r4.pack(fill='x', pady=4)
836
837 outer1, fr1 = param_frame(r4, width=col_width)
838 outer1.pack(side='left', padx=6, fill='y')
839 latex_label(fr1, r"\text{Central effective flux density } (S_e) \ [\text{Jy}]").pack(anchor='w')
840 ttk.Entry(fr1, textvariable=self.Se_var).pack(fill='x')
841
842 outer2, fr2 = param_frame(r4, width=col_width)
843 outer2.pack(side='left', padx=6, fill='y')
844 latex_label(fr2, r"\text{Spectral Resolution }(\Delta_{v_z})\ [km\;s^{-1}]").pack(anchor='w')
845 self.spec_slider = self.make_slider(fr2, "", self.spectral_resolution, 5, 40, resolution=5, fmt="{:d}", integer=True)
846 self.spec_slider.pack(fill='x')
847
848 # ---------------------------
849 # Row 5: Satellite size fraction (NEW) | Satellite offset
850 # Both greyed out unless n_gals > 1.
851 # ---------------------------
852 r5 = ttk.Frame(self.container)
853 r5.pack(fill='x', pady=4)
854
855 outer1, fr1 = param_frame(r5, width=col_width)
856 outer1.pack(side='left', padx=6, fill='y')
857 latex_label(fr1, r"\text{Satellite size fraction of central}").pack(anchor='w', pady=(0,6))
858 self.sat_frac_slider_frame = self.make_slider(
859 fr1, "", self.sat_frac_var, 0.1, 1.0, resolution=0.01, fmt="{:.2f}"
860 )
861 self.sat_frac_slider_frame.pack(fill='x')
862 self.sat_frac_scale = find_scale(self.sat_frac_slider_frame)
863
864 outer2, fr2 = param_frame(r5, width=col_width)
865 outer2.pack(side='left', padx=6, fill='y')
866 latex_label(fr2, r"\text{Satellite offset from centre [kpc]}").pack(anchor='w', pady=(0,6))
867 self.sat_offset_var = tk.DoubleVar(value=75.0)
868 self.sat_offset_slider_frame = self.make_slider(
869 fr2, "", self.sat_offset_var, 5.0, 100.0, resolution=0.1, fmt="{:.1f}"
870 )
871 self.sat_offset_slider_frame.pack(fill='x')
872 self.sat_offset_scale = find_scale(self.sat_offset_slider_frame)
873
874 # Grey out both sat sliders when only 1 galaxy is selected.
875 def _update_sat_dependent(*args):
876 active = self.n_gals_var.get() > 1
877 for scale in (self.sat_offset_scale, self.sat_frac_scale):
878 if scale is None:
879 continue
880 if active:
881 scale.state(['!disabled'])
882 else:
883 scale.state(['disabled'])
884
885 _update_sat_dependent()
886 if hasattr(self.n_gals_var, 'trace_add'):
887 self.n_gals_var.trace_add('write', _update_sat_dependent)
888 else:
889 self.n_gals_var.trace('w', _update_sat_dependent)
890
891 # ---------------------------
892 # Row 6: Velocity dispersion | (left blank to preserve 2-column rhythm)
893 # ---------------------------
894 r6 = ttk.Frame(self.container)
895 r6.pack(fill='x', pady=4)
896
897 outer1, fr1 = param_frame(r6, width=col_width)
898 outer1.pack(side='left', padx=6, fill='y')
899 latex_label(fr1, r"\text{Velocity dispersion }(\sigma_{v_z})\ [km\;s^{-1}]").pack(anchor='w')
900 self.sigma_slider = self.make_slider(fr1, "", self.sigma_v_var, 30.0, 60.0, resolution=0.1, fmt="{:.1f}")
901 self.sigma_slider.pack(fill='x')
902
903 outer2, fr2 = param_frame(r6, width=col_width)
904 outer2.pack(side='left', padx=6, fill='y')
905 latex_label(fr2, r"\text{Inclination angle }(\theta_X) \text{ [deg]}").pack(anchor='w')
906 self.angle_x_slider = self.make_slider(fr2, "", self.angle_x_var, 0, 359, resolution=1, fmt="{:d}", integer=True)
907 self.angle_x_slider.pack(fill='x')
908
909 # ---------------------------
910 # Row 7: Azimuthal angle | Diffuse emission (last row)
911 # The diffuse-emission box holds two checkboxes: master "Enabled" and
912 # "Show controls". The Show checkbox is greyed out unless Enabled is on,
913 # and toggling it expands / collapses the diffuse sliders below.
914 # ---------------------------
915 self.show_diffuse_controls_var = tk.BooleanVar(value=False)
916
917 r7 = ttk.Frame(self.container)
918 r7.pack(fill='x', pady=4)
919
920 outer1, fr1 = param_frame(r7, width=col_width)
921 outer1.pack(side='left', padx=6, fill='y')
922 latex_label(fr1, r"\text{Azimuthal angle }(\phi_Y) \text{ [deg]}").pack(anchor='w')
923 self.angle_y_slider = self.make_slider(fr1, "", self.angle_y_var, 0, 359, resolution=1, fmt="{:d}", integer=True)
924 self.angle_y_slider.pack(fill='x')
925
926 outer2, fr2 = param_frame(r7, width=col_width)
927 outer2.pack(side='left', padx=6, fill='y')
928 latex_label(fr2, r"\text{Diffuse emission}").pack(anchor='w', pady=(0,6))
929 ttk.Checkbutton(fr2, text="Enabled", variable=self.diffuse_enabled_var).pack(anchor='w')
930 self.show_controls_check = ttk.Checkbutton(
931 fr2, text="Show controls", variable=self.show_diffuse_controls_var,
932 )
933 self.show_controls_check.pack(anchor='w')
934
935 # ---------------------------
936 # Diffuse-emission slider section (parent that we hide / show).
937 # All diffuse-param rows below are children of this frame so a single
938 # `pack_forget` collapses the entire stack.
939 # ---------------------------
940 self.diffuse_section = ttk.Frame(self.container)
941
942 diffuse_hdr = ttk.Frame(self.diffuse_section)
943 diffuse_hdr.pack(fill='x', pady=(8,2))
944 latex_label(diffuse_hdr, r"\text{Diffuse Emission Controls}", font_size=2).pack(anchor='w', padx=10)
945
946 # ---------------------------
947 # Row 8: Halo S_e factor | Halo R_e factor
948 # ---------------------------
949 r8 = ttk.Frame(self.diffuse_section); r8.pack(fill='x', pady=4)
950 outer1, fr1 = param_frame(r8, width=col_width); outer1.pack(side='left', padx=6, fill='y')
951 latex_label(fr1, r"S_{e,\rm halo}\,/\,S_{e,c}\ \text{(amplitude)}").pack(anchor='w')
952 self.halo_Se_slider = self.make_slider(fr1, "", self.halo_Se_factor_var, 0.0, 0.3, resolution=0.005, fmt="{:.3f}")
953 self.halo_Se_slider.pack(fill='x')
954 outer2, fr2 = param_frame(r8, width=col_width); outer2.pack(side='left', padx=6, fill='y')
955 latex_label(fr2, r"R_{e,\rm halo}\,/\,R_{e,c}\ \text{(extent)}").pack(anchor='w')
956 self.halo_Re_slider = self.make_slider(fr2, "", self.halo_Re_factor_var, 1.0, 5.0, resolution=0.1, fmt="{:.1f}")
957 self.halo_Re_slider.pack(fill='x')
958
959 # ---------------------------
960 # Row 9: Halo h_z factor | Halo σ_vz
961 # ---------------------------
962 r9 = ttk.Frame(self.diffuse_section); r9.pack(fill='x', pady=4)
963 outer1, fr1 = param_frame(r9, width=col_width); outer1.pack(side='left', padx=6, fill='y')
964 latex_label(fr1, r"h_{z,\rm halo}\,/\,h_{z,c}").pack(anchor='w')
965 self.halo_hz_slider = self.make_slider(fr1, "", self.halo_hz_factor_var, 1.0, 5.0, resolution=0.1, fmt="{:.1f}")
966 self.halo_hz_slider.pack(fill='x')
967 outer2, fr2 = param_frame(r9, width=col_width); outer2.pack(side='left', padx=6, fill='y')
968 latex_label(fr2, r"\sigma_{v_z,\rm halo}\ [\rm km\,s^{-1}]").pack(anchor='w')
969 self.halo_sigma_slider = self.make_slider(fr2, "", self.halo_sigma_vz_var, 0.0, 150.0, resolution=5.0, fmt="{:.0f}")
970 self.halo_sigma_slider.pack(fill='x')
971
972 # ---------------------------
973 # Row 10: Bridge S_e factor | Bridge width (halo end)
974 # ---------------------------
975 r10 = ttk.Frame(self.diffuse_section); r10.pack(fill='x', pady=4)
976 outer1, fr1 = param_frame(r10, width=col_width); outer1.pack(side='left', padx=6, fill='y')
977 latex_label(fr1, r"S_{e,\rm br}\,/\,\min(S_{e,c}, S_{e,s})").pack(anchor='w')
978 self.bridge_Se_slider = self.make_slider(fr1, "", self.bridge_Se_factor_var, 0.0, 0.3, resolution=0.005, fmt="{:.3f}")
979 self.bridge_Se_slider.pack(fill='x')
980 outer2, fr2 = param_frame(r10, width=col_width); outer2.pack(side='left', padx=6, fill='y')
981 latex_label(fr2, r"\sigma_\text{bridge halo end}/R_{e,c}").pack(anchor='w')
982 self.bridge_w0_slider = self.make_slider(fr2, "", self.bridge_width_start_factor_var, 0.5, 4.0, resolution=0.1, fmt="{:.1f}")
983 self.bridge_w0_slider.pack(fill='x')
984
985 # ---------------------------
986 # Row 11: Bridge width (satellite end) | Tail S_e factor
987 # ---------------------------
988 r11 = ttk.Frame(self.diffuse_section); r11.pack(fill='x', pady=4)
989 outer1, fr1 = param_frame(r11, width=col_width); outer1.pack(side='left', padx=6, fill='y')
990 latex_label(fr1, r"\sigma_\text{bridge satellite end}/R_{e,s}").pack(anchor='w')
991 self.bridge_w1_slider = self.make_slider(fr1, "", self.bridge_width_end_factor_var, 0.3, 3.0, resolution=0.1, fmt="{:.1f}")
992 self.bridge_w1_slider.pack(fill='x')
993 outer2, fr2 = param_frame(r11, width=col_width); outer2.pack(side='left', padx=6, fill='y')
994 latex_label(fr2, r"S_{e,\rm tail}\,/\,S_{e,s}").pack(anchor='w')
995 self.tail_Se_slider = self.make_slider(fr2, "", self.tail_Se_factor_var, 0.0, 1.0, resolution=0.02, fmt="{:.2f}")
996 self.tail_Se_slider.pack(fill='x')
997
998 # ---------------------------
999 # Row 12: Tail length | Tail curvature
1000 # ---------------------------
1001 r12 = ttk.Frame(self.diffuse_section); r12.pack(fill='x', pady=4)
1002 outer1, fr1 = param_frame(r12, width=col_width); outer1.pack(side='left', padx=6, fill='y')
1003 latex_label(fr1, r"L_\text{tail}\,/\,\text{sep}").pack(anchor='w')
1004 self.tail_length_slider = self.make_slider(fr1, "", self.tail_length_factor_var, 0.0, 3.0, resolution=0.05, fmt="{:.2f}")
1005 self.tail_length_slider.pack(fill='x')
1006 outer2, fr2 = param_frame(r12, width=col_width); outer2.pack(side='left', padx=6, fill='y')
1007 latex_label(fr2, r"\kappa\,/\,\text{sep (curvature)}").pack(anchor='w')
1008 self.tail_curv_slider = self.make_slider(fr2, "", self.tail_curvature_var, 0.0, 1.5, resolution=0.05, fmt="{:.2f}")
1009 self.tail_curv_slider.pack(fill='x')
1010
1011 # ---------------------------
1012 # Visibility wiring for the diffuse section.
1013 # - Enabled off → Show-controls is disabled and forced off.
1014 # - Show-controls toggled → diffuse_section is packed/unpacked.
1015 # ---------------------------
1016 def _refresh_diffuse_show_state(*_):
1017 if self.diffuse_enabled_var.get():
1018 self.show_controls_check.state(['!disabled'])
1019 else:
1020 # Force-collapse when the master switch goes off.
1021 self.show_diffuse_controls_var.set(False)
1022 self.show_controls_check.state(['disabled'])
1023
1024 def _refresh_diffuse_section(*_):
1025 visible = bool(self.diffuse_enabled_var.get()
1026 and self.show_diffuse_controls_var.get())
1027 already = bool(self.diffuse_section.winfo_manager())
1028 if visible and not already:
1029 self.diffuse_section.pack(fill='x', pady=(2, 4))
1030 elif not visible and already:
1031 self.diffuse_section.pack_forget()
1032
1033 for v in (self.diffuse_enabled_var, self.show_diffuse_controls_var):
1034 if hasattr(v, 'trace_add'):
1035 v.trace_add('write', _refresh_diffuse_show_state)
1036 v.trace_add('write', _refresh_diffuse_section)
1037 else:
1038 v.trace('w', _refresh_diffuse_show_state)
1039 v.trace('w', _refresh_diffuse_section)
1040
1041 _refresh_diffuse_show_state()
1042 _refresh_diffuse_section()
1043
1044 # ---------------------------
1045 # Generate & utility buttons (Generate, Slice, Moments, Spectrum, Save, New)
1046 # ---------------------------
1047 btn_frame = ttk.Frame(self)
1048 btn_frame.pack(side='bottom', pady=8, fill='x')
1049
1050 # Button height (bigger than normal)
1051 btn_height = 2
1052 # Create buttons as ttk with a compact dark style so we don't change
1053 # the global theme but render dark buttons reliably on macOS.
1054 btn_fg = 'white'
1055 btn_disabled_fg = '#8c8c8c'
1056 btn_bg = '#222222'
1057 btn_active_bg = '#2f2f2f'
1058
1059 style = ttk.Style()
1060 # Do not change the global theme; just define a local style
1061 style.configure('Dark.TButton', background=btn_bg, foreground=btn_fg, height=btn_height, padding=(0,0))
1062 style.map('Dark.TButton',
1063 background=[('active', btn_active_bg), ('disabled', btn_bg), ('!disabled', btn_bg)],
1064 foreground=[('disabled', btn_disabled_fg), ('!disabled', btn_fg)])
1065
1066 # Create as ttk.Button with the dark style (keeps rest of theme intact)
1067 self.generate_btn = ttk.Button(btn_frame, text='Generate', command=self.generate, style='Dark.TButton', width=5)
1068 self.slice_btn = ttk.Button(btn_frame, text='Slice', command=self.show_slice, state='disabled', style='Dark.TButton', width=5)
1069 # Combined Moments button: shows both Moment0 and Moment1 windows
1070 self.moments_btn = ttk.Button(btn_frame, text='Moments', command=self.show_moments, state='disabled', style='Dark.TButton', width=5)
1071 self.spectra_btn = ttk.Button(btn_frame, text='Spectrum', command=self.show_spectra, state='disabled', style='Dark.TButton', width=5)
1072 # The "New" button resets the GUI to a fresh instance. Make it
1073 # visible by default (enabled) so users can quickly clear state.
1074 # It will be disabled by reset_instance when appropriate.
1075 self.new_instance_btn = ttk.Button(btn_frame, text='Reset', command=self.reset_instance, state='disabled', style='Dark.TButton', width=5)
1076
1077 # Pack buttons side by side with padding; Save before New (New last)
1078 self.save_btn = ttk.Button(btn_frame, text='Save', command=self.save_sim, state='disabled', style='Dark.TButton', width=5)
1079 for btn in [self.generate_btn, self.slice_btn, self.moments_btn, self.spectra_btn, self.save_btn, self.new_instance_btn]:
1080 btn.pack(side='left', padx=4, pady=2, expand=True, fill='x')
1081
1082
1083
1084
1085 # Auto-create/refresh generator when variables change (fast preview)
1086 def _auto_update_generator(*args):
1087 try:
1088 self.create_generator()
1089 except Exception as e:
1090 print("Auto-create generator failed:", e)
1091
1092 for var in [self.bmin_var, self.bmaj_var, self.bpa_var, self.spatial_resolution, self.n_var,
1093 self.hz_var, self.Se_var, self.sigma_v_var, self.fov,
1094 self.spectral_resolution, self.angle_x_var, self.angle_y_var,
1095 self.sat_frac_var, self.sat_offset_var,
1096 # Diffuse-emission knobs
1097 self.diffuse_enabled_var,
1098 self.halo_Se_factor_var, self.halo_Re_factor_var,
1099 self.halo_hz_factor_var, self.halo_sigma_vz_var,
1100 self.bridge_Se_factor_var, self.bridge_width_start_factor_var,
1101 self.bridge_width_end_factor_var,
1102 self.tail_Se_factor_var, self.tail_curvature_var,
1103 self.tail_length_factor_var]:
1104 if hasattr(var, 'trace_add'):
1105 var.trace_add('write', _auto_update_generator)
1106 else:
1107 var.trace('w', _auto_update_generator)
1108
1109
1110 # ---------------------------
1111 # Parameter collection & generator
1112 # ---------------------------
1113
1114
1115 def _collect_parameters(self):
1116 """Read current UI controls and return a parameter dict.
1117
1118 The returned dictionary mirrors the small set of fields used by the
1119 :class:`GalCubeCraft` constructor and the GUI. Values are converted
1120 to plain Python / NumPy types where appropriate.
1121
1122 Returns
1123 -------
1124 params : dict
1125 Dictionary containing keys like ``beam_info``, ``n_gals``,
1126 ``grid_size``, ``n_spectral_slices``, ``all_Re``, ``all_hz``,
1127 ``all_Se``, ``all_n``, and ``sigma_v``. This dict is consumed by
1128 :meth:`create_generator` and used when saving.
1129 """
1130
1131 bmin = float(self.bmin_var.get())
1132 bmaj = float(self.bmaj_var.get())
1133 bpa = float(self.bpa_var.get())
1134 n_gals = int(self.n_gals_var.get())
1135 fov = int(self.fov.get())
1136 spectral_resolution = int(self.spectral_resolution.get())
1137 spatial_resolution = int(self.spatial_resolution.get())
1138 central_n = float(self.n_var.get())
1139 central_hz = float(self.hz_var.get())
1140 central_Se = float(self.Se_var.get())
1141 central_gal_x_angle = int(self.angle_x_var.get())
1142 central_gal_y_angle = int(self.angle_y_var.get())
1143 offset_gals = float(self.sat_offset_var.get())
1144 sigma_v = float(self.sigma_v_var.get())
1145
1146 # Create per-galaxy lists. For a single galaxy we keep the
1147 # specified central values. For multiple galaxies we generate
1148 # satellite properties using simple random draws so the
1149 # generator receives arrays of length ``n_gals`` (primary + satellites).
1150 all_Re = [5/spatial_resolution]
1151 all_hz = [central_hz]
1152 all_Se = [central_Se]
1153 all_gal_x_angles = [central_gal_x_angle]
1154 all_gal_y_angles = [central_gal_y_angle]
1155 all_n = [central_n]
1156
1157 if n_gals > 1:
1158 n_sat = n_gals - 1
1159 rng = np.random.default_rng()
1160
1161 # Satellite size fraction f ∈ (0, 1] sets the upper bound of the
1162 # satellite/central ratio; the lower bound is 0.5 × f, so each
1163 # satellite property is uniformly sampled in [0.5·f, f] × central.
1164 f = float(self.sat_frac_var.get())
1165 f = max(0.05, min(1.0, f))
1166 sat_Re = list(rng.uniform(all_Re[0] * 0.5 * f, all_Re[0] * f, n_sat))
1167 sat_hz = list(rng.uniform(all_hz[0] * 0.5 * f, all_hz[0] * f, n_sat))
1168 sat_Se = list(rng.uniform(all_Se[0] * 0.5 * f, all_Se[0] * f, n_sat))
1169
1170 # Random Sérsic indices for satellites
1171 sat_n = list(rng.uniform(0.5, 1.5, n_sat))
1172
1173 # Random orientations for satellites (degrees)
1174 sat_x_angles = list(rng.uniform(-180.0, 180.0, n_sat))
1175 sat_y_angles = list(rng.uniform(-180.0, 180.0, n_sat))
1176
1177 all_Re += sat_Re
1178 all_hz += sat_hz
1179 all_Se += sat_Se
1180 all_n += sat_n
1181 all_gal_x_angles += sat_x_angles
1182 all_gal_y_angles += sat_y_angles
1183
1184 # Convert lists to NumPy arrays to match generator expectations
1185 all_Re = np.array(all_Re)
1186 all_hz = np.array(all_hz)
1187 all_Se = np.array(all_Se)
1188 all_n = np.array(all_n)
1189 all_gal_x_angles = np.array(all_gal_x_angles)
1190 all_gal_y_angles = np.array(all_gal_y_angles)
1191
1192 # Compose a `diffuse_params` dict from the GUI controls, layered on
1193 # top of the package defaults so we never silently drop any key the
1194 # core helper expects.
1195 diffuse_params = dict(DEFAULT_DIFFUSE_PARAMS)
1196 diffuse_params.update({
1197 'enabled': bool(self.diffuse_enabled_var.get()),
1198 'halo_Se_factor': float(self.halo_Se_factor_var.get()),
1199 'halo_Re_factor': float(self.halo_Re_factor_var.get()),
1200 'halo_hz_factor': float(self.halo_hz_factor_var.get()),
1201 'halo_sigma_vz': float(self.halo_sigma_vz_var.get()),
1202 'bridge_Se_factor': float(self.bridge_Se_factor_var.get()),
1203 'bridge_width_start_factor': float(self.bridge_width_start_factor_var.get()),
1204 'bridge_width_end_factor': float(self.bridge_width_end_factor_var.get()),
1205 'tail_Se_factor': float(self.tail_Se_factor_var.get()),
1206 'tail_curvature': float(self.tail_curvature_var.get()),
1207 'tail_length_factor': float(self.tail_length_factor_var.get()),
1208 })
1209
1210 params = dict(
1211 beam_info=[bmin,bmaj,bpa],
1212 n_gals=n_gals,
1213 fov=fov,
1214 spectral_resolution=spectral_resolution,
1215 spatial_resolution=spatial_resolution,
1216 all_Re=np.array(all_Re),
1217 all_hz=np.array(all_hz),
1218 all_Se=np.array(all_Se),
1219 all_n=np.array(all_n),
1220 all_gal_x_angles=np.array(all_gal_x_angles),
1221 all_gal_y_angles=np.array(all_gal_y_angles),
1222 sigma_v=sigma_v,
1223 offset_gals=offset_gals,
1224 diffuse_params=diffuse_params,
1225 )
1226 return params
1227
1228 def create_generator(self):
1229 """Instantiate a :class:`GalCubeCraft` object from current UI values.
1230
1231 The method calls :meth:`_collect_parameters` to assemble a parameter
1232 dictionary and then constructs a single-cube generator instance with
1233 sensible defaults for fields not exposed directly in the GUI. After
1234 construction the per-galaxy attributes on the generator are filled
1235 from the collected parameters so the generator is ready to run.
1236 """
1237
1238 params = self._collect_parameters()
1239 try:
1240 g = GalCubeCraft_Phy(
1241 n_gals=params['n_gals'],
1242 n_cubes=1,
1243 spatial_resolution=params['spatial_resolution'],
1244 spectral_resolution=params['spectral_resolution'],
1245 offset_gals=params['offset_gals'],
1246 beam_info=params['beam_info'],
1247 fov=params['fov'],
1248 verbose=True,
1249 seed=None,
1250 diffuse_params=params['diffuse_params'],
1251 )
1252 except Exception as e:
1253 messagebox.showerror('Error', f'Failed to create GalCubeCraft: {e}')
1254 return
1255
1256 # Fill the galaxy-specific properties
1257 n_g = params['n_gals']
1258 g.all_Re = [params['all_Re']]
1259 g.all_hz = [params['all_hz']]
1260 g.all_Se = [params['all_Se']]
1261 g.all_n = [params['all_n']]
1262 g.all_gal_x_angles = [params['all_gal_x_angles']]
1263 g.all_gal_y_angles = [params['all_gal_y_angles']]
1264 g.all_gal_vz_sigmas = [np.full(n_g, params['sigma_v'])]
1265 #g.all_pix_spatial_scales = [np.full(n_g, params['spatial_resolution'])]
1266 g.all_gal_v_0 = [np.full(n_g, 200.0)] # default systemic velocity
1267
1268 self.generator = g
1269
1270
1271 def _run_generate(self):
1272 # Disable garbage collection in this thread to prevent cleanup
1273 # of Tkinter objects from the wrong thread
1274 import gc
1275 gc_was_enabled = gc.isenabled()
1276 gc.disable()
1277
1278 try:
1279 # Check if closing before doing expensive work
1280 if self._is_closing:
1281 return
1282
1283 # Auto-show log window
1284 if hasattr(self, 'log_window') and self.log_window.winfo_exists():
1285 self.log_window.deiconify()
1286 self.log_window.lift()
1287 else:
1288 self.log_window = LogWindow(self)
1289
1290 try:
1291 results = self.generator.generate_cubes()
1292 # Check again before scheduling UI updates
1293 if self._is_closing:
1294 return
1295 # Enable buttons on main thread
1296 self.after(0, lambda: [
1297 self.moments_btn.config(state='normal'),
1298 self.spectra_btn.config(state='normal'),
1299 self.slice_btn.config(state='normal'),
1300 self.save_btn.config(state='normal'),
1301 self.new_instance_btn.config(state='normal'),
1302 ])
1303 except Exception as e:
1304 if not self._is_closing:
1305 self.after(0, lambda e=e: messagebox.showerror('Error during generation', str(e)))
1306 finally:
1307 # Re-enable garbage collection if it was enabled
1308 if gc_was_enabled:
1309 gc.enable()
1310
1311
1312 def generate(self):
1313 # Always create a fresh generator from current UI values
1314 # so that changes to n_gals or sliders are captured
1315 self.create_generator()
1316
1317 if self.generator is None:
1318 return
1319
1320 t = threading.Thread(target=self._run_generate, daemon=True)
1321 t.start()
1322
1323 # ---------------------------
1324 # Save simulation (cube + params)
1325 # ---------------------------
1326 def save_sim(self):
1327 """Generate (if needed) and save the sim tuple (cube, params).
1328
1329 This runs generation in a background thread and then opens a
1330 Save-As dialog on the main thread to let the user choose where
1331 to store the result. We support .npz (numpy savez) and .pkl
1332 (pickle) formats; complex parameter dicts fall back to pickle.
1333 """
1334 # If we already have generated results, save them directly without
1335 # re-running the (potentially expensive) generation. Otherwise,
1336 # fall back to running generation in background and then prompting
1337 # the user to save.
1338 try:
1339 has_results = bool(self.generator and getattr(self.generator, 'results', None))
1340 except Exception:
1341 has_results = False
1342
1343 if has_results:
1344 # Use existing results (do not re-run generation)
1345 results = self.generator.results
1346 # extract first cube/meta
1347 cube = None
1348 meta = None
1349 if isinstance(results, (list, tuple)) and len(results) > 0:
1350 first = results[0]
1351 if isinstance(first, tuple) and len(first) >= 2:
1352 cube, meta = first[0], first[1]
1353 else:
1354 cube = first
1355 else:
1356 cube = results
1357
1358 params = self._collect_parameters()
1359 # Prompt on main thread
1360 self.after(0, lambda: self._save_sim_prompt(cube, params, meta))
1361 return
1362
1363 # No existing results: run generation in background then prompt to save
1364 if self.generator is None:
1365 # create generator from current GUI values
1366 self.create_generator()
1367 if self.generator is None:
1368 return
1369
1370 t = threading.Thread(target=self._save_sim_thread, daemon=True)
1371 t.start()
1372
1373 def _save_sim_thread(self):
1374 """Background worker that runs generation and then prompts to save.
1375
1376 Runs ``self.generator.generate_cubes()`` in the background thread and
1377 then schedules :meth:`_save_sim_prompt` on the main thread to show the
1378 Save-As dialog. Errors are displayed via a messagebox scheduled on
1379 the main thread.
1380 """
1381 # Disable garbage collection in this thread to prevent cleanup
1382 # of Tkinter objects from the wrong thread
1383 import gc
1384 gc_was_enabled = gc.isenabled()
1385 gc.disable()
1386
1387 try:
1388 # Check if closing before doing expensive work
1389 if self._is_closing:
1390 return
1391
1392 try:
1393 results = self.generator.generate_cubes()
1394 except Exception as e:
1395 if not self._is_closing:
1396 self.after(0, lambda e=e: messagebox.showerror('Error during generation', str(e)))
1397 return
1398
1399 # Check again after generation completes
1400 if self._is_closing:
1401 return
1402
1403 # extract first cube and params
1404 cube = None
1405 meta = None
1406 if isinstance(results, (list, tuple)) and len(results) > 0:
1407 first = results[0]
1408 if isinstance(first, tuple) and len(first) >= 2:
1409 cube, meta = first[0], first[1]
1410 else:
1411 cube = first
1412 else:
1413 cube = results
1414
1415 params = self._collect_parameters()
1416
1417 # prompt/save on main thread
1418 if not self._is_closing:
1419 self.after(0, lambda: self._save_sim_prompt(cube, params, meta))
1420 finally:
1421 # Re-enable garbage collection if it was enabled
1422 if gc_was_enabled:
1423 gc.enable()
1424
1425 def _save_sim_prompt(self, cube, params, meta=None):
1426 """Prompt the user for a filename and save the provided cube/params.
1427
1428 Parameters
1429 ----------
1430 cube : ndarray
1431 Spectral cube array to save.
1432 params : dict
1433 Parameters dictionary produced by :meth:`_collect_parameters`.
1434 meta : dict or None
1435 Optional metadata returned by the generator.
1436 """
1437
1438 # Ask for filename
1439 fname = filedialog.asksaveasfilename(defaultextension='.npz', filetypes=[('NumPy archive', '.npz'), ('Pickled Python object', '.pkl')])
1440 if not fname:
1441 return
1442
1443 try:
1444 if fname.lower().endswith('.npz'):
1445 # try to prepare a flat dict for savez
1446 save_dict = {}
1447 save_dict['cube'] = cube
1448 # flatten params into arrays where possible
1449 for k, v in params.items():
1450 try:
1451 if isinstance(v, (list, tuple)):
1452 save_dict[k] = np.array(v)
1453 else:
1454 save_dict[k] = v
1455 except Exception:
1456 save_dict[k] = v
1457 # include meta if available
1458 if meta is not None:
1459 try:
1460 save_dict['meta'] = meta
1461 except Exception:
1462 pass
1463 np.savez(fname, **save_dict)
1464 else:
1465 with open(fname, 'wb') as fh:
1466 pickle.dump((cube, params, meta), fh)
1467 except Exception as e:
1468 messagebox.showerror('Save error', f'Failed to save simulation: {e}')
1469 return
1470
1471 messagebox.showinfo('Saved', f'Simulation saved to {fname}')
1472
1473 # ---------------------------
1474 # Cleanup
1475 # ---------------------------
1476 def _on_close(self):
1477 """Cleanup temporary files created for LaTeX rendering and exit.
1478
1479 Sets a flag to stop background threads from scheduling UI updates,
1480 removes any temporary PNG files recorded in ``_MATH_TEMPFILES``,
1481 and performs a graceful shutdown of the Tkinter application.
1482 """
1483 # Signal threads to stop scheduling UI updates
1484 self._is_closing = True
1485
1486 # Clean up temporary files
1487 for p in list(_MATH_TEMPFILES):
1488 try:
1489 os.remove(p)
1490 except:
1491 pass
1492
1493 # Graceful Tkinter shutdown
1494 try:
1495 self.quit() # Stop the mainloop
1496 except Exception:
1497 pass
1498
1499 try:
1500 self.destroy() # Destroy all widgets
1501 except Exception:
1502 pass
1503
1504
1505def main():
1506 app = GalCubeCraftGUI()
1507 try:
1508 app.mainloop()
1509 except KeyboardInterrupt:
1510 pass
1511 finally:
1512 # Ensure cleanup happens
1513 try:
1514 app._is_closing = True
1515 app.quit()
1516 except:
1517 pass
1518 try:
1519 app.destroy()
1520 except:
1521 pass
1522
1523if __name__ == '__main__':
1524 main()