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, 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()