GUI

Contents

GUI#

Show source#

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

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