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_TEMPFILES for 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: Tk

Main GUI application for interactively configuring and running GalCubeCraft simulations.

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. Calling Generate runs the generator in a background daemon thread so the UI remains responsive; generated results become available via self.generator.results.

  • Visualisation buttons call into functions defined in

    GalCubeCraft.visualise which 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_TEMPFILES list and cleaned up when the GUI is closed via _on_close.

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_close accordingly.

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 GalCubeCraft object 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.

generate()[source]#
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.Scale and a right-aligned textual value display. The function attaches a trace to var so 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.generator reference 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.

show_logs()[source]#
show_mom1()[source]#
show_moments()[source]#
show_slice()[source]#

Display an interactive spectral-slice viewer for the first cube.

Uses the helper in visualise.slice_view which provides an interactive Matplotlib Slider to step through spectral channels.

show_spectra()[source]#
class GalCubeCraft.gui.LogWindow(master)[source]#

Bases: Toplevel

Top-level log window that captures and displays stdout/stderr.

LogWindow creates a simple resizable Toplevel containing a Tk Text widget and installs TextRedirector instances on sys.stdout and sys.stderr so that all subsequent print output and uncaught exception tracebacks are visible in the GUI. The window restores the original streams when closed.

Behaviour#

  • Creating an instance replaces sys.stdout and sys.stderr in

    the running interpreter until the window is closed (on_close).

  • The window configures a separate text tag for stderr so error

    messages are coloured differently.

Example

>>> log = LogWindow(root)
>>> log.deiconify()  # show the window
on_close()[source]#
class GalCubeCraft.gui.TextRedirector(widget, tag='stdout')[source]#

Bases: object

Redirect writes into a Tk Text widget 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). TextRedirector implements a minimal stream interface (write and flush) so it can be assigned directly to sys.stdout or sys.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')
flush()[source]#
write(string)[source]#
GalCubeCraft.gui.latex_label(parent, latex, font_size=2)[source]#

Render a LaTeX string to a crisp Tkinter Label using 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 Label containing 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_TEMPFILES list so they can be removed when the application exits. Callers should ensure the GUI’s cleanup routine calls os.remove on 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 Label to.

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 Label widget 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.main()[source]#
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 inner frame while the outer provides a consistent visual outline.

Notes

  • When width or height are provided the inner frame will have its requested size set and pack_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 outer frame immediately; this simplifies call-sites but means callers should not re-pack the outer.

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 None to 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 None to allow the inner frame to size to its children.

Returns:

(outer, inner) where outer is the bordered container Frame (already packed) and inner is the content Frame where widgets should be placed.

Return type:

tuple

Examples

>>> outer, inner = param_frame(parent, padding=10, width=300, height=80)
>>> ttk.Label(inner, text='My parameter').pack(anchor='w')

Show source#

Below is the source for the GUI module (kept for reference). The Sphinx viewcode extension also provides cross-linked, highlighted source views.

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