PulsePalGUI


This file is part of the Sanworks PulsePal repository Copyright (C) Sanworks LLC, Rochester, New York, USA


This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3.

This program is distributed WITHOUT ANY WARRANTY and without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with this program. If not, see http://www.gnu.org/licenses/.

   1"""
   2----------------------------------------------------------------------------
   3
   4This file is part of the Sanworks PulsePal repository
   5Copyright (C) Sanworks LLC, Rochester, New York, USA
   6
   7----------------------------------------------------------------------------
   8
   9This program is free software: you can redistribute it and/or modify
  10it under the terms of the GNU General Public License as published by
  11the Free Software Foundation, version 3.
  12
  13This program is distributed WITHOUT ANY WARRANTY and without even the
  14implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
  15See the GNU General Public License for more details.
  16
  17You should have received a copy of the GNU General Public License
  18along with this program. If not, see <http://www.gnu.org/licenses/>.
  19"""
  20
  21# Parameter editor GUI for the Pulse Pal Python interface. This is the Python
  22# analog of MATLAB/@PulsePalDevice/gui.m. Launch it with PulsePalDevice.gui().
  23
  24import dataclasses
  25import json
  26import subprocess
  27import sys
  28import tkinter as tk
  29import weakref
  30from tkinter import filedialog, messagebox, ttk
  31
  32# Widget colors for each theme. The light palette matches the platform's
  33# native widget colors, so light mode can keep the native ttk theme.
  34_PALETTES = {
  35    "light": {
  36        "bg": "#f0f0f0",
  37        "field": "#ffffff",
  38        "fg": "#000000",
  39        "disabled_fg": "#6d6d6d",
  40        "disabled_field": "#f0f0f0",
  41        "select_bg": "#0078d7",
  42        "select_fg": "#ffffff",
  43        "border": "#a0a0a0",
  44        "button": "#e1e1e1",
  45        "active": "#cce4f7",
  46        "tooltip_bg": "#ffffe0",
  47        "tooltip_fg": "#000000",
  48    },
  49    "dark": {
  50        "bg": "#2b2b2b",
  51        "field": "#3c3f41",
  52        "fg": "#e0e0e0",
  53        "disabled_fg": "#808080",
  54        "disabled_field": "#323232",
  55        "select_bg": "#4b6eaf",
  56        "select_fg": "#ffffff",
  57        "border": "#555555",
  58        "button": "#3c3f41",
  59        "active": "#4c5052",
  60        "tooltip_bg": "#4b4b4b",
  61        "tooltip_fg": "#e8e8e8",
  62    },
  63}
  64
  65
  66def _detect_desktop_theme():
  67    """Return 'dark' or 'light' by probing the desktop, defaulting to light."""
  68    try:
  69        if sys.platform == "win32":
  70            import winreg
  71
  72            key_path = (
  73                r"Software\Microsoft\Windows\CurrentVersion\Themes"
  74                r"\Personalize"
  75            )
  76            with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path) as key:
  77                uses_light, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
  78            return "light" if uses_light else "dark"
  79
  80        if sys.platform == "darwin":
  81            result = subprocess.run(
  82                ("defaults", "read", "-g", "AppleInterfaceStyle"),
  83                capture_output=True,
  84                text=True,
  85                timeout=1,
  86            )
  87            # The key is absent entirely when macOS is in light mode
  88            return "dark" if "dark" in result.stdout.lower() else "light"
  89
  90        result = subprocess.run(
  91            (
  92                "gsettings",
  93                "get",
  94                "org.gnome.desktop.interface",
  95                "color-scheme",
  96            ),
  97            capture_output=True,
  98            text=True,
  99            timeout=1,
 100        )
 101        return "dark" if "dark" in result.stdout.lower() else "light"
 102    except Exception:
 103        # Probing is best-effort; any failure falls back to the light theme
 104        return "light"
 105
 106
 107def _resolve_theme(theme):
 108    """Validate a theme, resolving None/'auto' to the desktop theme."""
 109    if theme is None or str(theme).lower() == "auto":
 110        return _detect_desktop_theme()
 111    name = str(theme).lower()
 112    if name not in _PALETTES:
 113        raise ValueError(
 114            f"Unknown theme: {theme!r}. theme must be 'light', 'dark', or "
 115            "None to match the desktop theme."
 116        )
 117    return name
 118
 119
 120def _format_number(value):
 121    """Format a parameter value for display without scientific notation."""
 122    text = f"{float(value):.6f}".rstrip("0").rstrip(".")
 123    return text if text not in ("", "-") else "0"
 124
 125
 126def _parse_number_list(text):
 127    """Parse a comma (or newline) delimited list of numbers."""
 128    items = [item.strip() for item in text.replace("\n", ",").split(",")]
 129    return [float(item) for item in items if item]
 130
 131
 132class _ToolTip:
 133    """Minimal hover tooltip, used to mirror the MATLAB GUI's tooltips."""
 134
 135    def __init__(self, widget, text, palette):
 136        self._widget = widget
 137        self._text = text
 138        # Held by reference, and updated in place by set_theme()
 139        self._palette = palette
 140        self._window = None
 141        widget.bind("<Enter>", self._show, add="+")
 142        widget.bind("<Leave>", self._hide, add="+")
 143        widget.bind("<ButtonPress>", self._hide, add="+")
 144
 145    def _show(self, _event=None):
 146        if self._window is not None or not self._text:
 147            return
 148        x = self._widget.winfo_rootx() + 20
 149        y = self._widget.winfo_rooty() + self._widget.winfo_height() + 4
 150        self._window = tk.Toplevel(self._widget)
 151        self._window.wm_overrideredirect(True)
 152        self._window.wm_geometry(f"+{x}+{y}")
 153        tk.Label(
 154            self._window,
 155            text=self._text,
 156            justify="left",
 157            background=self._palette["tooltip_bg"],
 158            foreground=self._palette["tooltip_fg"],
 159            relief="solid",
 160            borderwidth=1,
 161            wraplength=320,
 162        ).pack(ipadx=4, ipady=2)
 163
 164    def _hide(self, _event=None):
 165        if self._window is not None:
 166            try:
 167                self._window.destroy()
 168            except tk.TclError:
 169                pass
 170            self._window = None
 171
 172
 173class PulsePalGUI:
 174    """Parameter editor window for a connected PulsePalDevice.
 175
 176    Parameters are edited in a local copy held by the GUI, and are only sent
 177    to the device when 'Load to Device' is clicked. This matches the behavior
 178    of the MATLAB parameter GUI.
 179    """
 180
 181    # Check mark strokes, as 2x2 blocks on the indicator grid
 182    _INDICATOR_SIZE = 13
 183    _CHECK_MARK = (
 184        (3, 6), (4, 7), (5, 8), (6, 7), (7, 6), (8, 5), (9, 4),
 185    )
 186
 187    _PULSE_TYPES = ("Monophasic", "Biphasic")
 188    _CUSTOM_TRAIN_TARGETS = ("Pulses", "Bursts")
 189    _TRIGGER_MODES = ("Normal", "Toggle", "Pulse Gated")
 190
 191    _DEFAULT_OUTPUT_PARAMS = {
 192        "is_biphasic": 0,
 193        "phase1_voltage": 5.0,
 194        "phase2_voltage": -5.0,
 195        "resting_voltage": 0.0,
 196        "phase1_duration": 0.001,
 197        "inter_phase_interval": 0.001,
 198        "phase2_duration": 0.001,
 199        "inter_pulse_interval": 0.01,
 200        "burst_duration": 0.0,
 201        "inter_burst_interval": 0.0,
 202        "pulse_train_duration": 1.0,
 203        "pulse_train_delay": 0.0,
 204        "link_trigger_channel1": 1,
 205        "link_trigger_channel2": 0,
 206        "custom_train_id": 0,
 207        "custom_train_target": 0,
 208        "custom_train_loop": 0,
 209    }
 210
 211    # (parameter name, label, tooltip)
 212    _VOLTAGE_FIELDS = (
 213        (
 214            "resting_voltage",
 215            "Resting (V)",
 216            "Voltage while not delivering a pulse (V)",
 217        ),
 218        (
 219            "phase1_voltage",
 220            "Phase1 (V)",
 221            "Voltage of the first phase of each pulse (V)",
 222        ),
 223        (
 224            "phase2_voltage",
 225            "Phase2 (V)",
 226            "Voltage of the second phase of each pulse (V)",
 227        ),
 228    )
 229    _TIME_FIELDS = (
 230        (
 231            "phase1_duration",
 232            "Phase1 (s)",
 233            "Duration of the first phase of each pulse (s)",
 234        ),
 235        (
 236            "inter_phase_interval",
 237            "Phase Interval",
 238            "Interval between pulse phases (s)",
 239        ),
 240        (
 241            "phase2_duration",
 242            "Phase2 (s)",
 243            "Duration of the second phase of each pulse (s)",
 244        ),
 245        (
 246            "inter_pulse_interval",
 247            "Pulse Interval",
 248            "Interval between pulse-end and the next pulse (s)",
 249        ),
 250        (
 251            "burst_duration",
 252            "Burst (s)",
 253            "Duration of pulse bursts (0 = no bursts, units = seconds)",
 254        ),
 255        (
 256            "inter_burst_interval",
 257            "Burst Interval",
 258            "Interval between pulse bursts (s)",
 259        ),
 260        (
 261            "pulse_train_duration",
 262            "Train (s)",
 263            "Duration of the pulse train (s)",
 264        ),
 265        (
 266            "pulse_train_delay",
 267            "Train Delay",
 268            "Delay from trigger to pulse train onset (s)",
 269        ),
 270    )
 271
 272    # Parameters that are only meaningful for biphasic pulses
 273    _BIPHASIC_ONLY = (
 274        "phase2_voltage",
 275        "inter_phase_interval",
 276        "phase2_duration",
 277    )
 278
 279    # Valid ranges, matching those enforced by the device interface
 280    _FIELD_RANGES = {
 281        "resting_voltage": (-10.0, 10.0),
 282        "phase1_voltage": (-10.0, 10.0),
 283        "phase2_voltage": (-10.0, 10.0),
 284        "phase1_duration": (0.0001, 3600.0),
 285        "inter_phase_interval": (0.0, 3600.0),
 286        "phase2_duration": (0.0001, 3600.0),
 287        "inter_pulse_interval": (0.0001, 3600.0),
 288        "burst_duration": (0.0, 3600.0),
 289        "inter_burst_interval": (0.0, 3600.0),
 290        "pulse_train_duration": (0.0001, 3600.0),
 291        "pulse_train_delay": (0.0, 3600.0),
 292    }
 293
 294    def __init__(self, device, theme=None):
 295        # The device is held weakly so that the GUI never keeps a released
 296        # PulsePalDevice alive: the device's destructor closes this window.
 297        self._device_ref = weakref.ref(device)
 298        self._closed = False
 299        self._release_host_event_loop = None
 300        self._topmost_after_id = None
 301
 302        # Resolved before any window exists, so an invalid theme argument
 303        # raises without leaving a half-built GUI behind
 304        theme = _resolve_theme(theme)
 305        self._theme = None
 306        self._palette = {}
 307        self._native_ttk_theme = None
 308        self._indicator_element = None
 309        self._indicator_images = {}
 310        self._loading = True
 311        self._last_program_dir = ""
 312
 313        n_trains = getattr(device.info, "n_custom_pulse_trains", None) or 2
 314        self._n_custom_trains = int(n_trains)
 315        self._custom_timestamps = [""] * self._n_custom_trains
 316        self._custom_voltages = [""] * self._n_custom_trains
 317
 318        self._params = {}
 319        self._trigger_mode = []
 320        self._load_default_params()
 321
 322        self._entry_vars = {}
 323        self._entry_widgets = {}
 324        self._field_labels = {
 325            name: label
 326            for name, label, _ in self._VOLTAGE_FIELDS + self._TIME_FIELDS
 327        }
 328
 329        self._root = tk.Tk()
 330        self._root.title("Pulse Pal Parameter GUI")
 331        self._root.resizable(False, False)
 332        self._root.protocol("WM_DELETE_WINDOW", self.close)
 333
 334        # Applied before the widgets are built: several of them take their
 335        # colors at construction time
 336        self.set_theme(theme)
 337
 338        self._build_header()
 339        self._build_output_panel()
 340        self._build_trigger_panel()
 341        self._build_custom_train_panel()
 342        self._build_status_bar()
 343
 344        self._loading = False
 345        self._refresh()
 346        self._set_status("GUI Loaded")
 347
 348    # ---- Public interface ----
 349
 350    @property
 351    def is_closed(self):
 352        """True once the GUI window has been closed."""
 353        return self._closed
 354
 355    @property
 356    def _device(self):
 357        """The device being edited, or None once it has been released."""
 358        ref = self._device_ref
 359        return ref() if ref is not None else None
 360
 361    @property
 362    def theme(self):
 363        """The active color theme, 'light' or 'dark'."""
 364        return self._theme
 365
 366    def set_theme(self, theme):
 367        """Switch the GUI between the light and dark color themes.
 368
 369        Args:
 370            theme: ``"light"``, ``"dark"``, or ``None`` to match the
 371                desktop theme.
 372
 373        Raises:
 374            ValueError: If the theme name is not recognized.
 375        """
 376        name = _resolve_theme(theme)
 377        if self._closed or name == self._theme:
 378            return
 379        self._theme = name
 380        # Updated in place, since tooltips hold a reference to this dict
 381        self._palette.clear()
 382        self._palette.update(_PALETTES[name])
 383        self._apply_theme_styles()
 384        self._apply_widget_palette()
 385
 386    def _apply_theme_styles(self):
 387        """Configure the ttk styles for the active theme."""
 388        palette = self._palette
 389        style = ttk.Style(self._root)
 390        if self._native_ttk_theme is None:
 391            self._native_ttk_theme = style.theme_use()
 392
 393        if self._theme != "dark":
 394            # The native ttk theme already matches the light palette
 395            style.theme_use(self._native_ttk_theme)
 396        else:
 397            # Native themes draw most widgets with the platform's own
 398            # colors and ignore color options, so dark mode switches to
 399            # 'clam', which is fully colorable
 400            style.theme_use("clam")
 401            style.configure(
 402                ".",
 403                background=palette["bg"],
 404                foreground=palette["fg"],
 405                fieldbackground=palette["field"],
 406                bordercolor=palette["border"],
 407                lightcolor=palette["bg"],
 408                darkcolor=palette["bg"],
 409                troughcolor=palette["field"],
 410                focuscolor=palette["select_bg"],
 411            )
 412            # clam maps disabled widgets to a light background of its own,
 413            # which configure() above does not override
 414            style.map(
 415                ".",
 416                background=[("disabled", palette["bg"])],
 417                foreground=[("disabled", palette["disabled_fg"])],
 418                fieldbackground=[("disabled", palette["disabled_field"])],
 419            )
 420            style.configure("TLabelframe", bordercolor=palette["border"])
 421            style.configure(
 422                "TButton",
 423                background=palette["button"],
 424                bordercolor=palette["border"],
 425                focuscolor=palette["bg"],
 426            )
 427            style.configure("TEntry", insertcolor=palette["fg"])
 428            style.configure(
 429                "TCombobox",
 430                arrowcolor=palette["fg"],
 431                background=palette["button"],
 432            )
 433            for widget in ("TCheckbutton", "TRadiobutton"):
 434                style.configure(
 435                    widget,
 436                    indicatorbackground=palette["field"],
 437                    indicatorforeground=palette["fg"],
 438                    # The indicator draws its own border, from options that
 439                    # do not inherit the style's bordercolor
 440                    upperbordercolor=palette["border"],
 441                    lowerbordercolor=palette["border"],
 442                )
 443                style.map(
 444                    widget,
 445                    foreground=[("disabled", palette["disabled_fg"])],
 446                    indicatorbackground=[
 447                        ("disabled", palette["disabled_field"]),
 448                        ("selected", palette["select_bg"]),
 449                    ],
 450                    indicatorforeground=[
 451                        ("selected", palette["select_fg"]),
 452                    ],
 453                )
 454            style.map(
 455                "TButton",
 456                background=[
 457                    ("pressed", palette["border"]),
 458                    ("active", palette["active"]),
 459                ],
 460                foreground=[("disabled", palette["disabled_fg"])],
 461            )
 462            style.map(
 463                "TEntry",
 464                fieldbackground=[
 465                    ("disabled", palette["disabled_field"]),
 466                ],
 467                foreground=[("disabled", palette["disabled_fg"])],
 468            )
 469            style.map(
 470                "TCombobox",
 471                fieldbackground=[
 472                    ("disabled", palette["disabled_field"]),
 473                    ("readonly", palette["field"]),
 474                ],
 475                foreground=[("disabled", palette["disabled_fg"])],
 476                arrowcolor=[("disabled", palette["disabled_fg"])],
 477                selectbackground=[("readonly", palette["field"])],
 478                selectforeground=[("readonly", palette["fg"])],
 479            )
 480            self._install_check_indicator(style)
 481
 482        # The combobox dropdown is a plain Tk listbox inside the popdown
 483        # window, which ttk styles do not reach
 484        for option, value in (
 485            ("*TCombobox*Listbox.background", palette["field"]),
 486            ("*TCombobox*Listbox.foreground", palette["fg"]),
 487            ("*TCombobox*Listbox.selectBackground", palette["select_bg"]),
 488            ("*TCombobox*Listbox.selectForeground", palette["select_fg"]),
 489        ):
 490            self._root.option_add(option, value)
 491
 492    def _install_check_indicator(self, style):
 493        """Give checkbuttons a check mark, which clam draws as an X."""
 494        name = "PulsePal.Checkbutton.indicator"
 495        if self._indicator_element is None:
 496            palette = self._palette
 497            images = {
 498                "off": self._draw_indicator(
 499                    palette["field"], palette["border"], None
 500                ),
 501                "on": self._draw_indicator(
 502                    palette["select_bg"],
 503                    palette["select_bg"],
 504                    palette["select_fg"],
 505                ),
 506                "off_disabled": self._draw_indicator(
 507                    palette["disabled_field"], palette["disabled_fg"], None
 508                ),
 509                "on_disabled": self._draw_indicator(
 510                    palette["disabled_field"],
 511                    palette["disabled_fg"],
 512                    palette["disabled_fg"],
 513                ),
 514            }
 515            # Held on the instance: ttk keeps no reference of its own, and
 516            # the indicators go blank if the images are collected
 517            self._indicator_images = images
 518            style.element_create(
 519                name,
 520                "image",
 521                images["off"],
 522                ("disabled", "selected", images["on_disabled"]),
 523                ("disabled", images["off_disabled"]),
 524                ("selected", images["on"]),
 525                sticky="",
 526            )
 527            self._indicator_element = name
 528
 529        style.layout(
 530            "TCheckbutton",
 531            self._replace_indicator(style.layout("TCheckbutton"), name),
 532        )
 533
 534    def _draw_indicator(self, fill, border, mark):
 535        """Draw one checkbutton indicator as a Tk image."""
 536        size = self._INDICATOR_SIZE
 537        image = tk.PhotoImage(master=self._root, width=size, height=size)
 538        image.put(border, to=(0, 0, size, size))
 539        image.put(fill, to=(1, 1, size - 1, size - 1))
 540        if mark is not None:
 541            for x, y in self._CHECK_MARK:
 542                image.put(mark, to=(x, y, x + 2, y + 2))
 543        return image
 544
 545    @classmethod
 546    def _replace_indicator(cls, layout, name):
 547        """Return a ttk layout with the checkbutton indicator swapped out."""
 548        replaced = []
 549        for element, options in layout:
 550            options = dict(options)
 551            children = options.get("children")
 552            if children:
 553                options["children"] = cls._replace_indicator(children, name)
 554            if element.endswith("Checkbutton.indicator"):
 555                element = name
 556            replaced.append((element, options))
 557        return replaced
 558
 559    def _apply_widget_palette(self):
 560        """Color the plain Tk widgets, which ttk styles do not cover."""
 561        palette = self._palette
 562        self._root.configure(background=palette["bg"])
 563
 564        listbox = getattr(self, "_custom_train_list", None)
 565        if listbox is not None:
 566            listbox.configure(
 567                background=palette["field"],
 568                foreground=palette["fg"],
 569                disabledforeground=palette["disabled_fg"],
 570                selectbackground=palette["select_bg"],
 571                selectforeground=palette["select_fg"],
 572                highlightbackground=palette["border"],
 573                highlightcolor=palette["select_bg"],
 574            )
 575
 576        texts = [
 577            getattr(self, "_timestamp_text", None),
 578            getattr(self, "_voltage_text", None),
 579        ]
 580        for text in texts:
 581            if text is None:
 582                continue
 583            text.configure(
 584                foreground=palette["fg"],
 585                insertbackground=palette["fg"],
 586                selectbackground=palette["select_bg"],
 587                selectforeground=palette["select_fg"],
 588                highlightbackground=palette["border"],
 589                highlightcolor=palette["select_bg"],
 590            )
 591        if all(text is not None for text in texts):
 592            # Repaints the text backgrounds for the current enabled state
 593            self._update_enabled_state()
 594
 595    def start(self, block=None):
 596        """Show the GUI.
 597
 598        Args:
 599            block: If True, run the Tk event loop until the window is closed.
 600                If False, return immediately (the host application must pump
 601                Tk events). If None, block only when the host does not
 602                already provide a Tk event loop.
 603        """
 604        if self._closed:
 605            return
 606        if block is None:
 607            block = not self._enable_host_event_loop()
 608        self._bring_to_front()
 609        if block:
 610            try:
 611                self._root.mainloop()
 612            finally:
 613                self.close()
 614
 615    def focus(self):
 616        """Raise the GUI window and give it keyboard focus."""
 617        self._bring_to_front()
 618
 619    def _bring_to_front(self):
 620        """Raise the window above the windows of other applications.
 621
 622        Windows refuses to activate a window belonging to a process that has
 623        not yet been in the foreground, which leaves the first GUI of a
 624        session stuck behind the host IDE. Marking the window topmost is not
 625        subject to that restriction; the flag is dropped again as soon as the
 626        window is up, so the window is raised without staying pinned over
 627        everything else.
 628        """
 629        root = self._root
 630        if self._closed or root is None:
 631            return
 632        try:
 633            root.deiconify()
 634            # The window must be realized before it can be raised
 635            root.update_idletasks()
 636            root.lift()
 637            root.attributes("-topmost", True)
 638            root.focus_force()
 639            self._cancel_topmost_reset()
 640            self._topmost_after_id = root.after_idle(self._clear_topmost)
 641        except tk.TclError:
 642            pass
 643
 644    def _clear_topmost(self):
 645        """Drop the topmost flag, leaving the window raised where it is."""
 646        self._topmost_after_id = None
 647        if self._closed or self._root is None:
 648            return
 649        try:
 650            self._root.attributes("-topmost", False)
 651        except tk.TclError:
 652            pass
 653
 654    def _cancel_topmost_reset(self):
 655        """Cancel a pending topmost reset, so it cannot outlive the window."""
 656        after_id = self._topmost_after_id
 657        self._topmost_after_id = None
 658        if after_id is None or self._root is None:
 659            return
 660        try:
 661            self._root.after_cancel(after_id)
 662        except tk.TclError:
 663            pass
 664
 665    def close(self):
 666        """Close the GUI window."""
 667        if self._closed:
 668            return
 669        self._closed = True
 670
 671        device = self._device
 672        self._device_ref = None
 673        if device is not None and getattr(device, "_gui", None) is self:
 674            device._gui = None
 675
 676        # Unregister before the window is destroyed, so that the host does
 677        # not keep pumping events for a dead Tk interpreter
 678        self._cancel_topmost_reset()
 679
 680        release = self._release_host_event_loop
 681        self._release_host_event_loop = None
 682        if release is not None:
 683            try:
 684                release()
 685            except Exception:
 686                pass
 687
 688        root = self._root
 689        self._root = None
 690        if root is not None:
 691            try:
 692                root.destroy()
 693            except Exception:
 694                # The interpreter may already be tearing down Tk
 695                pass
 696
 697    def _enable_host_event_loop(self):
 698        """Return True if the host will pump Tk events for the GUI."""
 699        # The PyCharm / PyDev console pumps a registered input hook between
 700        # commands. This window is passed explicitly: left to itself, PyDev
 701        # creates a second Tk interpreter, whose event loop would not service
 702        # this window. This is tried before IPython because the PyCharm
 703        # console's IPython shell delegates to the same hook.
 704        try:
 705            from pydev_ipython.inputhook import (
 706                GUI_TK,
 707                clear_inputhook,
 708                enable_gui,
 709            )
 710            enable_gui(GUI_TK, app=self._root)
 711        except Exception:
 712            pass
 713        else:
 714            self._release_host_event_loop = clear_inputhook
 715            return True
 716
 717        try:
 718            from IPython import get_ipython
 719            shell = get_ipython()
 720        except Exception:
 721            shell = None
 722
 723        if shell is not None:
 724            try:
 725                shell.enable_gui("tk")
 726                return True
 727            except Exception:
 728                return False
 729
 730        # The interactive CPython prompt pumps Tk events between commands
 731        return bool(getattr(sys, "ps1", None)) or bool(sys.flags.interactive)
 732
 733    # ---- Parameter storage ----
 734
 735    def _load_default_params(self):
 736        self._params = {
 737            name: [value] * 4
 738            for name, value in self._DEFAULT_OUTPUT_PARAMS.items()
 739        }
 740        self._trigger_mode = [0, 0]
 741
 742    def _output_channel(self):
 743        return self._output_channel_var.get()
 744
 745    def _trigger_channel(self):
 746        return self._trigger_channel_var.get()
 747
 748    # ---- Widget construction ----
 749
 750    def _build_header(self):
 751        header = ttk.Frame(self._root)
 752        header.pack(fill="x", padx=10, pady=(8, 0))
 753
 754        ttk.Label(
 755            header,
 756            text="Pulse Pal Program Editor",
 757            font=("TkDefaultFont", 16, "bold"),
 758        ).pack(side="left")
 759
 760        fire = ttk.Button(header, text="FIRE", width=6, command=self._fire)
 761        fire.pack(side="right", padx=(8, 0))
 762        self._tooltip(fire, "Trigger the selected output channels")
 763
 764        checks = ttk.Frame(header)
 765        checks.pack(side="right")
 766        ttk.Label(
 767            checks,
 768            text="Trigger Channels:",
 769            font=("TkDefaultFont", 9, "bold"),
 770        ).grid(row=1, column=0, padx=(0, 6))
 771        self._fire_vars = []
 772        for channel in range(1, 5):
 773            var = tk.IntVar(value=0)
 774            self._fire_vars.append(var)
 775            ttk.Label(
 776                checks,
 777                text=str(channel),
 778                font=("TkDefaultFont", 9, "bold"),
 779            ).grid(row=0, column=channel)
 780            check = ttk.Checkbutton(checks, variable=var)
 781            check.grid(row=1, column=channel)
 782            self._tooltip(
 783                check, f"Include output channel {channel} when firing"
 784            )
 785
 786        toolbar = ttk.Frame(self._root)
 787        toolbar.pack(fill="x", padx=10, pady=(6, 0))
 788        tools = (
 789            ("Restore Defaults", self._restore_defaults,
 790             "Restore default parameters"),
 791            ("Open Program...", self._open_program,
 792             "Open a program from a .json file"),
 793            ("Save Program...", self._save_program,
 794             "Save the current program to a .json file"),
 795            ("Load to Device", self._upload_program,
 796             "Load the current program to the Pulse Pal device"),
 797        )
 798        for text, command, tooltip in tools:
 799            button = ttk.Button(toolbar, text=text, command=command)
 800            button.pack(side="left", padx=(0, 6))
 801            self._tooltip(button, tooltip)
 802
 803    def _build_output_panel(self):
 804        panel = ttk.LabelFrame(self._root, text="Output Channels")
 805        panel.pack(fill="x", padx=10, pady=(8, 0), ipady=4)
 806
 807        channels = ttk.LabelFrame(panel, text="Channel")
 808        channels.pack(side="left", padx=6, pady=4, anchor="n")
 809        self._tooltip(channels, "Select an output channel to edit")
 810        self._output_channel_var = tk.IntVar(value=1)
 811        for index, channel in enumerate((1, 2, 3, 4)):
 812            ttk.Radiobutton(
 813                channels,
 814                text=str(channel),
 815                value=channel,
 816                variable=self._output_channel_var,
 817                command=self._refresh,
 818            ).grid(row=index // 2, column=index % 2, sticky="w", padx=2)
 819
 820        fields = ttk.Frame(panel)
 821        fields.pack(side="left", fill="x", expand=True, pady=2)
 822
 823        top = ttk.Frame(fields)
 824        top.pack(fill="x")
 825        column = 0
 826
 827        self._pulse_type_box = self._labeled(
 828            top,
 829            column,
 830            "Pulse Type",
 831            lambda parent: self._make_combobox(
 832                parent, self._PULSE_TYPES, self._on_pulse_type, width=11
 833            ),
 834            "Biphasic pulses add an interval at the resting voltage and "
 835            "then a second phase to each pulse",
 836        )
 837        column += 1
 838
 839        for name, label, tooltip in self._VOLTAGE_FIELDS:
 840            self._labeled(
 841                top,
 842                column,
 843                label,
 844                lambda parent, n=name: self._make_entry(parent, n),
 845                tooltip,
 846            )
 847            column += 1
 848
 849        train_ids = ["0 (None)"] + [
 850            str(i) for i in range(1, self._n_custom_trains + 1)
 851        ]
 852        self._custom_id_box = self._labeled(
 853            top,
 854            column,
 855            "Custom Train ID",
 856            lambda parent: self._make_combobox(
 857                parent, train_ids, self._on_custom_train_id, width=9
 858            ),
 859            "Custom pulse train to play on this output channel",
 860        )
 861        column += 1
 862
 863        self._custom_target_box = self._labeled(
 864            top,
 865            column,
 866            "Custom Train of",
 867            lambda parent: self._make_combobox(
 868                parent,
 869                self._CUSTOM_TRAIN_TARGETS,
 870                self._on_custom_train_target,
 871                width=9,
 872            ),
 873            "Custom train timestamps can indicate the onset of either each "
 874            "pulse, or each burst of pulses",
 875        )
 876        column += 1
 877
 878        self._custom_loop_var = tk.IntVar(value=0)
 879        self._custom_loop_check = self._labeled(
 880            top,
 881            column,
 882            "Loop",
 883            lambda parent: ttk.Checkbutton(
 884                parent,
 885                variable=self._custom_loop_var,
 886                command=self._on_custom_train_loop,
 887            ),
 888            "If enabled, the custom pulse train loops until the pulse train "
 889            "duration (Train (s) below)",
 890        )
 891
 892        bottom = ttk.Frame(fields)
 893        bottom.pack(fill="x")
 894        for column, (name, label, tooltip) in enumerate(self._TIME_FIELDS):
 895            self._labeled(
 896                bottom,
 897                column,
 898                label,
 899                lambda parent, n=name: self._make_entry(parent, n),
 900                tooltip,
 901            )
 902
 903    def _build_trigger_panel(self):
 904        panel = ttk.LabelFrame(self._root, text="Trigger Channels")
 905        panel.pack(fill="x", padx=10, pady=(8, 0), ipady=4)
 906
 907        channels = ttk.LabelFrame(panel, text="Channel")
 908        channels.pack(side="left", padx=6, pady=4, anchor="n")
 909        self._tooltip(channels, "Select a trigger channel to edit")
 910        self._trigger_channel_var = tk.IntVar(value=1)
 911        for channel in (1, 2):
 912            ttk.Radiobutton(
 913                channels,
 914                text=str(channel),
 915                value=channel,
 916                variable=self._trigger_channel_var,
 917                command=self._refresh,
 918            ).grid(row=0, column=channel - 1, sticky="w", padx=2)
 919
 920        fields = ttk.Frame(panel)
 921        fields.pack(side="left", pady=2)
 922
 923        self._trigger_mode_box = self._labeled(
 924            fields,
 925            0,
 926            "Trigger Mode",
 927            lambda parent: self._make_combobox(
 928                parent, self._TRIGGER_MODES, self._on_trigger_mode, width=12
 929            ),
 930            "Normal: TTL during pulse train ignored. Toggle: TTL during "
 931            "pulse train stops train. Pulse Gated: Pulse train only runs "
 932            "while trigger is high",
 933        )
 934
 935        links = ttk.Frame(fields)
 936        links.grid(row=0, column=1, padx=(16, 4), sticky="w")
 937        ttk.Label(links, text="Link to outputs").pack(anchor="w")
 938        link_row = ttk.Frame(links)
 939        link_row.pack(anchor="w")
 940        self._link_vars = []
 941        for channel in range(1, 5):
 942            var = tk.IntVar(value=0)
 943            self._link_vars.append(var)
 944            check = ttk.Checkbutton(
 945                link_row,
 946                text=f"Ch{channel}",
 947                variable=var,
 948                command=lambda c=channel: self._on_trigger_link(c),
 949            )
 950            check.pack(side="left", padx=(0, 8))
 951            self._tooltip(check, f"Link trigger channel to output channel "
 952                            f"{channel}")
 953
 954    def _build_custom_train_panel(self):
 955        panel = ttk.LabelFrame(self._root, text="Custom Pulse Trains")
 956        panel.pack(fill="x", padx=10, pady=(8, 0), ipady=4)
 957
 958        selector = ttk.Frame(panel)
 959        selector.pack(side="left", padx=6, pady=4, anchor="n")
 960        ttk.Label(selector, text="Custom Train ID").pack(anchor="w")
 961        self._custom_train_list = tk.Listbox(
 962            selector,
 963            height=min(self._n_custom_trains, 4),
 964            width=6,
 965            exportselection=False,
 966            # A plain Tk border is always drawn black, so the colorable
 967            # focus ring is used as the border instead
 968            relief="flat",
 969            borderwidth=0,
 970            highlightthickness=1,
 971            highlightbackground=self._palette["border"],
 972            highlightcolor=self._palette["select_bg"],
 973            background=self._palette["field"],
 974            foreground=self._palette["fg"],
 975            disabledforeground=self._palette["disabled_fg"],
 976            selectbackground=self._palette["select_bg"],
 977            selectforeground=self._palette["select_fg"],
 978        )
 979        for train_id in range(1, self._n_custom_trains + 1):
 980            self._custom_train_list.insert("end", str(train_id))
 981        self._custom_train_list.selection_set(0)
 982        self._custom_train_list.bind(
 983            "<<ListboxSelect>>", self._on_custom_train_selected
 984        )
 985        self._custom_train_list.pack(anchor="w")
 986        self._tooltip(self._custom_train_list, "Select the custom train to "
 987                                               "program")
 988
 989        self._timestamp_text = self._make_train_text(
 990            panel,
 991            "Timestamps (s)",
 992            "Enter the onset time of each pulse in the custom pulse train "
 993            "(comma delimited, units = seconds)",
 994            self._commit_timestamps,
 995        )
 996        self._voltage_text = self._make_train_text(
 997            panel,
 998            "Voltages (V)",
 999            "Enter the voltage of each pulse in the custom pulse train "
1000            "(comma delimited, units = volts)",
1001            self._commit_voltages,
1002        )
1003
1004    def _build_status_bar(self):
1005        bar = ttk.Frame(self._root)
1006        bar.pack(fill="x", padx=10, pady=(6, 8))
1007
1008        info = self._device.info
1009        port_name = getattr(self._device.port, "port", "")
1010        ttk.Label(
1011            bar, text=f"HW: Pulse Pal v{info.hardware_version}"
1012        ).pack(side="left", padx=(0, 12))
1013        ttk.Label(
1014            bar, text=f"Firmware: v{info.firmware_version}"
1015        ).pack(side="left", padx=(0, 12))
1016        ttk.Label(bar, text=f"Port: {port_name}").pack(side="left")
1017
1018        self._status_var = tk.StringVar(value="Status: GUI Loaded")
1019        ttk.Label(
1020            bar,
1021            textvariable=self._status_var,
1022            font=("TkDefaultFont", 9, "bold"),
1023        ).pack(side="right")
1024
1025    def _tooltip(self, widget, text):
1026        """Attach a hover tooltip that follows the active theme."""
1027        return _ToolTip(widget, text, self._palette)
1028
1029    def _labeled(self, parent, column, label, widget_factory, tooltip=None):
1030        """Create a labeled widget in a grid column of parent."""
1031        holder = ttk.Frame(parent)
1032        holder.grid(row=0, column=column, padx=4, pady=2, sticky="w")
1033        ttk.Label(holder, text=label).pack(anchor="w")
1034        widget = widget_factory(holder)
1035        widget.pack(anchor="w")
1036        if tooltip:
1037            self._tooltip(widget, tooltip)
1038        return widget
1039
1040    def _make_entry(self, parent, name):
1041        var = tk.StringVar()
1042        entry = ttk.Entry(parent, textvariable=var, width=10, justify="center")
1043        entry.bind("<Return>", lambda event, n=name: self._commit_entry(n))
1044        entry.bind("<FocusOut>", lambda event, n=name: self._commit_entry(n))
1045        self._entry_vars[name] = var
1046        self._entry_widgets[name] = entry
1047        return entry
1048
1049    def _make_combobox(self, parent, values, callback, width):
1050        box = ttk.Combobox(
1051            parent,
1052            values=list(values),
1053            state="readonly",
1054            width=width,
1055        )
1056        box.current(0)
1057        box.bind("<<ComboboxSelected>>", lambda event: callback())
1058        return box
1059
1060    def _make_train_text(self, parent, label, tooltip, commit):
1061        holder = ttk.Frame(parent)
1062        holder.pack(side="left", padx=6, pady=4, anchor="n")
1063        ttk.Label(holder, text=label).pack(anchor="w")
1064        text = tk.Text(
1065            holder,
1066            width=34,
1067            height=3,
1068            wrap="word",
1069            # A plain Tk border is always drawn black, so the colorable
1070            # focus ring is used as the border instead
1071            relief="flat",
1072            borderwidth=0,
1073            highlightthickness=1,
1074            highlightbackground=self._palette["border"],
1075            highlightcolor=self._palette["select_bg"],
1076            background=self._palette["field"],
1077            foreground=self._palette["fg"],
1078            insertbackground=self._palette["fg"],
1079            selectbackground=self._palette["select_bg"],
1080            selectforeground=self._palette["select_fg"],
1081        )
1082        text.pack(anchor="w")
1083        text.bind("<FocusOut>", lambda event: commit())
1084        self._tooltip(text, tooltip)
1085        return text
1086
1087    # ---- Refreshing the view ----
1088
1089    def _refresh(self):
1090        """Push the local parameter copy to the widgets."""
1091        self._loading = True
1092        try:
1093            channel = self._output_channel()
1094            index = channel - 1
1095
1096            self._pulse_type_box.current(
1097                int(self._params["is_biphasic"][index])
1098            )
1099            self._custom_id_box.current(
1100                int(self._params["custom_train_id"][index])
1101            )
1102            self._custom_target_box.current(
1103                int(self._params["custom_train_target"][index])
1104            )
1105            self._custom_loop_var.set(
1106                int(self._params["custom_train_loop"][index])
1107            )
1108
1109            for name in self._entry_vars:
1110                self._entry_vars[name].set(
1111                    _format_number(self._params[name][index])
1112                )
1113
1114            trigger_channel = self._trigger_channel()
1115            self._trigger_mode_box.current(
1116                int(self._trigger_mode[trigger_channel - 1])
1117            )
1118            link_param = f"link_trigger_channel{trigger_channel}"
1119            for output_index, var in enumerate(self._link_vars):
1120                var.set(int(self._params[link_param][output_index]))
1121        finally:
1122            self._loading = False
1123
1124        self._refresh_custom_train_view()
1125        self._update_enabled_state()
1126
1127    def _refresh_custom_train_view(self):
1128        train_index = self._selected_custom_train() - 1
1129        self._set_text(
1130            self._timestamp_text, self._custom_timestamps[train_index]
1131        )
1132        self._set_text(self._voltage_text, self._custom_voltages[train_index])
1133
1134    def _update_enabled_state(self):
1135        index = self._output_channel() - 1
1136        is_biphasic = bool(self._params["is_biphasic"][index])
1137        for name in self._BIPHASIC_ONLY:
1138            self._entry_widgets[name].configure(
1139                state="normal" if is_biphasic else "disabled"
1140            )
1141
1142        uses_custom = int(self._params["custom_train_id"][index]) > 0
1143        self._custom_target_box.configure(
1144            state="readonly" if uses_custom else "disabled"
1145        )
1146        self._custom_loop_check.configure(
1147            state="normal" if uses_custom else "disabled"
1148        )
1149        self._custom_train_list.configure(
1150            state="normal" if uses_custom else "disabled"
1151        )
1152        for text in (self._timestamp_text, self._voltage_text):
1153            text.configure(
1154                state="normal" if uses_custom else "disabled",
1155                background=self._palette[
1156                    "field" if uses_custom else "disabled_field"
1157                ],
1158            )
1159
1160    def _set_text(self, widget, value):
1161        was_disabled = str(widget.cget("state")) == "disabled"
1162        if was_disabled:
1163            widget.configure(state="normal")
1164        widget.delete("1.0", "end")
1165        widget.insert("1.0", value)
1166        if was_disabled:
1167            widget.configure(state="disabled")
1168
1169    def _set_status(self, message):
1170        self._status_var.set(f"Status: {message}")
1171
1172    def _selected_custom_train(self):
1173        selection = self._custom_train_list.curselection()
1174        return (selection[0] + 1) if selection else 1
1175
1176    # ---- Parameter edit callbacks ----
1177
1178    def _commit_entry(self, name):
1179        if self._loading or self._closed:
1180            return
1181        index = self._output_channel() - 1
1182        var = self._entry_vars[name]
1183        label = self._field_labels[name]
1184        try:
1185            value = float(var.get())
1186        except ValueError:
1187            self._show_error(f"{label} must be a number.")
1188            var.set(_format_number(self._params[name][index]))
1189            return
1190
1191        low, high = self._FIELD_RANGES[name]
1192        if not low <= value <= high:
1193            self._show_error(
1194                f"{label} must be in range {_format_number(low)} to "
1195                f"{_format_number(high)}."
1196            )
1197            var.set(_format_number(self._params[name][index]))
1198            return
1199
1200        self._params[name][index] = value
1201        var.set(_format_number(value))
1202
1203    def _on_pulse_type(self):
1204        index = self._output_channel() - 1
1205        self._params["is_biphasic"][index] = self._pulse_type_box.current()
1206        self._update_enabled_state()
1207
1208    def _on_custom_train_id(self):
1209        index = self._output_channel() - 1
1210        self._params["custom_train_id"][index] = self._custom_id_box.current()
1211        self._update_enabled_state()
1212
1213    def _on_custom_train_target(self):
1214        index = self._output_channel() - 1
1215        self._params["custom_train_target"][index] = (
1216            self._custom_target_box.current()
1217        )
1218
1219    def _on_custom_train_loop(self):
1220        index = self._output_channel() - 1
1221        self._params["custom_train_loop"][index] = self._custom_loop_var.get()
1222
1223    def _on_trigger_mode(self):
1224        channel_index = self._trigger_channel() - 1
1225        self._trigger_mode[channel_index] = self._trigger_mode_box.current()
1226
1227    def _on_trigger_link(self, output_channel):
1228        link_param = f"link_trigger_channel{self._trigger_channel()}"
1229        self._params[link_param][output_channel - 1] = (
1230            self._link_vars[output_channel - 1].get()
1231        )
1232
1233    def _on_custom_train_selected(self, _event=None):
1234        self._refresh_custom_train_view()
1235
1236    def _commit_timestamps(self):
1237        if self._closed:
1238            return
1239        text = self._timestamp_text.get("1.0", "end-1c")
1240        self._custom_timestamps[self._selected_custom_train() - 1] = text
1241        try:
1242            _parse_number_list(text)
1243        except ValueError:
1244            self._show_error(
1245                "Timestamps must be a comma-delimited list of pulse onset "
1246                "times, given in seconds."
1247            )
1248
1249    def _commit_voltages(self):
1250        if self._closed:
1251            return
1252        text = self._voltage_text.get("1.0", "end-1c")
1253        self._custom_voltages[self._selected_custom_train() - 1] = text
1254        try:
1255            _parse_number_list(text)
1256        except ValueError:
1257            self._show_error(
1258                "Voltages must be a comma-delimited list of pulse voltages, "
1259                "given in volts."
1260            )
1261
1262    # ---- Toolbar actions ----
1263
1264    def _fire(self):
1265        channels = [
1266            channel
1267            for channel, var in enumerate(self._fire_vars, start=1)
1268            if var.get()
1269        ]
1270        device = self._device
1271        if not channels or device is None:
1272            return
1273        try:
1274            device.trigger(channels)
1275        except Exception as exc:
1276            self._show_error(f"Failed to trigger output channels:\n{exc}")
1277            return
1278        self._set_status("Output Channels Triggered")
1279
1280    def _restore_defaults(self):
1281        self._load_default_params()
1282        self._custom_timestamps = [""] * self._n_custom_trains
1283        self._custom_voltages = [""] * self._n_custom_trains
1284        self._reset_selections()
1285        self._refresh()
1286        self._set_status("Default Program Restored")
1287
1288    def _upload_program(self):
1289        device = self._device
1290        if device is None:
1291            return
1292
1293        custom_trains = self._collect_custom_trains()
1294        if custom_trains is None:
1295            return
1296
1297        for index in range(4):
1298            if (
1299                int(self._params["custom_train_target"][index]) == 1
1300                and float(self._params["burst_duration"][index]) == 0
1301            ):
1302                self._show_error(
1303                    f"Error in output channel {index + 1}: when custom train "
1304                    "times target burst onsets, a non-zero burst duration "
1305                    "must be defined."
1306                )
1307                return
1308
1309        try:
1310            for name, values in self._params.items():
1311                getattr(device, name)[1:5] = list(values)
1312            device.trigger_mode[1:3] = list(self._trigger_mode)
1313            device.sync_to_device()
1314            for train_id, times, voltages in custom_trains:
1315                device.send_custom_pulse_train(train_id, times, voltages)
1316        except Exception as exc:
1317            self._show_error(f"Failed to load the program to the device:\n"
1318                             f"{exc}")
1319            return
1320        self._set_status("Program Loaded to Device")
1321
1322    def _collect_custom_trains(self):
1323        """Parse the custom train editor, returning None if it is invalid."""
1324        trains = []
1325        for train_id in range(1, self._n_custom_trains + 1):
1326            timestamp_text = self._custom_timestamps[train_id - 1]
1327            voltage_text = self._custom_voltages[train_id - 1]
1328            if not timestamp_text.strip() and not voltage_text.strip():
1329                continue
1330            try:
1331                times = _parse_number_list(timestamp_text)
1332                voltages = _parse_number_list(voltage_text)
1333            except ValueError:
1334                self._show_error(
1335                    f"Failed to load custom pulse train {train_id}: "
1336                    "timestamps and voltages must be comma-delimited lists "
1337                    "of numbers."
1338                )
1339                return None
1340            if len(times) != len(voltages):
1341                self._show_error(
1342                    f"Failed to load custom pulse train {train_id}: the "
1343                    "number of timestamps and voltages must match."
1344                )
1345                return None
1346            if times:
1347                trains.append((train_id, times, voltages))
1348        return trains
1349
1350    def _save_program(self):
1351        device = self._device
1352        if device is None:
1353            return
1354
1355        path = filedialog.asksaveasfilename(
1356            parent=self._root,
1357            title="Save program",
1358            defaultextension=".json",
1359            initialfile="PulsePalProgram.json",
1360            initialdir=self._last_program_dir or None,
1361            filetypes=(("Pulse Pal program", "*.json"), ("All files", "*.*")),
1362        )
1363        if not path:
1364            return
1365
1366        program = {
1367            "params": {
1368                name: list(values) for name, values in self._params.items()
1369            },
1370            "trigger_mode": list(self._trigger_mode),
1371            "custom_train_timestamps": list(self._custom_timestamps),
1372            "custom_train_voltages": list(self._custom_voltages),
1373            "device_info": dataclasses.asdict(device.info),
1374        }
1375        try:
1376            with open(path, "w", encoding="utf-8") as program_file:
1377                json.dump(program, program_file, indent=2)
1378        except OSError as exc:
1379            self._show_error(f"Failed to save the program:\n{exc}")
1380            return
1381
1382        self._last_program_dir = path
1383        self._set_status("Program Saved")
1384        self.focus()
1385
1386    def _open_program(self):
1387        path = filedialog.askopenfilename(
1388            parent=self._root,
1389            title="Open program",
1390            initialdir=self._last_program_dir or None,
1391            filetypes=(("Pulse Pal program", "*.json"), ("All files", "*.*")),
1392        )
1393        if not path:
1394            return
1395
1396        try:
1397            with open(path, encoding="utf-8") as program_file:
1398                program = json.load(program_file)
1399            params = program["params"]
1400            new_params = {}
1401            for name, default in self._DEFAULT_OUTPUT_PARAMS.items():
1402                values = params.get(name, [default] * 4)
1403                if len(values) != 4:
1404                    raise ValueError(
1405                        f"{name} must have one value per output channel."
1406                    )
1407                new_params[name] = [float(value) for value in values]
1408            trigger_mode = [
1409                int(value) for value in program.get("trigger_mode", [0, 0])
1410            ]
1411            if len(trigger_mode) != 2:
1412                raise ValueError(
1413                    "trigger_mode must have one value per trigger channel."
1414                )
1415            timestamps = list(program.get("custom_train_timestamps", []))
1416            voltages = list(program.get("custom_train_voltages", []))
1417        except (OSError, ValueError, KeyError, TypeError) as exc:
1418            self._show_error(f"Failed to open the program:\n{exc}")
1419            return
1420
1421        self._params = new_params
1422        self._trigger_mode = trigger_mode
1423        self._custom_timestamps = self._fit_custom_trains(timestamps)
1424        self._custom_voltages = self._fit_custom_trains(voltages)
1425        self._reset_selections()
1426        self._refresh()
1427        self._last_program_dir = path
1428        self._set_status("Program Opened")
1429        self.focus()
1430
1431    def _fit_custom_trains(self, values):
1432        """Coerce a saved custom train list to this device's train count."""
1433        fitted = [""] * self._n_custom_trains
1434        for index, value in enumerate(values[:self._n_custom_trains]):
1435            if isinstance(value, (list, tuple)):
1436                value = ", ".join(_format_number(item) for item in value)
1437            fitted[index] = str(value)
1438        return fitted
1439
1440    def _reset_selections(self):
1441        self._output_channel_var.set(1)
1442        self._trigger_channel_var.set(1)
1443        self._custom_train_list.selection_clear(0, "end")
1444        self._custom_train_list.selection_set(0)
1445
1446    def _show_error(self, message):
1447        messagebox.showerror("Pulse Pal", message, parent=self._root)
class PulsePalGUI:
 174class PulsePalGUI:
 175    """Parameter editor window for a connected PulsePalDevice.
 176
 177    Parameters are edited in a local copy held by the GUI, and are only sent
 178    to the device when 'Load to Device' is clicked. This matches the behavior
 179    of the MATLAB parameter GUI.
 180    """
 181
 182    # Check mark strokes, as 2x2 blocks on the indicator grid
 183    _INDICATOR_SIZE = 13
 184    _CHECK_MARK = (
 185        (3, 6), (4, 7), (5, 8), (6, 7), (7, 6), (8, 5), (9, 4),
 186    )
 187
 188    _PULSE_TYPES = ("Monophasic", "Biphasic")
 189    _CUSTOM_TRAIN_TARGETS = ("Pulses", "Bursts")
 190    _TRIGGER_MODES = ("Normal", "Toggle", "Pulse Gated")
 191
 192    _DEFAULT_OUTPUT_PARAMS = {
 193        "is_biphasic": 0,
 194        "phase1_voltage": 5.0,
 195        "phase2_voltage": -5.0,
 196        "resting_voltage": 0.0,
 197        "phase1_duration": 0.001,
 198        "inter_phase_interval": 0.001,
 199        "phase2_duration": 0.001,
 200        "inter_pulse_interval": 0.01,
 201        "burst_duration": 0.0,
 202        "inter_burst_interval": 0.0,
 203        "pulse_train_duration": 1.0,
 204        "pulse_train_delay": 0.0,
 205        "link_trigger_channel1": 1,
 206        "link_trigger_channel2": 0,
 207        "custom_train_id": 0,
 208        "custom_train_target": 0,
 209        "custom_train_loop": 0,
 210    }
 211
 212    # (parameter name, label, tooltip)
 213    _VOLTAGE_FIELDS = (
 214        (
 215            "resting_voltage",
 216            "Resting (V)",
 217            "Voltage while not delivering a pulse (V)",
 218        ),
 219        (
 220            "phase1_voltage",
 221            "Phase1 (V)",
 222            "Voltage of the first phase of each pulse (V)",
 223        ),
 224        (
 225            "phase2_voltage",
 226            "Phase2 (V)",
 227            "Voltage of the second phase of each pulse (V)",
 228        ),
 229    )
 230    _TIME_FIELDS = (
 231        (
 232            "phase1_duration",
 233            "Phase1 (s)",
 234            "Duration of the first phase of each pulse (s)",
 235        ),
 236        (
 237            "inter_phase_interval",
 238            "Phase Interval",
 239            "Interval between pulse phases (s)",
 240        ),
 241        (
 242            "phase2_duration",
 243            "Phase2 (s)",
 244            "Duration of the second phase of each pulse (s)",
 245        ),
 246        (
 247            "inter_pulse_interval",
 248            "Pulse Interval",
 249            "Interval between pulse-end and the next pulse (s)",
 250        ),
 251        (
 252            "burst_duration",
 253            "Burst (s)",
 254            "Duration of pulse bursts (0 = no bursts, units = seconds)",
 255        ),
 256        (
 257            "inter_burst_interval",
 258            "Burst Interval",
 259            "Interval between pulse bursts (s)",
 260        ),
 261        (
 262            "pulse_train_duration",
 263            "Train (s)",
 264            "Duration of the pulse train (s)",
 265        ),
 266        (
 267            "pulse_train_delay",
 268            "Train Delay",
 269            "Delay from trigger to pulse train onset (s)",
 270        ),
 271    )
 272
 273    # Parameters that are only meaningful for biphasic pulses
 274    _BIPHASIC_ONLY = (
 275        "phase2_voltage",
 276        "inter_phase_interval",
 277        "phase2_duration",
 278    )
 279
 280    # Valid ranges, matching those enforced by the device interface
 281    _FIELD_RANGES = {
 282        "resting_voltage": (-10.0, 10.0),
 283        "phase1_voltage": (-10.0, 10.0),
 284        "phase2_voltage": (-10.0, 10.0),
 285        "phase1_duration": (0.0001, 3600.0),
 286        "inter_phase_interval": (0.0, 3600.0),
 287        "phase2_duration": (0.0001, 3600.0),
 288        "inter_pulse_interval": (0.0001, 3600.0),
 289        "burst_duration": (0.0, 3600.0),
 290        "inter_burst_interval": (0.0, 3600.0),
 291        "pulse_train_duration": (0.0001, 3600.0),
 292        "pulse_train_delay": (0.0, 3600.0),
 293    }
 294
 295    def __init__(self, device, theme=None):
 296        # The device is held weakly so that the GUI never keeps a released
 297        # PulsePalDevice alive: the device's destructor closes this window.
 298        self._device_ref = weakref.ref(device)
 299        self._closed = False
 300        self._release_host_event_loop = None
 301        self._topmost_after_id = None
 302
 303        # Resolved before any window exists, so an invalid theme argument
 304        # raises without leaving a half-built GUI behind
 305        theme = _resolve_theme(theme)
 306        self._theme = None
 307        self._palette = {}
 308        self._native_ttk_theme = None
 309        self._indicator_element = None
 310        self._indicator_images = {}
 311        self._loading = True
 312        self._last_program_dir = ""
 313
 314        n_trains = getattr(device.info, "n_custom_pulse_trains", None) or 2
 315        self._n_custom_trains = int(n_trains)
 316        self._custom_timestamps = [""] * self._n_custom_trains
 317        self._custom_voltages = [""] * self._n_custom_trains
 318
 319        self._params = {}
 320        self._trigger_mode = []
 321        self._load_default_params()
 322
 323        self._entry_vars = {}
 324        self._entry_widgets = {}
 325        self._field_labels = {
 326            name: label
 327            for name, label, _ in self._VOLTAGE_FIELDS + self._TIME_FIELDS
 328        }
 329
 330        self._root = tk.Tk()
 331        self._root.title("Pulse Pal Parameter GUI")
 332        self._root.resizable(False, False)
 333        self._root.protocol("WM_DELETE_WINDOW", self.close)
 334
 335        # Applied before the widgets are built: several of them take their
 336        # colors at construction time
 337        self.set_theme(theme)
 338
 339        self._build_header()
 340        self._build_output_panel()
 341        self._build_trigger_panel()
 342        self._build_custom_train_panel()
 343        self._build_status_bar()
 344
 345        self._loading = False
 346        self._refresh()
 347        self._set_status("GUI Loaded")
 348
 349    # ---- Public interface ----
 350
 351    @property
 352    def is_closed(self):
 353        """True once the GUI window has been closed."""
 354        return self._closed
 355
 356    @property
 357    def _device(self):
 358        """The device being edited, or None once it has been released."""
 359        ref = self._device_ref
 360        return ref() if ref is not None else None
 361
 362    @property
 363    def theme(self):
 364        """The active color theme, 'light' or 'dark'."""
 365        return self._theme
 366
 367    def set_theme(self, theme):
 368        """Switch the GUI between the light and dark color themes.
 369
 370        Args:
 371            theme: ``"light"``, ``"dark"``, or ``None`` to match the
 372                desktop theme.
 373
 374        Raises:
 375            ValueError: If the theme name is not recognized.
 376        """
 377        name = _resolve_theme(theme)
 378        if self._closed or name == self._theme:
 379            return
 380        self._theme = name
 381        # Updated in place, since tooltips hold a reference to this dict
 382        self._palette.clear()
 383        self._palette.update(_PALETTES[name])
 384        self._apply_theme_styles()
 385        self._apply_widget_palette()
 386
 387    def _apply_theme_styles(self):
 388        """Configure the ttk styles for the active theme."""
 389        palette = self._palette
 390        style = ttk.Style(self._root)
 391        if self._native_ttk_theme is None:
 392            self._native_ttk_theme = style.theme_use()
 393
 394        if self._theme != "dark":
 395            # The native ttk theme already matches the light palette
 396            style.theme_use(self._native_ttk_theme)
 397        else:
 398            # Native themes draw most widgets with the platform's own
 399            # colors and ignore color options, so dark mode switches to
 400            # 'clam', which is fully colorable
 401            style.theme_use("clam")
 402            style.configure(
 403                ".",
 404                background=palette["bg"],
 405                foreground=palette["fg"],
 406                fieldbackground=palette["field"],
 407                bordercolor=palette["border"],
 408                lightcolor=palette["bg"],
 409                darkcolor=palette["bg"],
 410                troughcolor=palette["field"],
 411                focuscolor=palette["select_bg"],
 412            )
 413            # clam maps disabled widgets to a light background of its own,
 414            # which configure() above does not override
 415            style.map(
 416                ".",
 417                background=[("disabled", palette["bg"])],
 418                foreground=[("disabled", palette["disabled_fg"])],
 419                fieldbackground=[("disabled", palette["disabled_field"])],
 420            )
 421            style.configure("TLabelframe", bordercolor=palette["border"])
 422            style.configure(
 423                "TButton",
 424                background=palette["button"],
 425                bordercolor=palette["border"],
 426                focuscolor=palette["bg"],
 427            )
 428            style.configure("TEntry", insertcolor=palette["fg"])
 429            style.configure(
 430                "TCombobox",
 431                arrowcolor=palette["fg"],
 432                background=palette["button"],
 433            )
 434            for widget in ("TCheckbutton", "TRadiobutton"):
 435                style.configure(
 436                    widget,
 437                    indicatorbackground=palette["field"],
 438                    indicatorforeground=palette["fg"],
 439                    # The indicator draws its own border, from options that
 440                    # do not inherit the style's bordercolor
 441                    upperbordercolor=palette["border"],
 442                    lowerbordercolor=palette["border"],
 443                )
 444                style.map(
 445                    widget,
 446                    foreground=[("disabled", palette["disabled_fg"])],
 447                    indicatorbackground=[
 448                        ("disabled", palette["disabled_field"]),
 449                        ("selected", palette["select_bg"]),
 450                    ],
 451                    indicatorforeground=[
 452                        ("selected", palette["select_fg"]),
 453                    ],
 454                )
 455            style.map(
 456                "TButton",
 457                background=[
 458                    ("pressed", palette["border"]),
 459                    ("active", palette["active"]),
 460                ],
 461                foreground=[("disabled", palette["disabled_fg"])],
 462            )
 463            style.map(
 464                "TEntry",
 465                fieldbackground=[
 466                    ("disabled", palette["disabled_field"]),
 467                ],
 468                foreground=[("disabled", palette["disabled_fg"])],
 469            )
 470            style.map(
 471                "TCombobox",
 472                fieldbackground=[
 473                    ("disabled", palette["disabled_field"]),
 474                    ("readonly", palette["field"]),
 475                ],
 476                foreground=[("disabled", palette["disabled_fg"])],
 477                arrowcolor=[("disabled", palette["disabled_fg"])],
 478                selectbackground=[("readonly", palette["field"])],
 479                selectforeground=[("readonly", palette["fg"])],
 480            )
 481            self._install_check_indicator(style)
 482
 483        # The combobox dropdown is a plain Tk listbox inside the popdown
 484        # window, which ttk styles do not reach
 485        for option, value in (
 486            ("*TCombobox*Listbox.background", palette["field"]),
 487            ("*TCombobox*Listbox.foreground", palette["fg"]),
 488            ("*TCombobox*Listbox.selectBackground", palette["select_bg"]),
 489            ("*TCombobox*Listbox.selectForeground", palette["select_fg"]),
 490        ):
 491            self._root.option_add(option, value)
 492
 493    def _install_check_indicator(self, style):
 494        """Give checkbuttons a check mark, which clam draws as an X."""
 495        name = "PulsePal.Checkbutton.indicator"
 496        if self._indicator_element is None:
 497            palette = self._palette
 498            images = {
 499                "off": self._draw_indicator(
 500                    palette["field"], palette["border"], None
 501                ),
 502                "on": self._draw_indicator(
 503                    palette["select_bg"],
 504                    palette["select_bg"],
 505                    palette["select_fg"],
 506                ),
 507                "off_disabled": self._draw_indicator(
 508                    palette["disabled_field"], palette["disabled_fg"], None
 509                ),
 510                "on_disabled": self._draw_indicator(
 511                    palette["disabled_field"],
 512                    palette["disabled_fg"],
 513                    palette["disabled_fg"],
 514                ),
 515            }
 516            # Held on the instance: ttk keeps no reference of its own, and
 517            # the indicators go blank if the images are collected
 518            self._indicator_images = images
 519            style.element_create(
 520                name,
 521                "image",
 522                images["off"],
 523                ("disabled", "selected", images["on_disabled"]),
 524                ("disabled", images["off_disabled"]),
 525                ("selected", images["on"]),
 526                sticky="",
 527            )
 528            self._indicator_element = name
 529
 530        style.layout(
 531            "TCheckbutton",
 532            self._replace_indicator(style.layout("TCheckbutton"), name),
 533        )
 534
 535    def _draw_indicator(self, fill, border, mark):
 536        """Draw one checkbutton indicator as a Tk image."""
 537        size = self._INDICATOR_SIZE
 538        image = tk.PhotoImage(master=self._root, width=size, height=size)
 539        image.put(border, to=(0, 0, size, size))
 540        image.put(fill, to=(1, 1, size - 1, size - 1))
 541        if mark is not None:
 542            for x, y in self._CHECK_MARK:
 543                image.put(mark, to=(x, y, x + 2, y + 2))
 544        return image
 545
 546    @classmethod
 547    def _replace_indicator(cls, layout, name):
 548        """Return a ttk layout with the checkbutton indicator swapped out."""
 549        replaced = []
 550        for element, options in layout:
 551            options = dict(options)
 552            children = options.get("children")
 553            if children:
 554                options["children"] = cls._replace_indicator(children, name)
 555            if element.endswith("Checkbutton.indicator"):
 556                element = name
 557            replaced.append((element, options))
 558        return replaced
 559
 560    def _apply_widget_palette(self):
 561        """Color the plain Tk widgets, which ttk styles do not cover."""
 562        palette = self._palette
 563        self._root.configure(background=palette["bg"])
 564
 565        listbox = getattr(self, "_custom_train_list", None)
 566        if listbox is not None:
 567            listbox.configure(
 568                background=palette["field"],
 569                foreground=palette["fg"],
 570                disabledforeground=palette["disabled_fg"],
 571                selectbackground=palette["select_bg"],
 572                selectforeground=palette["select_fg"],
 573                highlightbackground=palette["border"],
 574                highlightcolor=palette["select_bg"],
 575            )
 576
 577        texts = [
 578            getattr(self, "_timestamp_text", None),
 579            getattr(self, "_voltage_text", None),
 580        ]
 581        for text in texts:
 582            if text is None:
 583                continue
 584            text.configure(
 585                foreground=palette["fg"],
 586                insertbackground=palette["fg"],
 587                selectbackground=palette["select_bg"],
 588                selectforeground=palette["select_fg"],
 589                highlightbackground=palette["border"],
 590                highlightcolor=palette["select_bg"],
 591            )
 592        if all(text is not None for text in texts):
 593            # Repaints the text backgrounds for the current enabled state
 594            self._update_enabled_state()
 595
 596    def start(self, block=None):
 597        """Show the GUI.
 598
 599        Args:
 600            block: If True, run the Tk event loop until the window is closed.
 601                If False, return immediately (the host application must pump
 602                Tk events). If None, block only when the host does not
 603                already provide a Tk event loop.
 604        """
 605        if self._closed:
 606            return
 607        if block is None:
 608            block = not self._enable_host_event_loop()
 609        self._bring_to_front()
 610        if block:
 611            try:
 612                self._root.mainloop()
 613            finally:
 614                self.close()
 615
 616    def focus(self):
 617        """Raise the GUI window and give it keyboard focus."""
 618        self._bring_to_front()
 619
 620    def _bring_to_front(self):
 621        """Raise the window above the windows of other applications.
 622
 623        Windows refuses to activate a window belonging to a process that has
 624        not yet been in the foreground, which leaves the first GUI of a
 625        session stuck behind the host IDE. Marking the window topmost is not
 626        subject to that restriction; the flag is dropped again as soon as the
 627        window is up, so the window is raised without staying pinned over
 628        everything else.
 629        """
 630        root = self._root
 631        if self._closed or root is None:
 632            return
 633        try:
 634            root.deiconify()
 635            # The window must be realized before it can be raised
 636            root.update_idletasks()
 637            root.lift()
 638            root.attributes("-topmost", True)
 639            root.focus_force()
 640            self._cancel_topmost_reset()
 641            self._topmost_after_id = root.after_idle(self._clear_topmost)
 642        except tk.TclError:
 643            pass
 644
 645    def _clear_topmost(self):
 646        """Drop the topmost flag, leaving the window raised where it is."""
 647        self._topmost_after_id = None
 648        if self._closed or self._root is None:
 649            return
 650        try:
 651            self._root.attributes("-topmost", False)
 652        except tk.TclError:
 653            pass
 654
 655    def _cancel_topmost_reset(self):
 656        """Cancel a pending topmost reset, so it cannot outlive the window."""
 657        after_id = self._topmost_after_id
 658        self._topmost_after_id = None
 659        if after_id is None or self._root is None:
 660            return
 661        try:
 662            self._root.after_cancel(after_id)
 663        except tk.TclError:
 664            pass
 665
 666    def close(self):
 667        """Close the GUI window."""
 668        if self._closed:
 669            return
 670        self._closed = True
 671
 672        device = self._device
 673        self._device_ref = None
 674        if device is not None and getattr(device, "_gui", None) is self:
 675            device._gui = None
 676
 677        # Unregister before the window is destroyed, so that the host does
 678        # not keep pumping events for a dead Tk interpreter
 679        self._cancel_topmost_reset()
 680
 681        release = self._release_host_event_loop
 682        self._release_host_event_loop = None
 683        if release is not None:
 684            try:
 685                release()
 686            except Exception:
 687                pass
 688
 689        root = self._root
 690        self._root = None
 691        if root is not None:
 692            try:
 693                root.destroy()
 694            except Exception:
 695                # The interpreter may already be tearing down Tk
 696                pass
 697
 698    def _enable_host_event_loop(self):
 699        """Return True if the host will pump Tk events for the GUI."""
 700        # The PyCharm / PyDev console pumps a registered input hook between
 701        # commands. This window is passed explicitly: left to itself, PyDev
 702        # creates a second Tk interpreter, whose event loop would not service
 703        # this window. This is tried before IPython because the PyCharm
 704        # console's IPython shell delegates to the same hook.
 705        try:
 706            from pydev_ipython.inputhook import (
 707                GUI_TK,
 708                clear_inputhook,
 709                enable_gui,
 710            )
 711            enable_gui(GUI_TK, app=self._root)
 712        except Exception:
 713            pass
 714        else:
 715            self._release_host_event_loop = clear_inputhook
 716            return True
 717
 718        try:
 719            from IPython import get_ipython
 720            shell = get_ipython()
 721        except Exception:
 722            shell = None
 723
 724        if shell is not None:
 725            try:
 726                shell.enable_gui("tk")
 727                return True
 728            except Exception:
 729                return False
 730
 731        # The interactive CPython prompt pumps Tk events between commands
 732        return bool(getattr(sys, "ps1", None)) or bool(sys.flags.interactive)
 733
 734    # ---- Parameter storage ----
 735
 736    def _load_default_params(self):
 737        self._params = {
 738            name: [value] * 4
 739            for name, value in self._DEFAULT_OUTPUT_PARAMS.items()
 740        }
 741        self._trigger_mode = [0, 0]
 742
 743    def _output_channel(self):
 744        return self._output_channel_var.get()
 745
 746    def _trigger_channel(self):
 747        return self._trigger_channel_var.get()
 748
 749    # ---- Widget construction ----
 750
 751    def _build_header(self):
 752        header = ttk.Frame(self._root)
 753        header.pack(fill="x", padx=10, pady=(8, 0))
 754
 755        ttk.Label(
 756            header,
 757            text="Pulse Pal Program Editor",
 758            font=("TkDefaultFont", 16, "bold"),
 759        ).pack(side="left")
 760
 761        fire = ttk.Button(header, text="FIRE", width=6, command=self._fire)
 762        fire.pack(side="right", padx=(8, 0))
 763        self._tooltip(fire, "Trigger the selected output channels")
 764
 765        checks = ttk.Frame(header)
 766        checks.pack(side="right")
 767        ttk.Label(
 768            checks,
 769            text="Trigger Channels:",
 770            font=("TkDefaultFont", 9, "bold"),
 771        ).grid(row=1, column=0, padx=(0, 6))
 772        self._fire_vars = []
 773        for channel in range(1, 5):
 774            var = tk.IntVar(value=0)
 775            self._fire_vars.append(var)
 776            ttk.Label(
 777                checks,
 778                text=str(channel),
 779                font=("TkDefaultFont", 9, "bold"),
 780            ).grid(row=0, column=channel)
 781            check = ttk.Checkbutton(checks, variable=var)
 782            check.grid(row=1, column=channel)
 783            self._tooltip(
 784                check, f"Include output channel {channel} when firing"
 785            )
 786
 787        toolbar = ttk.Frame(self._root)
 788        toolbar.pack(fill="x", padx=10, pady=(6, 0))
 789        tools = (
 790            ("Restore Defaults", self._restore_defaults,
 791             "Restore default parameters"),
 792            ("Open Program...", self._open_program,
 793             "Open a program from a .json file"),
 794            ("Save Program...", self._save_program,
 795             "Save the current program to a .json file"),
 796            ("Load to Device", self._upload_program,
 797             "Load the current program to the Pulse Pal device"),
 798        )
 799        for text, command, tooltip in tools:
 800            button = ttk.Button(toolbar, text=text, command=command)
 801            button.pack(side="left", padx=(0, 6))
 802            self._tooltip(button, tooltip)
 803
 804    def _build_output_panel(self):
 805        panel = ttk.LabelFrame(self._root, text="Output Channels")
 806        panel.pack(fill="x", padx=10, pady=(8, 0), ipady=4)
 807
 808        channels = ttk.LabelFrame(panel, text="Channel")
 809        channels.pack(side="left", padx=6, pady=4, anchor="n")
 810        self._tooltip(channels, "Select an output channel to edit")
 811        self._output_channel_var = tk.IntVar(value=1)
 812        for index, channel in enumerate((1, 2, 3, 4)):
 813            ttk.Radiobutton(
 814                channels,
 815                text=str(channel),
 816                value=channel,
 817                variable=self._output_channel_var,
 818                command=self._refresh,
 819            ).grid(row=index // 2, column=index % 2, sticky="w", padx=2)
 820
 821        fields = ttk.Frame(panel)
 822        fields.pack(side="left", fill="x", expand=True, pady=2)
 823
 824        top = ttk.Frame(fields)
 825        top.pack(fill="x")
 826        column = 0
 827
 828        self._pulse_type_box = self._labeled(
 829            top,
 830            column,
 831            "Pulse Type",
 832            lambda parent: self._make_combobox(
 833                parent, self._PULSE_TYPES, self._on_pulse_type, width=11
 834            ),
 835            "Biphasic pulses add an interval at the resting voltage and "
 836            "then a second phase to each pulse",
 837        )
 838        column += 1
 839
 840        for name, label, tooltip in self._VOLTAGE_FIELDS:
 841            self._labeled(
 842                top,
 843                column,
 844                label,
 845                lambda parent, n=name: self._make_entry(parent, n),
 846                tooltip,
 847            )
 848            column += 1
 849
 850        train_ids = ["0 (None)"] + [
 851            str(i) for i in range(1, self._n_custom_trains + 1)
 852        ]
 853        self._custom_id_box = self._labeled(
 854            top,
 855            column,
 856            "Custom Train ID",
 857            lambda parent: self._make_combobox(
 858                parent, train_ids, self._on_custom_train_id, width=9
 859            ),
 860            "Custom pulse train to play on this output channel",
 861        )
 862        column += 1
 863
 864        self._custom_target_box = self._labeled(
 865            top,
 866            column,
 867            "Custom Train of",
 868            lambda parent: self._make_combobox(
 869                parent,
 870                self._CUSTOM_TRAIN_TARGETS,
 871                self._on_custom_train_target,
 872                width=9,
 873            ),
 874            "Custom train timestamps can indicate the onset of either each "
 875            "pulse, or each burst of pulses",
 876        )
 877        column += 1
 878
 879        self._custom_loop_var = tk.IntVar(value=0)
 880        self._custom_loop_check = self._labeled(
 881            top,
 882            column,
 883            "Loop",
 884            lambda parent: ttk.Checkbutton(
 885                parent,
 886                variable=self._custom_loop_var,
 887                command=self._on_custom_train_loop,
 888            ),
 889            "If enabled, the custom pulse train loops until the pulse train "
 890            "duration (Train (s) below)",
 891        )
 892
 893        bottom = ttk.Frame(fields)
 894        bottom.pack(fill="x")
 895        for column, (name, label, tooltip) in enumerate(self._TIME_FIELDS):
 896            self._labeled(
 897                bottom,
 898                column,
 899                label,
 900                lambda parent, n=name: self._make_entry(parent, n),
 901                tooltip,
 902            )
 903
 904    def _build_trigger_panel(self):
 905        panel = ttk.LabelFrame(self._root, text="Trigger Channels")
 906        panel.pack(fill="x", padx=10, pady=(8, 0), ipady=4)
 907
 908        channels = ttk.LabelFrame(panel, text="Channel")
 909        channels.pack(side="left", padx=6, pady=4, anchor="n")
 910        self._tooltip(channels, "Select a trigger channel to edit")
 911        self._trigger_channel_var = tk.IntVar(value=1)
 912        for channel in (1, 2):
 913            ttk.Radiobutton(
 914                channels,
 915                text=str(channel),
 916                value=channel,
 917                variable=self._trigger_channel_var,
 918                command=self._refresh,
 919            ).grid(row=0, column=channel - 1, sticky="w", padx=2)
 920
 921        fields = ttk.Frame(panel)
 922        fields.pack(side="left", pady=2)
 923
 924        self._trigger_mode_box = self._labeled(
 925            fields,
 926            0,
 927            "Trigger Mode",
 928            lambda parent: self._make_combobox(
 929                parent, self._TRIGGER_MODES, self._on_trigger_mode, width=12
 930            ),
 931            "Normal: TTL during pulse train ignored. Toggle: TTL during "
 932            "pulse train stops train. Pulse Gated: Pulse train only runs "
 933            "while trigger is high",
 934        )
 935
 936        links = ttk.Frame(fields)
 937        links.grid(row=0, column=1, padx=(16, 4), sticky="w")
 938        ttk.Label(links, text="Link to outputs").pack(anchor="w")
 939        link_row = ttk.Frame(links)
 940        link_row.pack(anchor="w")
 941        self._link_vars = []
 942        for channel in range(1, 5):
 943            var = tk.IntVar(value=0)
 944            self._link_vars.append(var)
 945            check = ttk.Checkbutton(
 946                link_row,
 947                text=f"Ch{channel}",
 948                variable=var,
 949                command=lambda c=channel: self._on_trigger_link(c),
 950            )
 951            check.pack(side="left", padx=(0, 8))
 952            self._tooltip(check, f"Link trigger channel to output channel "
 953                            f"{channel}")
 954
 955    def _build_custom_train_panel(self):
 956        panel = ttk.LabelFrame(self._root, text="Custom Pulse Trains")
 957        panel.pack(fill="x", padx=10, pady=(8, 0), ipady=4)
 958
 959        selector = ttk.Frame(panel)
 960        selector.pack(side="left", padx=6, pady=4, anchor="n")
 961        ttk.Label(selector, text="Custom Train ID").pack(anchor="w")
 962        self._custom_train_list = tk.Listbox(
 963            selector,
 964            height=min(self._n_custom_trains, 4),
 965            width=6,
 966            exportselection=False,
 967            # A plain Tk border is always drawn black, so the colorable
 968            # focus ring is used as the border instead
 969            relief="flat",
 970            borderwidth=0,
 971            highlightthickness=1,
 972            highlightbackground=self._palette["border"],
 973            highlightcolor=self._palette["select_bg"],
 974            background=self._palette["field"],
 975            foreground=self._palette["fg"],
 976            disabledforeground=self._palette["disabled_fg"],
 977            selectbackground=self._palette["select_bg"],
 978            selectforeground=self._palette["select_fg"],
 979        )
 980        for train_id in range(1, self._n_custom_trains + 1):
 981            self._custom_train_list.insert("end", str(train_id))
 982        self._custom_train_list.selection_set(0)
 983        self._custom_train_list.bind(
 984            "<<ListboxSelect>>", self._on_custom_train_selected
 985        )
 986        self._custom_train_list.pack(anchor="w")
 987        self._tooltip(self._custom_train_list, "Select the custom train to "
 988                                               "program")
 989
 990        self._timestamp_text = self._make_train_text(
 991            panel,
 992            "Timestamps (s)",
 993            "Enter the onset time of each pulse in the custom pulse train "
 994            "(comma delimited, units = seconds)",
 995            self._commit_timestamps,
 996        )
 997        self._voltage_text = self._make_train_text(
 998            panel,
 999            "Voltages (V)",
1000            "Enter the voltage of each pulse in the custom pulse train "
1001            "(comma delimited, units = volts)",
1002            self._commit_voltages,
1003        )
1004
1005    def _build_status_bar(self):
1006        bar = ttk.Frame(self._root)
1007        bar.pack(fill="x", padx=10, pady=(6, 8))
1008
1009        info = self._device.info
1010        port_name = getattr(self._device.port, "port", "")
1011        ttk.Label(
1012            bar, text=f"HW: Pulse Pal v{info.hardware_version}"
1013        ).pack(side="left", padx=(0, 12))
1014        ttk.Label(
1015            bar, text=f"Firmware: v{info.firmware_version}"
1016        ).pack(side="left", padx=(0, 12))
1017        ttk.Label(bar, text=f"Port: {port_name}").pack(side="left")
1018
1019        self._status_var = tk.StringVar(value="Status: GUI Loaded")
1020        ttk.Label(
1021            bar,
1022            textvariable=self._status_var,
1023            font=("TkDefaultFont", 9, "bold"),
1024        ).pack(side="right")
1025
1026    def _tooltip(self, widget, text):
1027        """Attach a hover tooltip that follows the active theme."""
1028        return _ToolTip(widget, text, self._palette)
1029
1030    def _labeled(self, parent, column, label, widget_factory, tooltip=None):
1031        """Create a labeled widget in a grid column of parent."""
1032        holder = ttk.Frame(parent)
1033        holder.grid(row=0, column=column, padx=4, pady=2, sticky="w")
1034        ttk.Label(holder, text=label).pack(anchor="w")
1035        widget = widget_factory(holder)
1036        widget.pack(anchor="w")
1037        if tooltip:
1038            self._tooltip(widget, tooltip)
1039        return widget
1040
1041    def _make_entry(self, parent, name):
1042        var = tk.StringVar()
1043        entry = ttk.Entry(parent, textvariable=var, width=10, justify="center")
1044        entry.bind("<Return>", lambda event, n=name: self._commit_entry(n))
1045        entry.bind("<FocusOut>", lambda event, n=name: self._commit_entry(n))
1046        self._entry_vars[name] = var
1047        self._entry_widgets[name] = entry
1048        return entry
1049
1050    def _make_combobox(self, parent, values, callback, width):
1051        box = ttk.Combobox(
1052            parent,
1053            values=list(values),
1054            state="readonly",
1055            width=width,
1056        )
1057        box.current(0)
1058        box.bind("<<ComboboxSelected>>", lambda event: callback())
1059        return box
1060
1061    def _make_train_text(self, parent, label, tooltip, commit):
1062        holder = ttk.Frame(parent)
1063        holder.pack(side="left", padx=6, pady=4, anchor="n")
1064        ttk.Label(holder, text=label).pack(anchor="w")
1065        text = tk.Text(
1066            holder,
1067            width=34,
1068            height=3,
1069            wrap="word",
1070            # A plain Tk border is always drawn black, so the colorable
1071            # focus ring is used as the border instead
1072            relief="flat",
1073            borderwidth=0,
1074            highlightthickness=1,
1075            highlightbackground=self._palette["border"],
1076            highlightcolor=self._palette["select_bg"],
1077            background=self._palette["field"],
1078            foreground=self._palette["fg"],
1079            insertbackground=self._palette["fg"],
1080            selectbackground=self._palette["select_bg"],
1081            selectforeground=self._palette["select_fg"],
1082        )
1083        text.pack(anchor="w")
1084        text.bind("<FocusOut>", lambda event: commit())
1085        self._tooltip(text, tooltip)
1086        return text
1087
1088    # ---- Refreshing the view ----
1089
1090    def _refresh(self):
1091        """Push the local parameter copy to the widgets."""
1092        self._loading = True
1093        try:
1094            channel = self._output_channel()
1095            index = channel - 1
1096
1097            self._pulse_type_box.current(
1098                int(self._params["is_biphasic"][index])
1099            )
1100            self._custom_id_box.current(
1101                int(self._params["custom_train_id"][index])
1102            )
1103            self._custom_target_box.current(
1104                int(self._params["custom_train_target"][index])
1105            )
1106            self._custom_loop_var.set(
1107                int(self._params["custom_train_loop"][index])
1108            )
1109
1110            for name in self._entry_vars:
1111                self._entry_vars[name].set(
1112                    _format_number(self._params[name][index])
1113                )
1114
1115            trigger_channel = self._trigger_channel()
1116            self._trigger_mode_box.current(
1117                int(self._trigger_mode[trigger_channel - 1])
1118            )
1119            link_param = f"link_trigger_channel{trigger_channel}"
1120            for output_index, var in enumerate(self._link_vars):
1121                var.set(int(self._params[link_param][output_index]))
1122        finally:
1123            self._loading = False
1124
1125        self._refresh_custom_train_view()
1126        self._update_enabled_state()
1127
1128    def _refresh_custom_train_view(self):
1129        train_index = self._selected_custom_train() - 1
1130        self._set_text(
1131            self._timestamp_text, self._custom_timestamps[train_index]
1132        )
1133        self._set_text(self._voltage_text, self._custom_voltages[train_index])
1134
1135    def _update_enabled_state(self):
1136        index = self._output_channel() - 1
1137        is_biphasic = bool(self._params["is_biphasic"][index])
1138        for name in self._BIPHASIC_ONLY:
1139            self._entry_widgets[name].configure(
1140                state="normal" if is_biphasic else "disabled"
1141            )
1142
1143        uses_custom = int(self._params["custom_train_id"][index]) > 0
1144        self._custom_target_box.configure(
1145            state="readonly" if uses_custom else "disabled"
1146        )
1147        self._custom_loop_check.configure(
1148            state="normal" if uses_custom else "disabled"
1149        )
1150        self._custom_train_list.configure(
1151            state="normal" if uses_custom else "disabled"
1152        )
1153        for text in (self._timestamp_text, self._voltage_text):
1154            text.configure(
1155                state="normal" if uses_custom else "disabled",
1156                background=self._palette[
1157                    "field" if uses_custom else "disabled_field"
1158                ],
1159            )
1160
1161    def _set_text(self, widget, value):
1162        was_disabled = str(widget.cget("state")) == "disabled"
1163        if was_disabled:
1164            widget.configure(state="normal")
1165        widget.delete("1.0", "end")
1166        widget.insert("1.0", value)
1167        if was_disabled:
1168            widget.configure(state="disabled")
1169
1170    def _set_status(self, message):
1171        self._status_var.set(f"Status: {message}")
1172
1173    def _selected_custom_train(self):
1174        selection = self._custom_train_list.curselection()
1175        return (selection[0] + 1) if selection else 1
1176
1177    # ---- Parameter edit callbacks ----
1178
1179    def _commit_entry(self, name):
1180        if self._loading or self._closed:
1181            return
1182        index = self._output_channel() - 1
1183        var = self._entry_vars[name]
1184        label = self._field_labels[name]
1185        try:
1186            value = float(var.get())
1187        except ValueError:
1188            self._show_error(f"{label} must be a number.")
1189            var.set(_format_number(self._params[name][index]))
1190            return
1191
1192        low, high = self._FIELD_RANGES[name]
1193        if not low <= value <= high:
1194            self._show_error(
1195                f"{label} must be in range {_format_number(low)} to "
1196                f"{_format_number(high)}."
1197            )
1198            var.set(_format_number(self._params[name][index]))
1199            return
1200
1201        self._params[name][index] = value
1202        var.set(_format_number(value))
1203
1204    def _on_pulse_type(self):
1205        index = self._output_channel() - 1
1206        self._params["is_biphasic"][index] = self._pulse_type_box.current()
1207        self._update_enabled_state()
1208
1209    def _on_custom_train_id(self):
1210        index = self._output_channel() - 1
1211        self._params["custom_train_id"][index] = self._custom_id_box.current()
1212        self._update_enabled_state()
1213
1214    def _on_custom_train_target(self):
1215        index = self._output_channel() - 1
1216        self._params["custom_train_target"][index] = (
1217            self._custom_target_box.current()
1218        )
1219
1220    def _on_custom_train_loop(self):
1221        index = self._output_channel() - 1
1222        self._params["custom_train_loop"][index] = self._custom_loop_var.get()
1223
1224    def _on_trigger_mode(self):
1225        channel_index = self._trigger_channel() - 1
1226        self._trigger_mode[channel_index] = self._trigger_mode_box.current()
1227
1228    def _on_trigger_link(self, output_channel):
1229        link_param = f"link_trigger_channel{self._trigger_channel()}"
1230        self._params[link_param][output_channel - 1] = (
1231            self._link_vars[output_channel - 1].get()
1232        )
1233
1234    def _on_custom_train_selected(self, _event=None):
1235        self._refresh_custom_train_view()
1236
1237    def _commit_timestamps(self):
1238        if self._closed:
1239            return
1240        text = self._timestamp_text.get("1.0", "end-1c")
1241        self._custom_timestamps[self._selected_custom_train() - 1] = text
1242        try:
1243            _parse_number_list(text)
1244        except ValueError:
1245            self._show_error(
1246                "Timestamps must be a comma-delimited list of pulse onset "
1247                "times, given in seconds."
1248            )
1249
1250    def _commit_voltages(self):
1251        if self._closed:
1252            return
1253        text = self._voltage_text.get("1.0", "end-1c")
1254        self._custom_voltages[self._selected_custom_train() - 1] = text
1255        try:
1256            _parse_number_list(text)
1257        except ValueError:
1258            self._show_error(
1259                "Voltages must be a comma-delimited list of pulse voltages, "
1260                "given in volts."
1261            )
1262
1263    # ---- Toolbar actions ----
1264
1265    def _fire(self):
1266        channels = [
1267            channel
1268            for channel, var in enumerate(self._fire_vars, start=1)
1269            if var.get()
1270        ]
1271        device = self._device
1272        if not channels or device is None:
1273            return
1274        try:
1275            device.trigger(channels)
1276        except Exception as exc:
1277            self._show_error(f"Failed to trigger output channels:\n{exc}")
1278            return
1279        self._set_status("Output Channels Triggered")
1280
1281    def _restore_defaults(self):
1282        self._load_default_params()
1283        self._custom_timestamps = [""] * self._n_custom_trains
1284        self._custom_voltages = [""] * self._n_custom_trains
1285        self._reset_selections()
1286        self._refresh()
1287        self._set_status("Default Program Restored")
1288
1289    def _upload_program(self):
1290        device = self._device
1291        if device is None:
1292            return
1293
1294        custom_trains = self._collect_custom_trains()
1295        if custom_trains is None:
1296            return
1297
1298        for index in range(4):
1299            if (
1300                int(self._params["custom_train_target"][index]) == 1
1301                and float(self._params["burst_duration"][index]) == 0
1302            ):
1303                self._show_error(
1304                    f"Error in output channel {index + 1}: when custom train "
1305                    "times target burst onsets, a non-zero burst duration "
1306                    "must be defined."
1307                )
1308                return
1309
1310        try:
1311            for name, values in self._params.items():
1312                getattr(device, name)[1:5] = list(values)
1313            device.trigger_mode[1:3] = list(self._trigger_mode)
1314            device.sync_to_device()
1315            for train_id, times, voltages in custom_trains:
1316                device.send_custom_pulse_train(train_id, times, voltages)
1317        except Exception as exc:
1318            self._show_error(f"Failed to load the program to the device:\n"
1319                             f"{exc}")
1320            return
1321        self._set_status("Program Loaded to Device")
1322
1323    def _collect_custom_trains(self):
1324        """Parse the custom train editor, returning None if it is invalid."""
1325        trains = []
1326        for train_id in range(1, self._n_custom_trains + 1):
1327            timestamp_text = self._custom_timestamps[train_id - 1]
1328            voltage_text = self._custom_voltages[train_id - 1]
1329            if not timestamp_text.strip() and not voltage_text.strip():
1330                continue
1331            try:
1332                times = _parse_number_list(timestamp_text)
1333                voltages = _parse_number_list(voltage_text)
1334            except ValueError:
1335                self._show_error(
1336                    f"Failed to load custom pulse train {train_id}: "
1337                    "timestamps and voltages must be comma-delimited lists "
1338                    "of numbers."
1339                )
1340                return None
1341            if len(times) != len(voltages):
1342                self._show_error(
1343                    f"Failed to load custom pulse train {train_id}: the "
1344                    "number of timestamps and voltages must match."
1345                )
1346                return None
1347            if times:
1348                trains.append((train_id, times, voltages))
1349        return trains
1350
1351    def _save_program(self):
1352        device = self._device
1353        if device is None:
1354            return
1355
1356        path = filedialog.asksaveasfilename(
1357            parent=self._root,
1358            title="Save program",
1359            defaultextension=".json",
1360            initialfile="PulsePalProgram.json",
1361            initialdir=self._last_program_dir or None,
1362            filetypes=(("Pulse Pal program", "*.json"), ("All files", "*.*")),
1363        )
1364        if not path:
1365            return
1366
1367        program = {
1368            "params": {
1369                name: list(values) for name, values in self._params.items()
1370            },
1371            "trigger_mode": list(self._trigger_mode),
1372            "custom_train_timestamps": list(self._custom_timestamps),
1373            "custom_train_voltages": list(self._custom_voltages),
1374            "device_info": dataclasses.asdict(device.info),
1375        }
1376        try:
1377            with open(path, "w", encoding="utf-8") as program_file:
1378                json.dump(program, program_file, indent=2)
1379        except OSError as exc:
1380            self._show_error(f"Failed to save the program:\n{exc}")
1381            return
1382
1383        self._last_program_dir = path
1384        self._set_status("Program Saved")
1385        self.focus()
1386
1387    def _open_program(self):
1388        path = filedialog.askopenfilename(
1389            parent=self._root,
1390            title="Open program",
1391            initialdir=self._last_program_dir or None,
1392            filetypes=(("Pulse Pal program", "*.json"), ("All files", "*.*")),
1393        )
1394        if not path:
1395            return
1396
1397        try:
1398            with open(path, encoding="utf-8") as program_file:
1399                program = json.load(program_file)
1400            params = program["params"]
1401            new_params = {}
1402            for name, default in self._DEFAULT_OUTPUT_PARAMS.items():
1403                values = params.get(name, [default] * 4)
1404                if len(values) != 4:
1405                    raise ValueError(
1406                        f"{name} must have one value per output channel."
1407                    )
1408                new_params[name] = [float(value) for value in values]
1409            trigger_mode = [
1410                int(value) for value in program.get("trigger_mode", [0, 0])
1411            ]
1412            if len(trigger_mode) != 2:
1413                raise ValueError(
1414                    "trigger_mode must have one value per trigger channel."
1415                )
1416            timestamps = list(program.get("custom_train_timestamps", []))
1417            voltages = list(program.get("custom_train_voltages", []))
1418        except (OSError, ValueError, KeyError, TypeError) as exc:
1419            self._show_error(f"Failed to open the program:\n{exc}")
1420            return
1421
1422        self._params = new_params
1423        self._trigger_mode = trigger_mode
1424        self._custom_timestamps = self._fit_custom_trains(timestamps)
1425        self._custom_voltages = self._fit_custom_trains(voltages)
1426        self._reset_selections()
1427        self._refresh()
1428        self._last_program_dir = path
1429        self._set_status("Program Opened")
1430        self.focus()
1431
1432    def _fit_custom_trains(self, values):
1433        """Coerce a saved custom train list to this device's train count."""
1434        fitted = [""] * self._n_custom_trains
1435        for index, value in enumerate(values[:self._n_custom_trains]):
1436            if isinstance(value, (list, tuple)):
1437                value = ", ".join(_format_number(item) for item in value)
1438            fitted[index] = str(value)
1439        return fitted
1440
1441    def _reset_selections(self):
1442        self._output_channel_var.set(1)
1443        self._trigger_channel_var.set(1)
1444        self._custom_train_list.selection_clear(0, "end")
1445        self._custom_train_list.selection_set(0)
1446
1447    def _show_error(self, message):
1448        messagebox.showerror("Pulse Pal", message, parent=self._root)

Parameter editor window for a connected PulsePalDevice.

Parameters are edited in a local copy held by the GUI, and are only sent to the device when 'Load to Device' is clicked. This matches the behavior of the MATLAB parameter GUI.

PulsePalGUI(device, theme=None)
295    def __init__(self, device, theme=None):
296        # The device is held weakly so that the GUI never keeps a released
297        # PulsePalDevice alive: the device's destructor closes this window.
298        self._device_ref = weakref.ref(device)
299        self._closed = False
300        self._release_host_event_loop = None
301        self._topmost_after_id = None
302
303        # Resolved before any window exists, so an invalid theme argument
304        # raises without leaving a half-built GUI behind
305        theme = _resolve_theme(theme)
306        self._theme = None
307        self._palette = {}
308        self._native_ttk_theme = None
309        self._indicator_element = None
310        self._indicator_images = {}
311        self._loading = True
312        self._last_program_dir = ""
313
314        n_trains = getattr(device.info, "n_custom_pulse_trains", None) or 2
315        self._n_custom_trains = int(n_trains)
316        self._custom_timestamps = [""] * self._n_custom_trains
317        self._custom_voltages = [""] * self._n_custom_trains
318
319        self._params = {}
320        self._trigger_mode = []
321        self._load_default_params()
322
323        self._entry_vars = {}
324        self._entry_widgets = {}
325        self._field_labels = {
326            name: label
327            for name, label, _ in self._VOLTAGE_FIELDS + self._TIME_FIELDS
328        }
329
330        self._root = tk.Tk()
331        self._root.title("Pulse Pal Parameter GUI")
332        self._root.resizable(False, False)
333        self._root.protocol("WM_DELETE_WINDOW", self.close)
334
335        # Applied before the widgets are built: several of them take their
336        # colors at construction time
337        self.set_theme(theme)
338
339        self._build_header()
340        self._build_output_panel()
341        self._build_trigger_panel()
342        self._build_custom_train_panel()
343        self._build_status_bar()
344
345        self._loading = False
346        self._refresh()
347        self._set_status("GUI Loaded")
is_closed
351    @property
352    def is_closed(self):
353        """True once the GUI window has been closed."""
354        return self._closed

True once the GUI window has been closed.

theme
362    @property
363    def theme(self):
364        """The active color theme, 'light' or 'dark'."""
365        return self._theme

The active color theme, 'light' or 'dark'.

def set_theme(self, theme):
367    def set_theme(self, theme):
368        """Switch the GUI between the light and dark color themes.
369
370        Args:
371            theme: ``"light"``, ``"dark"``, or ``None`` to match the
372                desktop theme.
373
374        Raises:
375            ValueError: If the theme name is not recognized.
376        """
377        name = _resolve_theme(theme)
378        if self._closed or name == self._theme:
379            return
380        self._theme = name
381        # Updated in place, since tooltips hold a reference to this dict
382        self._palette.clear()
383        self._palette.update(_PALETTES[name])
384        self._apply_theme_styles()
385        self._apply_widget_palette()

Switch the GUI between the light and dark color themes.

Arguments:
  • theme: "light", "dark", or None to match the desktop theme.
Raises:
  • ValueError: If the theme name is not recognized.
def start(self, block=None):
596    def start(self, block=None):
597        """Show the GUI.
598
599        Args:
600            block: If True, run the Tk event loop until the window is closed.
601                If False, return immediately (the host application must pump
602                Tk events). If None, block only when the host does not
603                already provide a Tk event loop.
604        """
605        if self._closed:
606            return
607        if block is None:
608            block = not self._enable_host_event_loop()
609        self._bring_to_front()
610        if block:
611            try:
612                self._root.mainloop()
613            finally:
614                self.close()

Show the GUI.

Arguments:
  • block: If True, run the Tk event loop until the window is closed. If False, return immediately (the host application must pump Tk events). If None, block only when the host does not already provide a Tk event loop.
def focus(self):
616    def focus(self):
617        """Raise the GUI window and give it keyboard focus."""
618        self._bring_to_front()

Raise the GUI window and give it keyboard focus.

def close(self):
666    def close(self):
667        """Close the GUI window."""
668        if self._closed:
669            return
670        self._closed = True
671
672        device = self._device
673        self._device_ref = None
674        if device is not None and getattr(device, "_gui", None) is self:
675            device._gui = None
676
677        # Unregister before the window is destroyed, so that the host does
678        # not keep pumping events for a dead Tk interpreter
679        self._cancel_topmost_reset()
680
681        release = self._release_host_event_loop
682        self._release_host_event_loop = None
683        if release is not None:
684            try:
685                release()
686            except Exception:
687                pass
688
689        root = self._root
690        self._root = None
691        if root is not None:
692            try:
693                root.destroy()
694            except Exception:
695                # The interpreter may already be tearing down Tk
696                pass

Close the GUI window.