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 os
  27import subprocess
  28import sys
  29import tkinter as tk
  30import weakref
  31from tkinter import filedialog, font as tkfont, messagebox, ttk
  32
  33# Widget colors for each theme. The light palette matches the platform's
  34# native widget colors, so light mode can keep the native ttk theme.
  35_PALETTES = {
  36    "light": {
  37        "bg": "#f0f0f0",
  38        "field": "#ffffff",
  39        "fg": "#000000",
  40        "disabled_fg": "#6d6d6d",
  41        "disabled_field": "#f0f0f0",
  42        "select_bg": "#0078d7",
  43        "select_fg": "#ffffff",
  44        "border": "#a0a0a0",
  45        "button": "#e1e1e1",
  46        "active": "#cce4f7",
  47        "tooltip_bg": "#ffffe0",
  48        "tooltip_fg": "#000000",
  49    },
  50    "dark": {
  51        "bg": "#2b2b2b",
  52        "field": "#3c3f41",
  53        "fg": "#e0e0e0",
  54        "disabled_fg": "#808080",
  55        "disabled_field": "#323232",
  56        "select_bg": "#4b6eaf",
  57        "select_fg": "#ffffff",
  58        "border": "#555555",
  59        "button": "#3c3f41",
  60        "active": "#4c5052",
  61        "tooltip_bg": "#4b4b4b",
  62        "tooltip_fg": "#e8e8e8",
  63    },
  64}
  65
  66
  67def _detect_desktop_theme():
  68    """Return 'dark' or 'light' by probing the desktop, defaulting to light."""
  69    try:
  70        if sys.platform == "win32":
  71            import winreg
  72
  73            key_path = (
  74                r"Software\Microsoft\Windows\CurrentVersion\Themes"
  75                r"\Personalize"
  76            )
  77            with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path) as key:
  78                uses_light, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
  79            return "light" if uses_light else "dark"
  80
  81        if sys.platform == "darwin":
  82            result = subprocess.run(
  83                ("defaults", "read", "-g", "AppleInterfaceStyle"),
  84                capture_output=True,
  85                text=True,
  86                timeout=1,
  87            )
  88            # The key is absent entirely when macOS is in light mode
  89            return "dark" if "dark" in result.stdout.lower() else "light"
  90
  91        result = subprocess.run(
  92            (
  93                "gsettings",
  94                "get",
  95                "org.gnome.desktop.interface",
  96                "color-scheme",
  97            ),
  98            capture_output=True,
  99            text=True,
 100            timeout=1,
 101        )
 102        return "dark" if "dark" in result.stdout.lower() else "light"
 103    except Exception:
 104        # Probing is best-effort; any failure falls back to the light theme
 105        return "light"
 106
 107
 108# Families to fall back through on desktops that do not publish their
 109# UI font. Ubuntu ships the first, GNOME the second, and the rest are
 110# common enough elsewhere that one of them is almost always installed.
 111_UI_FONT_FALLBACKS = (
 112    "Ubuntu", "Cantarell", "Noto Sans", "DejaVu Sans", "Liberation Sans",
 113)
 114
 115
 116def _detect_desktop_font(root):
 117    """Return the desktop's UI font as (family, size), or (None, None).
 118
 119    Tk chooses its own default on X11, which can be a coarse bitmap face
 120    that looks out of place beside the rest of the desktop, and is a
 121    different size from what other applications use. GNOME publishes the
 122    font it draws everything else with, so ask for that and fall back to
 123    whichever of the usual families is installed. Windows and macOS Tk
 124    already follow the platform's own UI font.
 125    """
 126    if sys.platform in ("win32", "darwin"):
 127        return None, None
 128
 129    installed = {name.lower() for name in tkfont.families(root)}
 130    described = ""
 131    try:
 132        result = subprocess.run(
 133            (
 134                "gsettings",
 135                "get",
 136                "org.gnome.desktop.interface",
 137                "font-name",
 138            ),
 139            capture_output=True,
 140            text=True,
 141            timeout=1,
 142        )
 143        described = result.stdout.strip().strip("'\"")
 144    except Exception:
 145        # Probing is best-effort, as it is for the theme
 146        described = ""
 147
 148    # A description is a family followed by an optional style and the
 149    # point size, e.g. "Ubuntu 11" or "Cantarell Light 11"
 150    words = described.split()
 151    size = None
 152    if words and words[-1].replace(".", "", 1).isdigit():
 153        size = round(float(words.pop()))
 154    while words:
 155        family = " ".join(words)
 156        if family.lower() in installed:
 157            return family, size
 158        words.pop()
 159
 160    for family in _UI_FONT_FALLBACKS:
 161        if family.lower() in installed:
 162            return family, size
 163    return None, size
 164
 165
 166def _default_program_dir():
 167    """Return the folder the program file dialogs should open in.
 168
 169    Tk opens a file dialog in the working directory unless it is told
 170    otherwise, which on Linux is wherever the interpreter was started,
 171    often the package directory. Programs belong with the user's own
 172    files, so start at the desktop, or the documents folder where there
 173    is no desktop. Windows and macOS open somewhere sensible of their
 174    own accord, and are left to it.
 175    """
 176    if sys.platform in ("win32", "darwin"):
 177        return ""
 178
 179    home = os.path.expanduser("~")
 180    for key, default_name in (("DESKTOP", "Desktop"),
 181                              ("DOCUMENTS", "Documents")):
 182        path = ""
 183        try:
 184            result = subprocess.run(
 185                ("xdg-user-dir", key),
 186                capture_output=True,
 187                text=True,
 188                timeout=1,
 189            )
 190            path = result.stdout.strip()
 191        except Exception:
 192            # Probing is best-effort, as it is for the theme and font
 193            path = ""
 194        # xdg-user-dir answers with the home directory for a folder the
 195        # desktop does not define, which is not what was asked for
 196        if not path or os.path.normpath(path) == os.path.normpath(home):
 197            path = os.path.join(home, default_name)
 198        if os.path.isdir(path):
 199            return path
 200    return home if os.path.isdir(home) else ""
 201
 202
 203def _resolve_theme(theme):
 204    """Validate a theme, resolving None/'auto' to the desktop theme."""
 205    if theme is None or str(theme).lower() == "auto":
 206        return _detect_desktop_theme()
 207    name = str(theme).lower()
 208    if name not in _PALETTES:
 209        raise ValueError(
 210            f"Unknown theme: {theme!r}. theme must be 'light', 'dark', or "
 211            "None to match the desktop theme."
 212        )
 213    return name
 214
 215
 216def _format_number(value):
 217    """Format a parameter value for display without scientific notation."""
 218    text = f"{float(value):.6f}".rstrip("0").rstrip(".")
 219    return text if text not in ("", "-") else "0"
 220
 221
 222def _parse_number_list(text):
 223    """Parse a comma (or newline) delimited list of numbers."""
 224    items = [item.strip() for item in text.replace("\n", ",").split(",")]
 225    return [float(item) for item in items if item]
 226
 227
 228class _ToolTip:
 229    """Minimal hover tooltip, used to mirror the MATLAB GUI's tooltips."""
 230
 231    def __init__(self, widget, text, palette):
 232        self._widget = widget
 233        self._text = text
 234        # Held by reference, and updated in place by set_theme()
 235        self._palette = palette
 236        self._window = None
 237        widget.bind("<Enter>", self._show, add="+")
 238        widget.bind("<Leave>", self._hide, add="+")
 239        widget.bind("<ButtonPress>", self._hide, add="+")
 240
 241    def _show(self, _event=None):
 242        if self._window is not None or not self._text:
 243            return
 244        x = self._widget.winfo_rootx() + 20
 245        y = self._widget.winfo_rooty() + self._widget.winfo_height() + 4
 246        self._window = tk.Toplevel(self._widget)
 247        self._window.wm_overrideredirect(True)
 248        self._window.wm_geometry(f"+{x}+{y}")
 249        tk.Label(
 250            self._window,
 251            text=self._text,
 252            justify="left",
 253            background=self._palette["tooltip_bg"],
 254            foreground=self._palette["tooltip_fg"],
 255            relief="solid",
 256            borderwidth=1,
 257            wraplength=320,
 258        ).pack(ipadx=4, ipady=2)
 259
 260    def _hide(self, _event=None):
 261        if self._window is not None:
 262            try:
 263                self._window.destroy()
 264            except tk.TclError:
 265                pass
 266            self._window = None
 267
 268
 269class PulsePalGUI:
 270    """Parameter editor window for a connected PulsePalDevice.
 271
 272    Parameters are edited in a local copy held by the GUI, and are only sent
 273    to the device when 'Load to Device' is clicked. This matches the behavior
 274    of the MATLAB parameter GUI.
 275    """
 276
 277    # Check mark strokes, as 2x2 blocks on the indicator grid
 278    _INDICATOR_SIZE = 13
 279    _CHECK_MARK = (
 280        (3, 6), (4, 7), (5, 8), (6, 7), (7, 6), (8, 5), (9, 4),
 281    )
 282
 283    # Minimum side length of the square FIRE button, in pixels. The
 284    # MATLAB GUI draws the same button 46x44. The button grows past this
 285    # where the theme font needs the room, so that its label always fits.
 286    _FIRE_BUTTON_SIZE = 45
 287
 288    # Line height of the font the pixel sizes here were measured
 289    # against, Windows' 9 point Segoe UI. Desktops that set a larger UI
 290    # font scale them up in proportion, so that the parts drawn to a
 291    # pixel size keep pace with the parts drawn to the font. See
 292    # _scaled.
 293    _REFERENCE_LINESPACE = 15
 294
 295    # Title size as a multiple of the default UI font, which is 9 point on
 296    # Windows and larger on most Linux desktops. Scaling keeps the heading
 297    # in proportion with the rest of the window on both.
 298    _TITLE_FONT_SCALE = 16 / 9
 299
 300    # Width of the custom train text boxes, in characters. This is only
 301    # a floor: the boxes expand to fill the Custom Pulse Trains panel,
 302    # which the wider Output Channels panel above sizes. Asking for the
 303    # full width here instead made this panel the widest in the window,
 304    # which stretched the panels above it past their own content and
 305    # widened the window again whenever a scrollbar appeared.
 306    _TRAIN_TEXT_COLUMNS = 20
 307
 308    # Height of those boxes, in rows. Four reaches just past the bottom
 309    # of the train selector beside them, which holds four trains on
 310    # current hardware, and takes a fourth line of values before a
 311    # scrollbar is needed.
 312    _TRAIN_TEXT_ROWS = 4
 313
 314    # Space around each field in the parameter panels, in pixels
 315    _FIELD_PADDING = 4
 316
 317    # Distance, in pixels, from the center of a checkbutton's indicator
 318    # to the center of the widget. A checkbutton keeps room to the right
 319    # of its indicator for text, which these checkbuttons do not have,
 320    # so their indicators sit left of center by this much.
 321    _INDICATOR_OFFSET = 2
 322
 323    _PULSE_TYPES = ("Monophasic", "Biphasic")
 324    _CUSTOM_TRAIN_TARGETS = ("Pulses", "Bursts")
 325    _TRIGGER_MODES = ("Normal", "Toggle", "Pulse Gated")
 326
 327    _DEFAULT_OUTPUT_PARAMS = {
 328        "is_biphasic": 0,
 329        "phase1_voltage": 5.0,
 330        "phase2_voltage": -5.0,
 331        "resting_voltage": 0.0,
 332        "phase1_duration": 0.001,
 333        "inter_phase_interval": 0.001,
 334        "phase2_duration": 0.001,
 335        "inter_pulse_interval": 0.01,
 336        "burst_duration": 0.0,
 337        "inter_burst_interval": 0.0,
 338        "pulse_train_duration": 1.0,
 339        "pulse_train_delay": 0.0,
 340        "link_trigger_channel1": 1,
 341        "link_trigger_channel2": 0,
 342        "custom_train_id": 0,
 343        "custom_train_target": 0,
 344        "custom_train_loop": 0,
 345    }
 346
 347    # (parameter name, label, tooltip)
 348    _VOLTAGE_FIELDS = (
 349        (
 350            "resting_voltage",
 351            "Resting (V)",
 352            "Voltage while not delivering a pulse (V)",
 353        ),
 354        (
 355            "phase1_voltage",
 356            "Phase1 (V)",
 357            "Voltage of the first phase of each pulse (V)",
 358        ),
 359        (
 360            "phase2_voltage",
 361            "Phase2 (V)",
 362            "Voltage of the second phase of each pulse (V)",
 363        ),
 364    )
 365    _TIME_FIELDS = (
 366        (
 367            "phase1_duration",
 368            "Phase1 (s)",
 369            "Duration of the first phase of each pulse (s)",
 370        ),
 371        (
 372            "inter_phase_interval",
 373            "Phase Interval",
 374            "Interval between pulse phases (s)",
 375        ),
 376        (
 377            "phase2_duration",
 378            "Phase2 (s)",
 379            "Duration of the second phase of each pulse (s)",
 380        ),
 381        (
 382            "inter_pulse_interval",
 383            "Pulse Interval",
 384            "Interval between pulse-end and the next pulse (s)",
 385        ),
 386        (
 387            "burst_duration",
 388            "Burst (s)",
 389            "Duration of pulse bursts (0 = no bursts, units = seconds)",
 390        ),
 391        (
 392            "inter_burst_interval",
 393            "Burst Interval",
 394            "Interval between pulse bursts (s)",
 395        ),
 396        (
 397            "pulse_train_duration",
 398            "Train (s)",
 399            "Duration of the pulse train (s)",
 400        ),
 401        (
 402            "pulse_train_delay",
 403            "Train Delay",
 404            "Delay from trigger to pulse train onset (s)",
 405        ),
 406    )
 407
 408    # Parameters that are only meaningful for biphasic pulses
 409    _BIPHASIC_ONLY = (
 410        "phase2_voltage",
 411        "inter_phase_interval",
 412        "phase2_duration",
 413    )
 414
 415    # Valid ranges, matching those enforced by the device interface
 416    _FIELD_RANGES = {
 417        "resting_voltage": (-10.0, 10.0),
 418        "phase1_voltage": (-10.0, 10.0),
 419        "phase2_voltage": (-10.0, 10.0),
 420        "phase1_duration": (0.0001, 3600.0),
 421        "inter_phase_interval": (0.0, 3600.0),
 422        "phase2_duration": (0.0001, 3600.0),
 423        "inter_pulse_interval": (0.0001, 3600.0),
 424        "burst_duration": (0.0, 3600.0),
 425        "inter_burst_interval": (0.0, 3600.0),
 426        "pulse_train_duration": (0.0001, 3600.0),
 427        "pulse_train_delay": (0.0, 3600.0),
 428    }
 429
 430    def __init__(self, device, theme=None):
 431        # The device is held weakly so that the GUI never keeps a released
 432        # PulsePalDevice alive: the device's destructor closes this window.
 433        self._device_ref = weakref.ref(device)
 434        self._closed = False
 435        self._release_host_event_loop = None
 436        self._topmost_after_id = None
 437
 438        # Resolved before any window exists, so an invalid theme argument
 439        # raises without leaving a half-built GUI behind
 440        theme = _resolve_theme(theme)
 441        self._theme = None
 442        self._palette = {}
 443        self._native_ttk_theme = None
 444        self._indicator_element = None
 445        self._indicator_images = {}
 446        self._loading = True
 447        self._last_program_dir = _default_program_dir()
 448
 449        n_trains = getattr(device.info, "n_custom_pulse_trains", None) or 2
 450        self._n_custom_trains = int(n_trains)
 451        self._custom_timestamps = [""] * self._n_custom_trains
 452        self._custom_voltages = [""] * self._n_custom_trains
 453        # The train the text boxes are showing, which is not always the
 454        # one selected in the list: see _commit_timestamps
 455        self._displayed_train = 0
 456
 457        self._params = {}
 458        self._trigger_mode = []
 459        self._load_default_params()
 460
 461        self._entry_vars = {}
 462        self._entry_widgets = {}
 463        self._field_labels = {
 464            name: label
 465            for name, label, _ in self._VOLTAGE_FIELDS + self._TIME_FIELDS
 466        }
 467
 468        self._root = tk.Tk()
 469        self._root.title("Pulse Pal Parameter Editor")
 470        self._root.resizable(False, False)
 471        self._root.protocol("WM_DELETE_WINDOW", self.close)
 472        self._init_fonts()
 473
 474        # Applied before the widgets are built: several of them take their
 475        # colors at construction time
 476        self.set_theme(theme)
 477
 478        self._build_header()
 479        self._build_output_panel()
 480        self._build_trigger_panel()
 481        self._build_custom_train_panel()
 482        self._build_status_bar()
 483
 484        self._loading = False
 485        self._refresh()
 486        self._set_status("GUI Loaded")
 487
 488    # ---- Public interface ----
 489
 490    @property
 491    def is_closed(self):
 492        """True once the GUI window has been closed."""
 493        return self._closed
 494
 495    @property
 496    def _device(self):
 497        """The device being edited, or None once it has been released."""
 498        ref = self._device_ref
 499        return ref() if ref is not None else None
 500
 501    @property
 502    def theme(self):
 503        """The active color theme, 'light' or 'dark'."""
 504        return self._theme
 505
 506    def set_theme(self, theme):
 507        """Switch the GUI between the light and dark color themes.
 508
 509        Args:
 510            theme: ``"light"``, ``"dark"``, or ``None`` to match the
 511                desktop theme.
 512
 513        Raises:
 514            ValueError: If the theme name is not recognized.
 515        """
 516        name = _resolve_theme(theme)
 517        if self._closed or name == self._theme:
 518            return
 519        self._theme = name
 520        # Updated in place, since tooltips hold a reference to this dict
 521        self._palette.clear()
 522        self._palette.update(_PALETTES[name])
 523        self._apply_theme_styles()
 524        self._apply_widget_palette()
 525
 526    def _apply_theme_styles(self):
 527        """Configure the ttk styles for the active theme."""
 528        palette = self._palette
 529        style = ttk.Style(self._root)
 530        if self._native_ttk_theme is None:
 531            self._native_ttk_theme = style.theme_use()
 532
 533        if self._theme != "dark":
 534            # The native ttk theme already matches the light palette
 535            style.theme_use(self._native_ttk_theme)
 536        else:
 537            # Native themes draw most widgets with the platform's own
 538            # colors and ignore color options, so dark mode switches to
 539            # 'clam', which is fully colorable
 540            style.theme_use("clam")
 541            style.configure(
 542                ".",
 543                background=palette["bg"],
 544                foreground=palette["fg"],
 545                fieldbackground=palette["field"],
 546                bordercolor=palette["border"],
 547                lightcolor=palette["bg"],
 548                darkcolor=palette["bg"],
 549                troughcolor=palette["field"],
 550                focuscolor=palette["select_bg"],
 551            )
 552            # clam maps disabled widgets to a light background of its own,
 553            # which configure() above does not override
 554            style.map(
 555                ".",
 556                background=[("disabled", palette["bg"])],
 557                foreground=[("disabled", palette["disabled_fg"])],
 558                fieldbackground=[("disabled", palette["disabled_field"])],
 559            )
 560            style.configure("TLabelframe", bordercolor=palette["border"])
 561            style.configure(
 562                "TButton",
 563                background=palette["button"],
 564                bordercolor=palette["border"],
 565                focuscolor=palette["bg"],
 566            )
 567            style.configure("TEntry", insertcolor=palette["fg"])
 568            style.configure(
 569                "TScrollbar",
 570                background=palette["button"],
 571                troughcolor=palette["field"],
 572                bordercolor=palette["border"],
 573                arrowcolor=palette["fg"],
 574            )
 575            style.map(
 576                "TScrollbar",
 577                background=[("active", palette["active"])],
 578            )
 579            style.configure(
 580                "TCombobox",
 581                arrowcolor=palette["fg"],
 582                background=palette["button"],
 583            )
 584            for widget in ("TCheckbutton", "TRadiobutton"):
 585                style.configure(
 586                    widget,
 587                    indicatorbackground=palette["field"],
 588                    indicatorforeground=palette["fg"],
 589                    # The indicator draws its own border, from options that
 590                    # do not inherit the style's bordercolor
 591                    upperbordercolor=palette["border"],
 592                    lowerbordercolor=palette["border"],
 593                )
 594                style.map(
 595                    widget,
 596                    foreground=[("disabled", palette["disabled_fg"])],
 597                    indicatorbackground=[
 598                        ("disabled", palette["disabled_field"]),
 599                        ("selected", palette["select_bg"]),
 600                    ],
 601                    indicatorforeground=[
 602                        ("selected", palette["select_fg"]),
 603                    ],
 604                )
 605            style.map(
 606                "TButton",
 607                background=[
 608                    ("pressed", palette["border"]),
 609                    ("active", palette["active"]),
 610                ],
 611                foreground=[("disabled", palette["disabled_fg"])],
 612            )
 613            style.map(
 614                "TEntry",
 615                fieldbackground=[
 616                    ("disabled", palette["disabled_field"]),
 617                ],
 618                foreground=[("disabled", palette["disabled_fg"])],
 619            )
 620            style.map(
 621                "TCombobox",
 622                fieldbackground=[
 623                    ("disabled", palette["disabled_field"]),
 624                    ("readonly", palette["field"]),
 625                ],
 626                foreground=[("disabled", palette["disabled_fg"])],
 627                arrowcolor=[("disabled", palette["disabled_fg"])],
 628                selectbackground=[("readonly", palette["field"])],
 629                selectforeground=[("readonly", palette["fg"])],
 630            )
 631            self._install_check_indicator(style)
 632
 633        # The combobox dropdown is a plain Tk listbox inside the popdown
 634        # window, which ttk styles do not reach
 635        for option, value in (
 636            ("*TCombobox*Listbox.background", palette["field"]),
 637            ("*TCombobox*Listbox.foreground", palette["fg"]),
 638            ("*TCombobox*Listbox.selectBackground", palette["select_bg"]),
 639            ("*TCombobox*Listbox.selectForeground", palette["select_fg"]),
 640        ):
 641            self._root.option_add(option, value)
 642
 643    def _install_check_indicator(self, style):
 644        """Give checkbuttons a check mark, which clam draws as an X."""
 645        name = "PulsePal.Checkbutton.indicator"
 646        if self._indicator_element is None:
 647            palette = self._palette
 648            images = {
 649                "off": self._draw_indicator(
 650                    palette["field"], palette["border"], None
 651                ),
 652                "on": self._draw_indicator(
 653                    palette["select_bg"],
 654                    palette["select_bg"],
 655                    palette["select_fg"],
 656                ),
 657                "off_disabled": self._draw_indicator(
 658                    palette["disabled_field"], palette["disabled_fg"], None
 659                ),
 660                "on_disabled": self._draw_indicator(
 661                    palette["disabled_field"],
 662                    palette["disabled_fg"],
 663                    palette["disabled_fg"],
 664                ),
 665            }
 666            # Held on the instance: ttk keeps no reference of its own, and
 667            # the indicators go blank if the images are collected
 668            self._indicator_images = images
 669            style.element_create(
 670                name,
 671                "image",
 672                images["off"],
 673                ("disabled", "selected", images["on_disabled"]),
 674                ("disabled", images["off_disabled"]),
 675                ("selected", images["on"]),
 676                sticky="",
 677            )
 678            self._indicator_element = name
 679
 680        style.layout(
 681            "TCheckbutton",
 682            self._replace_indicator(style.layout("TCheckbutton"), name),
 683        )
 684
 685    def _draw_indicator(self, fill, border, mark):
 686        """Draw one checkbutton indicator as a Tk image.
 687
 688        Drawn at the size the desktop's font asks for. The native themes
 689        size their own indicators from the font, so a fixed size here
 690        left dark mode, which draws these instead, with check boxes
 691        visibly smaller than the light theme's.
 692        """
 693        size = self._scaled(self._INDICATOR_SIZE)
 694        edge = self._scaled(1)
 695        image = tk.PhotoImage(master=self._root, width=size, height=size)
 696        image.put(border, to=(0, 0, size, size))
 697        image.put(fill, to=(edge, edge, size - edge, size - edge))
 698        if mark is not None:
 699            # The stroke coordinates are on the reference grid, so they
 700            # scale with it
 701            block = self._scaled(2)
 702            for x, y in self._CHECK_MARK:
 703                left, top = self._scaled(x), self._scaled(y)
 704                image.put(mark, to=(left, top, left + block, top + block))
 705        return image
 706
 707    @classmethod
 708    def _replace_indicator(cls, layout, name):
 709        """Return a ttk layout with the checkbutton indicator swapped out."""
 710        replaced = []
 711        for element, options in layout:
 712            options = dict(options)
 713            children = options.get("children")
 714            if children:
 715                options["children"] = cls._replace_indicator(children, name)
 716            if element.endswith("Checkbutton.indicator"):
 717                element = name
 718            replaced.append((element, options))
 719        return replaced
 720
 721    def _apply_widget_palette(self):
 722        """Color the plain Tk widgets, which ttk styles do not cover."""
 723        palette = self._palette
 724        self._root.configure(background=palette["bg"])
 725
 726        listbox = getattr(self, "_custom_train_list", None)
 727        if listbox is not None:
 728            listbox.configure(
 729                background=palette["field"],
 730                foreground=palette["fg"],
 731                disabledforeground=palette["disabled_fg"],
 732                selectbackground=palette["select_bg"],
 733                selectforeground=palette["select_fg"],
 734                highlightbackground=palette["border"],
 735                highlightcolor=palette["select_bg"],
 736            )
 737
 738        texts = [
 739            getattr(self, "_timestamp_text", None),
 740            getattr(self, "_voltage_text", None),
 741        ]
 742        for text in texts:
 743            if text is None:
 744                continue
 745            text.configure(
 746                foreground=palette["fg"],
 747                insertbackground=palette["fg"],
 748                selectbackground=palette["select_bg"],
 749                selectforeground=palette["select_fg"],
 750                highlightbackground=palette["border"],
 751                highlightcolor=palette["select_bg"],
 752            )
 753        if all(text is not None for text in texts):
 754            # Repaints the text backgrounds for the current enabled state
 755            self._update_enabled_state()
 756
 757    def start(self, block=None):
 758        """Show the GUI.
 759
 760        Args:
 761            block: If True, run the Tk event loop until the window is closed.
 762                If False, return immediately (the host application must pump
 763                Tk events). If None, block only when the host does not
 764                already provide a Tk event loop.
 765        """
 766        if self._closed:
 767            return
 768        if block is None:
 769            block = not self._enable_host_event_loop()
 770        self._bring_to_front()
 771        if block:
 772            try:
 773                self._root.mainloop()
 774            finally:
 775                self.close()
 776
 777    def focus(self):
 778        """Raise the GUI window and give it keyboard focus."""
 779        self._bring_to_front()
 780
 781    def _bring_to_front(self):
 782        """Raise the window above the windows of other applications.
 783
 784        Windows refuses to activate a window belonging to a process that has
 785        not yet been in the foreground, which leaves the first GUI of a
 786        session stuck behind the host IDE. Marking the window topmost is not
 787        subject to that restriction; the flag is dropped again as soon as the
 788        window is up, so the window is raised without staying pinned over
 789        everything else.
 790        """
 791        root = self._root
 792        if self._closed or root is None:
 793            return
 794        try:
 795            root.deiconify()
 796            # The window must be realized before it can be raised
 797            root.update_idletasks()
 798            root.lift()
 799            root.attributes("-topmost", True)
 800            root.focus_force()
 801            self._cancel_topmost_reset()
 802            self._topmost_after_id = root.after_idle(self._clear_topmost)
 803        except tk.TclError:
 804            pass
 805
 806    def _clear_topmost(self):
 807        """Drop the topmost flag, leaving the window raised where it is."""
 808        self._topmost_after_id = None
 809        if self._closed or self._root is None:
 810            return
 811        try:
 812            self._root.attributes("-topmost", False)
 813        except tk.TclError:
 814            pass
 815
 816    def _cancel_topmost_reset(self):
 817        """Cancel a pending topmost reset, so it cannot outlive the window."""
 818        after_id = self._topmost_after_id
 819        self._topmost_after_id = None
 820        if after_id is None or self._root is None:
 821            return
 822        try:
 823            self._root.after_cancel(after_id)
 824        except tk.TclError:
 825            pass
 826
 827    def close(self):
 828        """Close the GUI window."""
 829        if self._closed:
 830            return
 831        self._closed = True
 832
 833        device = self._device
 834        self._device_ref = None
 835        if device is not None and getattr(device, "_gui", None) is self:
 836            device._gui = None
 837
 838        # Unregister before the window is destroyed, so that the host does
 839        # not keep pumping events for a dead Tk interpreter
 840        self._cancel_topmost_reset()
 841
 842        release = self._release_host_event_loop
 843        self._release_host_event_loop = None
 844        if release is not None:
 845            try:
 846                release()
 847            except Exception:
 848                pass
 849
 850        root = self._root
 851        self._root = None
 852        if root is not None:
 853            try:
 854                root.destroy()
 855            except Exception:
 856                # The interpreter may already be tearing down Tk
 857                pass
 858
 859    def _enable_host_event_loop(self):
 860        """Return True if the host will pump Tk events for the GUI."""
 861        # The PyCharm / PyDev console pumps a registered input hook between
 862        # commands. This window is passed explicitly: left to itself, PyDev
 863        # creates a second Tk interpreter, whose event loop would not service
 864        # this window. This is tried before IPython because the PyCharm
 865        # console's IPython shell delegates to the same hook.
 866        try:
 867            from pydev_ipython.inputhook import (
 868                GUI_TK,
 869                clear_inputhook,
 870                enable_gui,
 871            )
 872            enable_gui(GUI_TK, app=self._root)
 873        except Exception:
 874            pass
 875        else:
 876            self._release_host_event_loop = clear_inputhook
 877            return True
 878
 879        try:
 880            from IPython import get_ipython
 881            shell = get_ipython()
 882        except Exception:
 883            shell = None
 884
 885        if shell is not None:
 886            try:
 887                shell.enable_gui("tk")
 888                return True
 889            except Exception:
 890                return False
 891
 892        # The interactive CPython prompt pumps Tk events between commands
 893        return bool(getattr(sys, "ps1", None)) or bool(sys.flags.interactive)
 894
 895    # ---- Parameter storage ----
 896
 897    def _load_default_params(self):
 898        self._params = {
 899            name: [value] * 4
 900            for name, value in self._DEFAULT_OUTPUT_PARAMS.items()
 901        }
 902        self._trigger_mode = [0, 0]
 903
 904    def _output_channel(self):
 905        return self._output_channel_var.get()
 906
 907    def _trigger_channel(self):
 908        return self._trigger_channel_var.get()
 909
 910    # ---- Widget construction ----
 911
 912    def _init_fonts(self):
 913        """Derive the header fonts from the platform's default UI font.
 914
 915        The family in a font tuple has to be a font family, and
 916        "TkDefaultFont" is the name of a named font rather than one.
 917        Naming it as a family leaves Tk no match, so it substitutes its
 918        fallback: a scalable face on Windows, which hid the mistake, and
 919        a bitmap face on X11, which rendered these labels pixelated.
 920        """
 921        # Bound to this window's interpreter rather than looked up with
 922        # nametofont, which resolves against the default root: that is a
 923        # different window when the GUI runs inside a host application
 924        # that already created one. (nametofont grew a root argument in
 925        # 3.10, past this package's floor.)
 926        base = tkfont.Font(root=self._root, name="TkDefaultFont", exists=True)
 927
 928        # Named fonts are shared by every widget that does not ask for
 929        # one of its own, so pointing them at the desktop's UI font
 930        # covers the labels, buttons and lists at once. The custom train
 931        # boxes keep TkFixedFont, whose columns line their values up.
 932        family, desktop_size = _detect_desktop_font(self._root)
 933        if family is not None or desktop_size is not None:
 934            changes = {}
 935            if family is not None:
 936                changes["family"] = family
 937            if desktop_size is not None:
 938                changes["size"] = desktop_size
 939            for name in ("TkDefaultFont", "TkTextFont", "TkMenuFont",
 940                         "TkHeadingFont"):
 941                tkfont.Font(
 942                    root=self._root, name=name, exists=True
 943                ).configure(**changes)
 944
 945        size = base.cget("size")
 946
 947        self._title_font = base.copy()
 948        # A font size is in points when positive and pixels when negative
 949        scaled = round(abs(size) * self._TITLE_FONT_SCALE)
 950        self._title_font.configure(
 951            size=-scaled if size < 0 else scaled, weight="bold"
 952        )
 953
 954        # Bold at the default size, rather than at a fixed 9 point, so
 955        # these labels stay in step with the plain ones beside them
 956        self._label_font = base.copy()
 957        self._label_font.configure(weight="bold")
 958
 959        self._ui_scale = (
 960            base.metrics("linespace") / self._REFERENCE_LINESPACE
 961        )
 962
 963    def _scaled(self, pixels):
 964        """Scale a pixel size measured at the reference font size.
 965
 966        Sizes given in pixels do not follow the desktop's UI font the
 967        way the widgets around them do. Under a larger font they end up
 968        cramped, which showed as a crowded parameter panel and undersized
 969        check marks on desktops whose font is larger than Windows'.
 970        """
 971        return max(1, round(pixels * self._ui_scale))
 972
 973    def _build_header(self):
 974        header = ttk.Frame(self._root)
 975        header.pack(fill="x", padx=10, pady=(8, 0))
 976
 977        # The title and the toolbar are stacked on the left, so that the
 978        # trigger controls on the right are centered across both of them
 979        titles = ttk.Frame(header)
 980        titles.pack(side="left", fill="x", expand=True)
 981
 982        ttk.Label(
 983            titles,
 984            text="Pulse Pal Parameter Editor",
 985            font=self._title_font,
 986        ).pack(anchor="w")
 987
 988        trigger_controls = ttk.Frame(header)
 989        trigger_controls.pack(side="right")
 990
 991        # A ttk.Button has no height option, and its width is measured in
 992        # text characters, so it is packed into a fixed size frame with
 993        # geometry propagation off to make it square
 994        fire_box = ttk.Frame(trigger_controls)
 995        fire_box.pack(side="right", padx=(8, 0))
 996        fire_box.pack_propagate(False)
 997        fire = ttk.Button(fire_box, text="FIRE", command=self._fire)
 998        fire.pack(fill="both", expand=True)
 999        self._tooltip(fire, "Trigger the selected output channels")
1000
1001        # A fixed 45 px is only wide enough for "FIRE" in fonts as
1002        # narrow as Windows' 9 point Segoe UI, and clipped the label
1003        # under the larger fonts of Linux desktops. Fitting it takes the
1004        # width of the text plus the room the theme leaves around it,
1005        # which is 10 px under vista and 16 under clam, the theme dark
1006        # mode switches to. A button cannot be asked for that room
1007        # directly, and its requested width is no help: themes ask for a
1008        # standard button width, 11 characters under vista, which has
1009        # nothing to do with the label. Text longer than that minimum
1010        # leaves the theme's own padding as the difference.
1011        style = ttk.Style(self._root)
1012        spec = style.lookup("TButton", "font") or "TkDefaultFont"
1013        button_font = tkfont.Font(root=self._root, font=spec)
1014        probe_text = "FIRE" * 10
1015        probe = ttk.Button(fire_box, text=probe_text)
1016        chrome = probe.winfo_reqwidth() - button_font.measure(probe_text)
1017        probe.destroy()
1018
1019        side = max(
1020            self._scaled(self._FIRE_BUTTON_SIZE),
1021            button_font.measure("FIRE") + chrome,
1022        )
1023        fire_box.configure(width=side, height=side)
1024
1025        checks = ttk.Frame(trigger_controls)
1026        checks.pack(side="right")
1027        ttk.Label(
1028            checks,
1029            text="Trigger Channels:",
1030            font=self._label_font,
1031        ).grid(row=1, column=0, padx=(0, 6))
1032        self._fire_vars = []
1033        for channel in range(1, 5):
1034            var = tk.IntVar(value=0)
1035            self._fire_vars.append(var)
1036            # Padding on the right shifts the digit left by half of
1037            # itself, since grid centers the label and its padding
1038            # together, to place the digit over the indicator below
1039            ttk.Label(
1040                checks,
1041                text=str(channel),
1042                font=self._label_font,
1043            ).grid(
1044                row=0,
1045                column=channel,
1046                padx=(0, self._scaled(2 * self._INDICATOR_OFFSET)),
1047            )
1048            check = ttk.Checkbutton(checks, variable=var)
1049            check.grid(row=1, column=channel)
1050            self._tooltip(
1051                check, f"Include output channel {channel} when firing"
1052            )
1053
1054        toolbar = ttk.Frame(titles)
1055        toolbar.pack(fill="x", pady=(6, 0))
1056        tools = (
1057            ("Restore Defaults", self._restore_defaults,
1058             "Restore default parameters"),
1059            ("Open Program...", self._open_program,
1060             "Open a program from a .json file"),
1061            ("Save Program...", self._save_program,
1062             "Save the current program to a .json file"),
1063            ("Load to Device", self._upload_program,
1064             "Load the current program to the Pulse Pal device"),
1065        )
1066        for text, command, tooltip in tools:
1067            button = ttk.Button(toolbar, text=text, command=command)
1068            button.pack(side="left", padx=(0, 6))
1069            self._tooltip(button, tooltip)
1070
1071    def _build_output_panel(self):
1072        panel = ttk.LabelFrame(self._root, text="Output Channels")
1073        panel.pack(fill="x", padx=10, pady=(8, 0), ipady=4)
1074
1075        # The channel selector spans the first row of fields only, so
1076        # that the second row starts at the panel's left edge as it does
1077        # in the MATLAB GUI. Placing both rows beside the selector
1078        # instead indented the second one by the selector's width, which
1079        # widened the window and left the first row short of the right
1080        # edge, as a gap after the Loop checkbox.
1081        channels = ttk.LabelFrame(panel, text="Channel")
1082        channels.grid(row=0, column=0, padx=6, pady=4, sticky="nw")
1083        self._tooltip(channels, "Select an output channel to edit")
1084        self._output_channel_var = tk.IntVar(value=1)
1085        for index, channel in enumerate((1, 2, 3, 4)):
1086            ttk.Radiobutton(
1087                channels,
1088                text=str(channel),
1089                value=channel,
1090                variable=self._output_channel_var,
1091                command=self._refresh,
1092            ).grid(row=index // 2, column=index % 2, sticky="w", padx=2)
1093
1094        panel.columnconfigure(1, weight=1)
1095        top = ttk.Frame(panel)
1096        top.grid(row=0, column=1, sticky="ew", pady=2)
1097        column = 0
1098
1099        self._pulse_type_box = self._labeled(
1100            top,
1101            column,
1102            "Pulse Type",
1103            lambda parent: self._make_combobox(
1104                parent, self._PULSE_TYPES, self._on_pulse_type, width=11
1105            ),
1106            "Biphasic pulses add an interval at the resting voltage and "
1107            "then a second phase to each pulse",
1108        )
1109        column += 1
1110
1111        for name, label, tooltip in self._VOLTAGE_FIELDS:
1112            self._labeled(
1113                top,
1114                column,
1115                label,
1116                lambda parent, n=name: self._make_entry(parent, n),
1117                tooltip,
1118            )
1119            column += 1
1120
1121        train_ids = ["0 (None)"] + [
1122            str(i) for i in range(1, self._n_custom_trains + 1)
1123        ]
1124        self._custom_id_box = self._labeled(
1125            top,
1126            column,
1127            "Custom Train ID",
1128            lambda parent: self._make_combobox(
1129                parent, train_ids, self._on_custom_train_id, width=9
1130            ),
1131            "Custom pulse train to play on this output channel",
1132        )
1133        column += 1
1134
1135        self._custom_target_box = self._labeled(
1136            top,
1137            column,
1138            "Custom Train of",
1139            lambda parent: self._make_combobox(
1140                parent,
1141                self._CUSTOM_TRAIN_TARGETS,
1142                self._on_custom_train_target,
1143                width=9,
1144            ),
1145            "Custom train timestamps can indicate the onset of either each "
1146            "pulse, or each burst of pulses",
1147        )
1148        column += 1
1149
1150        self._custom_loop_var = tk.IntVar(value=0)
1151        self._custom_loop_check = self._labeled(
1152            top,
1153            column,
1154            "Loop",
1155            lambda parent: ttk.Checkbutton(
1156                parent,
1157                variable=self._custom_loop_var,
1158                command=self._on_custom_train_loop,
1159            ),
1160            "If enabled, the custom pulse train loops until the pulse train "
1161            "duration (Train (s) below)",
1162            center=True,
1163        )
1164
1165        # padx lines the first entry up with the selector's left edge,
1166        # allowing for the padding _labeled puts around each field
1167        bottom = ttk.Frame(panel)
1168        bottom.grid(row=1, column=0, columnspan=2, sticky="ew", padx=2)
1169        for column, (name, label, tooltip) in enumerate(self._TIME_FIELDS):
1170            self._labeled(
1171                bottom,
1172                column,
1173                label,
1174                lambda parent, n=name: self._make_entry(parent, n),
1175                tooltip,
1176            )
1177
1178        # Room left over once the fields have their natural widths is
1179        # divided evenly between the columns, rather than all of it
1180        # falling after the last field, which is how the MATLAB GUI
1181        # spaces the same two rows. The fields stay left aligned in
1182        # their columns, so the space opens up as wider gaps between
1183        # them.
1184        for row in (top, bottom):
1185            for index in range(row.grid_size()[0]):
1186                row.columnconfigure(index, weight=1)
1187
1188    def _build_trigger_panel(self):
1189        panel = ttk.LabelFrame(self._root, text="Trigger Channels")
1190        panel.pack(fill="x", padx=10, pady=(8, 0), ipady=4)
1191
1192        channels = ttk.LabelFrame(panel, text="Channel")
1193        channels.pack(side="left", padx=6, pady=4, anchor="n")
1194        self._tooltip(channels, "Select a trigger channel to edit")
1195        self._trigger_channel_var = tk.IntVar(value=1)
1196        for channel in (1, 2):
1197            ttk.Radiobutton(
1198                channels,
1199                text=str(channel),
1200                value=channel,
1201                variable=self._trigger_channel_var,
1202                command=self._refresh,
1203            ).grid(row=0, column=channel - 1, sticky="w", padx=2)
1204
1205        fields = ttk.Frame(panel)
1206        fields.pack(side="left", pady=2)
1207
1208        self._trigger_mode_box = self._labeled(
1209            fields,
1210            0,
1211            "Trigger Mode",
1212            lambda parent: self._make_combobox(
1213                parent, self._TRIGGER_MODES, self._on_trigger_mode, width=12
1214            ),
1215            "Normal: TTL during pulse train ignored. Toggle: TTL during "
1216            "pulse train stops train. Pulse Gated: Pulse train only runs "
1217            "while trigger is high",
1218        )
1219
1220        links = ttk.Frame(fields)
1221        links.grid(row=0, column=1, padx=(16, 4), sticky="w")
1222        ttk.Label(links, text="Link to outputs").pack(anchor="w")
1223        link_row = ttk.Frame(links)
1224        link_row.pack(anchor="w")
1225        self._link_vars = []
1226        for channel in range(1, 5):
1227            var = tk.IntVar(value=0)
1228            self._link_vars.append(var)
1229            check = ttk.Checkbutton(
1230                link_row,
1231                text=f"Ch{channel}",
1232                variable=var,
1233                command=lambda c=channel: self._on_trigger_link(c),
1234            )
1235            check.pack(side="left", padx=(0, 8))
1236            self._tooltip(check, f"Link trigger channel to output channel "
1237                            f"{channel}")
1238
1239    def _build_custom_train_panel(self):
1240        panel = ttk.LabelFrame(self._root, text="Custom Pulse Trains")
1241        panel.pack(fill="x", padx=10, pady=(8, 0), ipady=4)
1242
1243        selector = ttk.Frame(panel)
1244        selector.pack(side="left", padx=6, pady=4, anchor="n")
1245        ttk.Label(selector, text="Custom Train ID").pack(anchor="w")
1246        self._custom_train_list = tk.Listbox(
1247            selector,
1248            height=min(self._n_custom_trains, 4),
1249            width=6,
1250            exportselection=False,
1251            # A plain Tk border is always drawn black, so the colorable
1252            # focus ring is used as the border instead
1253            relief="flat",
1254            borderwidth=0,
1255            highlightthickness=1,
1256            highlightbackground=self._palette["border"],
1257            highlightcolor=self._palette["select_bg"],
1258            background=self._palette["field"],
1259            foreground=self._palette["fg"],
1260            disabledforeground=self._palette["disabled_fg"],
1261            selectbackground=self._palette["select_bg"],
1262            selectforeground=self._palette["select_fg"],
1263        )
1264        for train_id in range(1, self._n_custom_trains + 1):
1265            self._custom_train_list.insert("end", str(train_id))
1266        self._custom_train_list.selection_set(0)
1267        self._custom_train_list.bind(
1268            "<<ListboxSelect>>", self._on_custom_train_selected
1269        )
1270        # Fills the holder, whose width is set by the wider label above
1271        self._custom_train_list.pack(fill="x")
1272        self._tooltip(self._custom_train_list, "Select the custom train to "
1273                                               "program")
1274
1275        self._timestamp_text = self._make_train_text(
1276            panel,
1277            "Timestamps (s)",
1278            "Enter the onset time of each pulse in the custom pulse train "
1279            "(comma delimited, units = seconds)",
1280            self._commit_timestamps,
1281        )
1282        self._voltage_text = self._make_train_text(
1283            panel,
1284            "Voltages (V)",
1285            "Enter the voltage of each pulse in the custom pulse train "
1286            "(comma delimited, units = volts)",
1287            self._commit_voltages,
1288        )
1289
1290    def _build_status_bar(self):
1291        bar = ttk.Frame(self._root)
1292        bar.pack(fill="x", padx=10, pady=(6, 8))
1293
1294        info = self._device.info
1295        port_name = getattr(self._device.port, "port", "")
1296        ttk.Label(
1297            bar, text=f"HW: Pulse Pal v{info.hardware_version}"
1298        ).pack(side="left", padx=(0, 12))
1299        ttk.Label(
1300            bar, text=f"Firmware: v{info.firmware_version}"
1301        ).pack(side="left", padx=(0, 12))
1302        ttk.Label(bar, text=f"Port: {port_name}").pack(side="left")
1303
1304        self._status_var = tk.StringVar(value="Status: GUI Loaded")
1305        ttk.Label(
1306            bar,
1307            textvariable=self._status_var,
1308            font=self._label_font,
1309        ).pack(side="right")
1310
1311    def _tooltip(self, widget, text):
1312        """Attach a hover tooltip that follows the active theme."""
1313        return _ToolTip(widget, text, self._palette)
1314
1315    def _labeled(
1316        self, parent, column, label, widget_factory, tooltip=None,
1317        center=False,
1318    ):
1319        """Create a labeled widget in a grid column of parent.
1320
1321        The widget lines up with the left edge of its label, unless
1322        center is set, which centers a checkbutton's indicator under the
1323        label instead.
1324        """
1325        padding = self._scaled(self._FIELD_PADDING)
1326        holder = ttk.Frame(parent)
1327        holder.grid(row=0, column=column, padx=padding, pady=2, sticky="w")
1328        ttk.Label(holder, text=label).pack(anchor="w")
1329        widget = widget_factory(holder)
1330        if center:
1331            # The padding shifts the widget right by half of itself,
1332            # which centers the indicator rather than the checkbutton
1333            widget.pack(padx=(self._scaled(2 * self._INDICATOR_OFFSET), 0))
1334        else:
1335            # Widened to its label where the label is the longer of the
1336            # two, as the MATLAB GUI sizes the same fields. A field is
1337            # otherwise as wide as the characters asked of it, which
1338            # leaves labels such as "Custom Train ID" overhanging their
1339            # field by more the larger the desktop's UI font is. The
1340            # holder takes its width from the wider of the pair, so this
1341            # never widens the column.
1342            widget.pack(anchor="w", fill="x")
1343        if tooltip:
1344            self._tooltip(widget, tooltip)
1345        return widget
1346
1347    def _make_entry(self, parent, name):
1348        var = tk.StringVar()
1349        entry = ttk.Entry(parent, textvariable=var, width=10, justify="center")
1350        entry.bind("<Return>", lambda event, n=name: self._commit_entry(n))
1351        entry.bind("<FocusOut>", lambda event, n=name: self._commit_entry(n))
1352        self._entry_vars[name] = var
1353        self._entry_widgets[name] = entry
1354        return entry
1355
1356    def _make_combobox(self, parent, values, callback, width):
1357        box = ttk.Combobox(
1358            parent,
1359            values=list(values),
1360            state="readonly",
1361            width=width,
1362        )
1363        box.current(0)
1364        box.bind("<<ComboboxSelected>>", lambda event: callback())
1365        return box
1366
1367    def _make_train_text(self, parent, label, tooltip, commit):
1368        # The two boxes divide whatever width the panel has beyond the
1369        # train selector, which is what the wider Output Channels panel
1370        # above sets. Their requested width still sets the floor, so
1371        # sharing the spare room never widens the window.
1372        holder = ttk.Frame(parent)
1373        holder.pack(side="left", padx=6, pady=4, anchor="n", fill="x",
1374                    expand=True)
1375        ttk.Label(holder, text=label).pack(anchor="w")
1376        # The text and its scrollbar share a grid, so that the
1377        # scrollbar can leave the layout without the text shifting
1378        body = ttk.Frame(holder)
1379        body.pack(fill="x", expand=True)
1380        body.columnconfigure(0, weight=1)
1381
1382        text = tk.Text(
1383            body,
1384            width=self._TRAIN_TEXT_COLUMNS,
1385            height=self._TRAIN_TEXT_ROWS,
1386            wrap="word",
1387            # A plain Tk border is always drawn black, so the colorable
1388            # focus ring is used as the border instead
1389            relief="flat",
1390            borderwidth=0,
1391            highlightthickness=1,
1392            highlightbackground=self._palette["border"],
1393            highlightcolor=self._palette["select_bg"],
1394            background=self._palette["field"],
1395            foreground=self._palette["fg"],
1396            insertbackground=self._palette["fg"],
1397            selectbackground=self._palette["select_bg"],
1398            selectforeground=self._palette["select_fg"],
1399        )
1400        text.grid(row=0, column=0, sticky="nsew")
1401        text.bind("<FocusOut>", lambda event: commit())
1402        self._tooltip(text, tooltip)
1403
1404        scrollbar = ttk.Scrollbar(body, orient="vertical", command=text.yview)
1405        scrollbar.grid(row=0, column=1, sticky="ns")
1406        # Laid out and then withdrawn, so that _autoscroll can restore it
1407        # with the same grid options once there is something to scroll
1408        scrollbar.grid_remove()
1409        text.configure(
1410            yscrollcommand=lambda first, last: self._on_text_scrolled(
1411                scrollbar, text, first, last
1412            )
1413        )
1414        # Tk measures wrapped lines in the background, and reports a
1415        # complete view to the scroll callback until that pass finishes.
1416        # After a large insert, such as opening a program, the fractions
1417        # the callback is handed therefore say the text fits when it
1418        # does not. This event marks the end of the pass.
1419        text.bind(
1420            "<<WidgetViewSync>>",
1421            lambda event: self._sync_scrollbar(scrollbar, text),
1422            add="+",
1423        )
1424        return text
1425
1426    def _on_text_scrolled(self, scrollbar, text, first, last):
1427        """Track a text widget's view, and show its scrollbar as needed."""
1428        scrollbar.set(first, last)
1429        self._sync_scrollbar(scrollbar, text)
1430
1431    def _sync_scrollbar(self, scrollbar, text):
1432        """Show a scrollbar only while its text has rows out of view.
1433
1434        Tk leaves a scrollbar wherever it is put, whether or not the
1435        widget it drives has anything to scroll, so hiding it is left to
1436        the application, as MATLAB's edit boxes do. The view is read
1437        from the widget rather than taken from the scroll callback,
1438        whose fractions can predate the wrapped line measurements.
1439        """
1440        if self._closed:
1441            return
1442        first, last = text.yview()
1443        if first <= 0.0 and last >= 1.0:
1444            scrollbar.grid_remove()
1445        else:
1446            scrollbar.grid()
1447
1448    # ---- Refreshing the view ----
1449
1450    def _refresh(self):
1451        """Push the local parameter copy to the widgets."""
1452        self._loading = True
1453        try:
1454            channel = self._output_channel()
1455            index = channel - 1
1456
1457            self._pulse_type_box.current(
1458                int(self._params["is_biphasic"][index])
1459            )
1460            self._custom_id_box.current(
1461                int(self._params["custom_train_id"][index])
1462            )
1463            self._custom_target_box.current(
1464                int(self._params["custom_train_target"][index])
1465            )
1466            self._custom_loop_var.set(
1467                int(self._params["custom_train_loop"][index])
1468            )
1469
1470            for name in self._entry_vars:
1471                self._entry_vars[name].set(
1472                    _format_number(self._params[name][index])
1473                )
1474
1475            trigger_channel = self._trigger_channel()
1476            self._trigger_mode_box.current(
1477                int(self._trigger_mode[trigger_channel - 1])
1478            )
1479            link_param = f"link_trigger_channel{trigger_channel}"
1480            for output_index, var in enumerate(self._link_vars):
1481                var.set(int(self._params[link_param][output_index]))
1482        finally:
1483            self._loading = False
1484
1485        self._refresh_custom_train_view()
1486        self._update_enabled_state()
1487
1488    def _refresh_custom_train_view(self):
1489        train_index = self._selected_custom_train() - 1
1490        self._displayed_train = train_index
1491        self._set_text(
1492            self._timestamp_text, self._custom_timestamps[train_index]
1493        )
1494        self._set_text(self._voltage_text, self._custom_voltages[train_index])
1495
1496    def _update_enabled_state(self):
1497        index = self._output_channel() - 1
1498        is_biphasic = bool(self._params["is_biphasic"][index])
1499        for name in self._BIPHASIC_ONLY:
1500            self._entry_widgets[name].configure(
1501                state="normal" if is_biphasic else "disabled"
1502            )
1503
1504        uses_custom = int(self._params["custom_train_id"][index]) > 0
1505        self._custom_target_box.configure(
1506            state="readonly" if uses_custom else "disabled"
1507        )
1508        self._custom_loop_check.configure(
1509            state="normal" if uses_custom else "disabled"
1510        )
1511        self._custom_train_list.configure(
1512            state="normal" if uses_custom else "disabled"
1513        )
1514        for text in (self._timestamp_text, self._voltage_text):
1515            text.configure(
1516                state="normal" if uses_custom else "disabled",
1517                background=self._palette[
1518                    "field" if uses_custom else "disabled_field"
1519                ],
1520            )
1521
1522    def _set_text(self, widget, value):
1523        was_disabled = str(widget.cget("state")) == "disabled"
1524        if was_disabled:
1525            widget.configure(state="normal")
1526        widget.delete("1.0", "end")
1527        widget.insert("1.0", value)
1528        if was_disabled:
1529            widget.configure(state="disabled")
1530
1531    def _set_status(self, message):
1532        self._status_var.set(f"Status: {message}")
1533
1534    def _selected_custom_train(self):
1535        selection = self._custom_train_list.curselection()
1536        return (selection[0] + 1) if selection else 1
1537
1538    # ---- Parameter edit callbacks ----
1539
1540    def _commit_entry(self, name):
1541        if self._loading or self._closed:
1542            return
1543        index = self._output_channel() - 1
1544        var = self._entry_vars[name]
1545        label = self._field_labels[name]
1546        try:
1547            value = float(var.get())
1548        except ValueError:
1549            self._show_error(f"{label} must be a number.")
1550            var.set(_format_number(self._params[name][index]))
1551            return
1552
1553        low, high = self._FIELD_RANGES[name]
1554        if not low <= value <= high:
1555            self._show_error(
1556                f"{label} must be in range {_format_number(low)} to "
1557                f"{_format_number(high)}."
1558            )
1559            var.set(_format_number(self._params[name][index]))
1560            return
1561
1562        self._params[name][index] = value
1563        var.set(_format_number(value))
1564
1565    def _on_pulse_type(self):
1566        index = self._output_channel() - 1
1567        self._params["is_biphasic"][index] = self._pulse_type_box.current()
1568        self._update_enabled_state()
1569
1570    def _on_custom_train_id(self):
1571        index = self._output_channel() - 1
1572        self._params["custom_train_id"][index] = self._custom_id_box.current()
1573        self._update_enabled_state()
1574
1575    def _on_custom_train_target(self):
1576        index = self._output_channel() - 1
1577        self._params["custom_train_target"][index] = (
1578            self._custom_target_box.current()
1579        )
1580
1581    def _on_custom_train_loop(self):
1582        index = self._output_channel() - 1
1583        self._params["custom_train_loop"][index] = self._custom_loop_var.get()
1584
1585    def _on_trigger_mode(self):
1586        channel_index = self._trigger_channel() - 1
1587        self._trigger_mode[channel_index] = self._trigger_mode_box.current()
1588
1589    def _on_trigger_link(self, output_channel):
1590        link_param = f"link_trigger_channel{self._trigger_channel()}"
1591        self._params[link_param][output_channel - 1] = (
1592            self._link_vars[output_channel - 1].get()
1593        )
1594
1595    def _on_custom_train_selected(self, _event=None):
1596        # Both boxes are committed before the new train is loaded over
1597        # them, since this arrives before they lose focus
1598        self._commit_timestamps()
1599        self._commit_voltages()
1600        self._refresh_custom_train_view()
1601
1602    def _commit_timestamps(self):
1603        """Store the timestamps box against the train it is showing.
1604
1605        Not against the selected train: a click on the train list
1606        changes the selection, and loads the newly selected train into
1607        the boxes, before they are told they have lost focus. Committing
1608        to the selection at that point would file the edit under the
1609        train the user had just moved to. _on_custom_train_selected
1610        commits first, so that by the time the focus event arrives the
1611        boxes and this index agree and the commit is a no-op.
1612        """
1613        if self._closed:
1614            return
1615        text = self._timestamp_text.get("1.0", "end-1c")
1616        self._custom_timestamps[self._displayed_train] = text
1617        try:
1618            _parse_number_list(text)
1619        except ValueError:
1620            self._show_error(
1621                "Timestamps must be a comma-delimited list of pulse onset "
1622                "times, given in seconds."
1623            )
1624
1625    def _commit_voltages(self):
1626        """Store the voltages box against the train it is showing."""
1627        if self._closed:
1628            return
1629        text = self._voltage_text.get("1.0", "end-1c")
1630        self._custom_voltages[self._displayed_train] = text
1631        try:
1632            _parse_number_list(text)
1633        except ValueError:
1634            self._show_error(
1635                "Voltages must be a comma-delimited list of pulse voltages, "
1636                "given in volts."
1637            )
1638
1639    # ---- Toolbar actions ----
1640
1641    def _fire(self):
1642        channels = [
1643            channel
1644            for channel, var in enumerate(self._fire_vars, start=1)
1645            if var.get()
1646        ]
1647        device = self._device
1648        if not channels or device is None:
1649            return
1650        try:
1651            device.trigger(channels)
1652        except Exception as exc:
1653            self._show_error(f"Failed to trigger output channels:\n{exc}")
1654            return
1655        self._set_status("Output Channels Triggered")
1656
1657    def _restore_defaults(self):
1658        self._load_default_params()
1659        self._custom_timestamps = [""] * self._n_custom_trains
1660        self._custom_voltages = [""] * self._n_custom_trains
1661        self._reset_selections()
1662        self._refresh()
1663        self._set_status("Default Program Restored")
1664
1665    def _upload_program(self):
1666        device = self._device
1667        if device is None:
1668            return
1669
1670        self._store_train_boxes()
1671        custom_trains = self._collect_custom_trains()
1672        if custom_trains is None:
1673            return
1674
1675        for index in range(4):
1676            if (
1677                int(self._params["custom_train_target"][index]) == 1
1678                and float(self._params["burst_duration"][index]) == 0
1679            ):
1680                self._show_error(
1681                    f"Error in output channel {index + 1}: when custom train "
1682                    "times target burst onsets, a non-zero burst duration "
1683                    "must be defined."
1684                )
1685                return
1686
1687        try:
1688            for name, values in self._params.items():
1689                getattr(device, name)[1:5] = list(values)
1690            device.trigger_mode[1:3] = list(self._trigger_mode)
1691            device.sync_to_device()
1692            for train_id, times, voltages in custom_trains:
1693                device.send_custom_pulse_train(train_id, times, voltages)
1694        except Exception as exc:
1695            self._show_error(f"Failed to load the program to the device:\n"
1696                             f"{exc}")
1697            return
1698        self._set_status("Program Loaded to Device")
1699
1700    def _store_train_boxes(self):
1701        """File what the text boxes hold, without validating it.
1702
1703        The toolbar works from the stored copy of the custom trains, so
1704        anything typed since the boxes last lost focus has to be filed
1705        before it is read. Whether the toolbar waits for the boxes to
1706        lose focus first is up to how the platform orders a click on a
1707        button against the focus change it causes, which is not worth
1708        depending on. Validation is left to the caller, which reports
1709        what it finds in terms of the action the user asked for.
1710        """
1711        if self._closed:
1712            return
1713        index = self._displayed_train
1714        self._custom_timestamps[index] = self._timestamp_text.get(
1715            "1.0", "end-1c"
1716        )
1717        self._custom_voltages[index] = self._voltage_text.get("1.0", "end-1c")
1718
1719    def _collect_custom_trains(self):
1720        """Parse the custom train editor, returning None if it is invalid."""
1721        trains = []
1722        for train_id in range(1, self._n_custom_trains + 1):
1723            timestamp_text = self._custom_timestamps[train_id - 1]
1724            voltage_text = self._custom_voltages[train_id - 1]
1725            if not timestamp_text.strip() and not voltage_text.strip():
1726                continue
1727            try:
1728                times = _parse_number_list(timestamp_text)
1729                voltages = _parse_number_list(voltage_text)
1730            except ValueError:
1731                self._show_error(
1732                    f"Failed to load custom pulse train {train_id}: "
1733                    "timestamps and voltages must be comma-delimited lists "
1734                    "of numbers."
1735                )
1736                return None
1737            if len(times) != len(voltages):
1738                self._show_error(
1739                    f"Failed to load custom pulse train {train_id}: the "
1740                    "number of timestamps and voltages must match."
1741                )
1742                return None
1743            if times:
1744                trains.append((train_id, times, voltages))
1745        return trains
1746
1747    def _save_program(self):
1748        device = self._device
1749        if device is None:
1750            return
1751
1752        self._store_train_boxes()
1753        path = filedialog.asksaveasfilename(
1754            parent=self._root,
1755            title="Save program",
1756            defaultextension=".json",
1757            initialfile="PulsePalProgram.json",
1758            initialdir=self._last_program_dir or None,
1759            filetypes=(("Pulse Pal program", "*.json"), ("All files", "*.*")),
1760        )
1761        if not path:
1762            return
1763
1764        program = {
1765            "params": {
1766                name: list(values) for name, values in self._params.items()
1767            },
1768            "trigger_mode": list(self._trigger_mode),
1769            "custom_train_timestamps": list(self._custom_timestamps),
1770            "custom_train_voltages": list(self._custom_voltages),
1771            "device_info": dataclasses.asdict(device.info),
1772        }
1773        try:
1774            with open(path, "w", encoding="utf-8") as program_file:
1775                json.dump(program, program_file, indent=2)
1776        except OSError as exc:
1777            self._show_error(f"Failed to save the program:\n{exc}")
1778            return
1779
1780        self._last_program_dir = os.path.dirname(path)
1781        self._set_status("Program Saved")
1782        self.focus()
1783
1784    def _open_program(self):
1785        path = filedialog.askopenfilename(
1786            parent=self._root,
1787            title="Open program",
1788            initialdir=self._last_program_dir or None,
1789            filetypes=(("Pulse Pal program", "*.json"), ("All files", "*.*")),
1790        )
1791        if not path:
1792            return
1793
1794        try:
1795            with open(path, encoding="utf-8") as program_file:
1796                program = json.load(program_file)
1797            params = program["params"]
1798            new_params = {}
1799            for name, default in self._DEFAULT_OUTPUT_PARAMS.items():
1800                values = params.get(name, [default] * 4)
1801                if len(values) != 4:
1802                    raise ValueError(
1803                        f"{name} must have one value per output channel."
1804                    )
1805                new_params[name] = [float(value) for value in values]
1806            trigger_mode = [
1807                int(value) for value in program.get("trigger_mode", [0, 0])
1808            ]
1809            if len(trigger_mode) != 2:
1810                raise ValueError(
1811                    "trigger_mode must have one value per trigger channel."
1812                )
1813            timestamps = list(program.get("custom_train_timestamps", []))
1814            voltages = list(program.get("custom_train_voltages", []))
1815        except (OSError, ValueError, KeyError, TypeError) as exc:
1816            self._show_error(f"Failed to open the program:\n{exc}")
1817            return
1818
1819        self._params = new_params
1820        self._trigger_mode = trigger_mode
1821        self._custom_timestamps = self._fit_custom_trains(timestamps)
1822        self._custom_voltages = self._fit_custom_trains(voltages)
1823        self._reset_selections()
1824        self._refresh()
1825        self._last_program_dir = os.path.dirname(path)
1826        self._set_status("Program Opened")
1827        self.focus()
1828
1829    def _fit_custom_trains(self, values):
1830        """Coerce a saved custom train list to this device's train count."""
1831        fitted = [""] * self._n_custom_trains
1832        for index, value in enumerate(values[:self._n_custom_trains]):
1833            if isinstance(value, (list, tuple)):
1834                value = ", ".join(_format_number(item) for item in value)
1835            fitted[index] = str(value)
1836        return fitted
1837
1838    def _reset_selections(self):
1839        self._output_channel_var.set(1)
1840        self._trigger_channel_var.set(1)
1841        # A disabled listbox drops selection changes without complaint,
1842        # and this one is disabled whenever the output channel on show
1843        # plays no custom train, which is the default. Restoring
1844        # defaults or opening a program would then leave the list on the
1845        # train that happened to be selected. The state is put back as
1846        # it was, and _update_enabled_state settles it either way.
1847        state = str(self._custom_train_list.cget("state"))
1848        self._custom_train_list.configure(state="normal")
1849        self._custom_train_list.selection_clear(0, "end")
1850        self._custom_train_list.selection_set(0)
1851        self._custom_train_list.configure(state=state)
1852
1853    def _show_error(self, message):
1854        messagebox.showerror("Pulse Pal", message, parent=self._root)
class PulsePalGUI:
 270class PulsePalGUI:
 271    """Parameter editor window for a connected PulsePalDevice.
 272
 273    Parameters are edited in a local copy held by the GUI, and are only sent
 274    to the device when 'Load to Device' is clicked. This matches the behavior
 275    of the MATLAB parameter GUI.
 276    """
 277
 278    # Check mark strokes, as 2x2 blocks on the indicator grid
 279    _INDICATOR_SIZE = 13
 280    _CHECK_MARK = (
 281        (3, 6), (4, 7), (5, 8), (6, 7), (7, 6), (8, 5), (9, 4),
 282    )
 283
 284    # Minimum side length of the square FIRE button, in pixels. The
 285    # MATLAB GUI draws the same button 46x44. The button grows past this
 286    # where the theme font needs the room, so that its label always fits.
 287    _FIRE_BUTTON_SIZE = 45
 288
 289    # Line height of the font the pixel sizes here were measured
 290    # against, Windows' 9 point Segoe UI. Desktops that set a larger UI
 291    # font scale them up in proportion, so that the parts drawn to a
 292    # pixel size keep pace with the parts drawn to the font. See
 293    # _scaled.
 294    _REFERENCE_LINESPACE = 15
 295
 296    # Title size as a multiple of the default UI font, which is 9 point on
 297    # Windows and larger on most Linux desktops. Scaling keeps the heading
 298    # in proportion with the rest of the window on both.
 299    _TITLE_FONT_SCALE = 16 / 9
 300
 301    # Width of the custom train text boxes, in characters. This is only
 302    # a floor: the boxes expand to fill the Custom Pulse Trains panel,
 303    # which the wider Output Channels panel above sizes. Asking for the
 304    # full width here instead made this panel the widest in the window,
 305    # which stretched the panels above it past their own content and
 306    # widened the window again whenever a scrollbar appeared.
 307    _TRAIN_TEXT_COLUMNS = 20
 308
 309    # Height of those boxes, in rows. Four reaches just past the bottom
 310    # of the train selector beside them, which holds four trains on
 311    # current hardware, and takes a fourth line of values before a
 312    # scrollbar is needed.
 313    _TRAIN_TEXT_ROWS = 4
 314
 315    # Space around each field in the parameter panels, in pixels
 316    _FIELD_PADDING = 4
 317
 318    # Distance, in pixels, from the center of a checkbutton's indicator
 319    # to the center of the widget. A checkbutton keeps room to the right
 320    # of its indicator for text, which these checkbuttons do not have,
 321    # so their indicators sit left of center by this much.
 322    _INDICATOR_OFFSET = 2
 323
 324    _PULSE_TYPES = ("Monophasic", "Biphasic")
 325    _CUSTOM_TRAIN_TARGETS = ("Pulses", "Bursts")
 326    _TRIGGER_MODES = ("Normal", "Toggle", "Pulse Gated")
 327
 328    _DEFAULT_OUTPUT_PARAMS = {
 329        "is_biphasic": 0,
 330        "phase1_voltage": 5.0,
 331        "phase2_voltage": -5.0,
 332        "resting_voltage": 0.0,
 333        "phase1_duration": 0.001,
 334        "inter_phase_interval": 0.001,
 335        "phase2_duration": 0.001,
 336        "inter_pulse_interval": 0.01,
 337        "burst_duration": 0.0,
 338        "inter_burst_interval": 0.0,
 339        "pulse_train_duration": 1.0,
 340        "pulse_train_delay": 0.0,
 341        "link_trigger_channel1": 1,
 342        "link_trigger_channel2": 0,
 343        "custom_train_id": 0,
 344        "custom_train_target": 0,
 345        "custom_train_loop": 0,
 346    }
 347
 348    # (parameter name, label, tooltip)
 349    _VOLTAGE_FIELDS = (
 350        (
 351            "resting_voltage",
 352            "Resting (V)",
 353            "Voltage while not delivering a pulse (V)",
 354        ),
 355        (
 356            "phase1_voltage",
 357            "Phase1 (V)",
 358            "Voltage of the first phase of each pulse (V)",
 359        ),
 360        (
 361            "phase2_voltage",
 362            "Phase2 (V)",
 363            "Voltage of the second phase of each pulse (V)",
 364        ),
 365    )
 366    _TIME_FIELDS = (
 367        (
 368            "phase1_duration",
 369            "Phase1 (s)",
 370            "Duration of the first phase of each pulse (s)",
 371        ),
 372        (
 373            "inter_phase_interval",
 374            "Phase Interval",
 375            "Interval between pulse phases (s)",
 376        ),
 377        (
 378            "phase2_duration",
 379            "Phase2 (s)",
 380            "Duration of the second phase of each pulse (s)",
 381        ),
 382        (
 383            "inter_pulse_interval",
 384            "Pulse Interval",
 385            "Interval between pulse-end and the next pulse (s)",
 386        ),
 387        (
 388            "burst_duration",
 389            "Burst (s)",
 390            "Duration of pulse bursts (0 = no bursts, units = seconds)",
 391        ),
 392        (
 393            "inter_burst_interval",
 394            "Burst Interval",
 395            "Interval between pulse bursts (s)",
 396        ),
 397        (
 398            "pulse_train_duration",
 399            "Train (s)",
 400            "Duration of the pulse train (s)",
 401        ),
 402        (
 403            "pulse_train_delay",
 404            "Train Delay",
 405            "Delay from trigger to pulse train onset (s)",
 406        ),
 407    )
 408
 409    # Parameters that are only meaningful for biphasic pulses
 410    _BIPHASIC_ONLY = (
 411        "phase2_voltage",
 412        "inter_phase_interval",
 413        "phase2_duration",
 414    )
 415
 416    # Valid ranges, matching those enforced by the device interface
 417    _FIELD_RANGES = {
 418        "resting_voltage": (-10.0, 10.0),
 419        "phase1_voltage": (-10.0, 10.0),
 420        "phase2_voltage": (-10.0, 10.0),
 421        "phase1_duration": (0.0001, 3600.0),
 422        "inter_phase_interval": (0.0, 3600.0),
 423        "phase2_duration": (0.0001, 3600.0),
 424        "inter_pulse_interval": (0.0001, 3600.0),
 425        "burst_duration": (0.0, 3600.0),
 426        "inter_burst_interval": (0.0, 3600.0),
 427        "pulse_train_duration": (0.0001, 3600.0),
 428        "pulse_train_delay": (0.0, 3600.0),
 429    }
 430
 431    def __init__(self, device, theme=None):
 432        # The device is held weakly so that the GUI never keeps a released
 433        # PulsePalDevice alive: the device's destructor closes this window.
 434        self._device_ref = weakref.ref(device)
 435        self._closed = False
 436        self._release_host_event_loop = None
 437        self._topmost_after_id = None
 438
 439        # Resolved before any window exists, so an invalid theme argument
 440        # raises without leaving a half-built GUI behind
 441        theme = _resolve_theme(theme)
 442        self._theme = None
 443        self._palette = {}
 444        self._native_ttk_theme = None
 445        self._indicator_element = None
 446        self._indicator_images = {}
 447        self._loading = True
 448        self._last_program_dir = _default_program_dir()
 449
 450        n_trains = getattr(device.info, "n_custom_pulse_trains", None) or 2
 451        self._n_custom_trains = int(n_trains)
 452        self._custom_timestamps = [""] * self._n_custom_trains
 453        self._custom_voltages = [""] * self._n_custom_trains
 454        # The train the text boxes are showing, which is not always the
 455        # one selected in the list: see _commit_timestamps
 456        self._displayed_train = 0
 457
 458        self._params = {}
 459        self._trigger_mode = []
 460        self._load_default_params()
 461
 462        self._entry_vars = {}
 463        self._entry_widgets = {}
 464        self._field_labels = {
 465            name: label
 466            for name, label, _ in self._VOLTAGE_FIELDS + self._TIME_FIELDS
 467        }
 468
 469        self._root = tk.Tk()
 470        self._root.title("Pulse Pal Parameter Editor")
 471        self._root.resizable(False, False)
 472        self._root.protocol("WM_DELETE_WINDOW", self.close)
 473        self._init_fonts()
 474
 475        # Applied before the widgets are built: several of them take their
 476        # colors at construction time
 477        self.set_theme(theme)
 478
 479        self._build_header()
 480        self._build_output_panel()
 481        self._build_trigger_panel()
 482        self._build_custom_train_panel()
 483        self._build_status_bar()
 484
 485        self._loading = False
 486        self._refresh()
 487        self._set_status("GUI Loaded")
 488
 489    # ---- Public interface ----
 490
 491    @property
 492    def is_closed(self):
 493        """True once the GUI window has been closed."""
 494        return self._closed
 495
 496    @property
 497    def _device(self):
 498        """The device being edited, or None once it has been released."""
 499        ref = self._device_ref
 500        return ref() if ref is not None else None
 501
 502    @property
 503    def theme(self):
 504        """The active color theme, 'light' or 'dark'."""
 505        return self._theme
 506
 507    def set_theme(self, theme):
 508        """Switch the GUI between the light and dark color themes.
 509
 510        Args:
 511            theme: ``"light"``, ``"dark"``, or ``None`` to match the
 512                desktop theme.
 513
 514        Raises:
 515            ValueError: If the theme name is not recognized.
 516        """
 517        name = _resolve_theme(theme)
 518        if self._closed or name == self._theme:
 519            return
 520        self._theme = name
 521        # Updated in place, since tooltips hold a reference to this dict
 522        self._palette.clear()
 523        self._palette.update(_PALETTES[name])
 524        self._apply_theme_styles()
 525        self._apply_widget_palette()
 526
 527    def _apply_theme_styles(self):
 528        """Configure the ttk styles for the active theme."""
 529        palette = self._palette
 530        style = ttk.Style(self._root)
 531        if self._native_ttk_theme is None:
 532            self._native_ttk_theme = style.theme_use()
 533
 534        if self._theme != "dark":
 535            # The native ttk theme already matches the light palette
 536            style.theme_use(self._native_ttk_theme)
 537        else:
 538            # Native themes draw most widgets with the platform's own
 539            # colors and ignore color options, so dark mode switches to
 540            # 'clam', which is fully colorable
 541            style.theme_use("clam")
 542            style.configure(
 543                ".",
 544                background=palette["bg"],
 545                foreground=palette["fg"],
 546                fieldbackground=palette["field"],
 547                bordercolor=palette["border"],
 548                lightcolor=palette["bg"],
 549                darkcolor=palette["bg"],
 550                troughcolor=palette["field"],
 551                focuscolor=palette["select_bg"],
 552            )
 553            # clam maps disabled widgets to a light background of its own,
 554            # which configure() above does not override
 555            style.map(
 556                ".",
 557                background=[("disabled", palette["bg"])],
 558                foreground=[("disabled", palette["disabled_fg"])],
 559                fieldbackground=[("disabled", palette["disabled_field"])],
 560            )
 561            style.configure("TLabelframe", bordercolor=palette["border"])
 562            style.configure(
 563                "TButton",
 564                background=palette["button"],
 565                bordercolor=palette["border"],
 566                focuscolor=palette["bg"],
 567            )
 568            style.configure("TEntry", insertcolor=palette["fg"])
 569            style.configure(
 570                "TScrollbar",
 571                background=palette["button"],
 572                troughcolor=palette["field"],
 573                bordercolor=palette["border"],
 574                arrowcolor=palette["fg"],
 575            )
 576            style.map(
 577                "TScrollbar",
 578                background=[("active", palette["active"])],
 579            )
 580            style.configure(
 581                "TCombobox",
 582                arrowcolor=palette["fg"],
 583                background=palette["button"],
 584            )
 585            for widget in ("TCheckbutton", "TRadiobutton"):
 586                style.configure(
 587                    widget,
 588                    indicatorbackground=palette["field"],
 589                    indicatorforeground=palette["fg"],
 590                    # The indicator draws its own border, from options that
 591                    # do not inherit the style's bordercolor
 592                    upperbordercolor=palette["border"],
 593                    lowerbordercolor=palette["border"],
 594                )
 595                style.map(
 596                    widget,
 597                    foreground=[("disabled", palette["disabled_fg"])],
 598                    indicatorbackground=[
 599                        ("disabled", palette["disabled_field"]),
 600                        ("selected", palette["select_bg"]),
 601                    ],
 602                    indicatorforeground=[
 603                        ("selected", palette["select_fg"]),
 604                    ],
 605                )
 606            style.map(
 607                "TButton",
 608                background=[
 609                    ("pressed", palette["border"]),
 610                    ("active", palette["active"]),
 611                ],
 612                foreground=[("disabled", palette["disabled_fg"])],
 613            )
 614            style.map(
 615                "TEntry",
 616                fieldbackground=[
 617                    ("disabled", palette["disabled_field"]),
 618                ],
 619                foreground=[("disabled", palette["disabled_fg"])],
 620            )
 621            style.map(
 622                "TCombobox",
 623                fieldbackground=[
 624                    ("disabled", palette["disabled_field"]),
 625                    ("readonly", palette["field"]),
 626                ],
 627                foreground=[("disabled", palette["disabled_fg"])],
 628                arrowcolor=[("disabled", palette["disabled_fg"])],
 629                selectbackground=[("readonly", palette["field"])],
 630                selectforeground=[("readonly", palette["fg"])],
 631            )
 632            self._install_check_indicator(style)
 633
 634        # The combobox dropdown is a plain Tk listbox inside the popdown
 635        # window, which ttk styles do not reach
 636        for option, value in (
 637            ("*TCombobox*Listbox.background", palette["field"]),
 638            ("*TCombobox*Listbox.foreground", palette["fg"]),
 639            ("*TCombobox*Listbox.selectBackground", palette["select_bg"]),
 640            ("*TCombobox*Listbox.selectForeground", palette["select_fg"]),
 641        ):
 642            self._root.option_add(option, value)
 643
 644    def _install_check_indicator(self, style):
 645        """Give checkbuttons a check mark, which clam draws as an X."""
 646        name = "PulsePal.Checkbutton.indicator"
 647        if self._indicator_element is None:
 648            palette = self._palette
 649            images = {
 650                "off": self._draw_indicator(
 651                    palette["field"], palette["border"], None
 652                ),
 653                "on": self._draw_indicator(
 654                    palette["select_bg"],
 655                    palette["select_bg"],
 656                    palette["select_fg"],
 657                ),
 658                "off_disabled": self._draw_indicator(
 659                    palette["disabled_field"], palette["disabled_fg"], None
 660                ),
 661                "on_disabled": self._draw_indicator(
 662                    palette["disabled_field"],
 663                    palette["disabled_fg"],
 664                    palette["disabled_fg"],
 665                ),
 666            }
 667            # Held on the instance: ttk keeps no reference of its own, and
 668            # the indicators go blank if the images are collected
 669            self._indicator_images = images
 670            style.element_create(
 671                name,
 672                "image",
 673                images["off"],
 674                ("disabled", "selected", images["on_disabled"]),
 675                ("disabled", images["off_disabled"]),
 676                ("selected", images["on"]),
 677                sticky="",
 678            )
 679            self._indicator_element = name
 680
 681        style.layout(
 682            "TCheckbutton",
 683            self._replace_indicator(style.layout("TCheckbutton"), name),
 684        )
 685
 686    def _draw_indicator(self, fill, border, mark):
 687        """Draw one checkbutton indicator as a Tk image.
 688
 689        Drawn at the size the desktop's font asks for. The native themes
 690        size their own indicators from the font, so a fixed size here
 691        left dark mode, which draws these instead, with check boxes
 692        visibly smaller than the light theme's.
 693        """
 694        size = self._scaled(self._INDICATOR_SIZE)
 695        edge = self._scaled(1)
 696        image = tk.PhotoImage(master=self._root, width=size, height=size)
 697        image.put(border, to=(0, 0, size, size))
 698        image.put(fill, to=(edge, edge, size - edge, size - edge))
 699        if mark is not None:
 700            # The stroke coordinates are on the reference grid, so they
 701            # scale with it
 702            block = self._scaled(2)
 703            for x, y in self._CHECK_MARK:
 704                left, top = self._scaled(x), self._scaled(y)
 705                image.put(mark, to=(left, top, left + block, top + block))
 706        return image
 707
 708    @classmethod
 709    def _replace_indicator(cls, layout, name):
 710        """Return a ttk layout with the checkbutton indicator swapped out."""
 711        replaced = []
 712        for element, options in layout:
 713            options = dict(options)
 714            children = options.get("children")
 715            if children:
 716                options["children"] = cls._replace_indicator(children, name)
 717            if element.endswith("Checkbutton.indicator"):
 718                element = name
 719            replaced.append((element, options))
 720        return replaced
 721
 722    def _apply_widget_palette(self):
 723        """Color the plain Tk widgets, which ttk styles do not cover."""
 724        palette = self._palette
 725        self._root.configure(background=palette["bg"])
 726
 727        listbox = getattr(self, "_custom_train_list", None)
 728        if listbox is not None:
 729            listbox.configure(
 730                background=palette["field"],
 731                foreground=palette["fg"],
 732                disabledforeground=palette["disabled_fg"],
 733                selectbackground=palette["select_bg"],
 734                selectforeground=palette["select_fg"],
 735                highlightbackground=palette["border"],
 736                highlightcolor=palette["select_bg"],
 737            )
 738
 739        texts = [
 740            getattr(self, "_timestamp_text", None),
 741            getattr(self, "_voltage_text", None),
 742        ]
 743        for text in texts:
 744            if text is None:
 745                continue
 746            text.configure(
 747                foreground=palette["fg"],
 748                insertbackground=palette["fg"],
 749                selectbackground=palette["select_bg"],
 750                selectforeground=palette["select_fg"],
 751                highlightbackground=palette["border"],
 752                highlightcolor=palette["select_bg"],
 753            )
 754        if all(text is not None for text in texts):
 755            # Repaints the text backgrounds for the current enabled state
 756            self._update_enabled_state()
 757
 758    def start(self, block=None):
 759        """Show the GUI.
 760
 761        Args:
 762            block: If True, run the Tk event loop until the window is closed.
 763                If False, return immediately (the host application must pump
 764                Tk events). If None, block only when the host does not
 765                already provide a Tk event loop.
 766        """
 767        if self._closed:
 768            return
 769        if block is None:
 770            block = not self._enable_host_event_loop()
 771        self._bring_to_front()
 772        if block:
 773            try:
 774                self._root.mainloop()
 775            finally:
 776                self.close()
 777
 778    def focus(self):
 779        """Raise the GUI window and give it keyboard focus."""
 780        self._bring_to_front()
 781
 782    def _bring_to_front(self):
 783        """Raise the window above the windows of other applications.
 784
 785        Windows refuses to activate a window belonging to a process that has
 786        not yet been in the foreground, which leaves the first GUI of a
 787        session stuck behind the host IDE. Marking the window topmost is not
 788        subject to that restriction; the flag is dropped again as soon as the
 789        window is up, so the window is raised without staying pinned over
 790        everything else.
 791        """
 792        root = self._root
 793        if self._closed or root is None:
 794            return
 795        try:
 796            root.deiconify()
 797            # The window must be realized before it can be raised
 798            root.update_idletasks()
 799            root.lift()
 800            root.attributes("-topmost", True)
 801            root.focus_force()
 802            self._cancel_topmost_reset()
 803            self._topmost_after_id = root.after_idle(self._clear_topmost)
 804        except tk.TclError:
 805            pass
 806
 807    def _clear_topmost(self):
 808        """Drop the topmost flag, leaving the window raised where it is."""
 809        self._topmost_after_id = None
 810        if self._closed or self._root is None:
 811            return
 812        try:
 813            self._root.attributes("-topmost", False)
 814        except tk.TclError:
 815            pass
 816
 817    def _cancel_topmost_reset(self):
 818        """Cancel a pending topmost reset, so it cannot outlive the window."""
 819        after_id = self._topmost_after_id
 820        self._topmost_after_id = None
 821        if after_id is None or self._root is None:
 822            return
 823        try:
 824            self._root.after_cancel(after_id)
 825        except tk.TclError:
 826            pass
 827
 828    def close(self):
 829        """Close the GUI window."""
 830        if self._closed:
 831            return
 832        self._closed = True
 833
 834        device = self._device
 835        self._device_ref = None
 836        if device is not None and getattr(device, "_gui", None) is self:
 837            device._gui = None
 838
 839        # Unregister before the window is destroyed, so that the host does
 840        # not keep pumping events for a dead Tk interpreter
 841        self._cancel_topmost_reset()
 842
 843        release = self._release_host_event_loop
 844        self._release_host_event_loop = None
 845        if release is not None:
 846            try:
 847                release()
 848            except Exception:
 849                pass
 850
 851        root = self._root
 852        self._root = None
 853        if root is not None:
 854            try:
 855                root.destroy()
 856            except Exception:
 857                # The interpreter may already be tearing down Tk
 858                pass
 859
 860    def _enable_host_event_loop(self):
 861        """Return True if the host will pump Tk events for the GUI."""
 862        # The PyCharm / PyDev console pumps a registered input hook between
 863        # commands. This window is passed explicitly: left to itself, PyDev
 864        # creates a second Tk interpreter, whose event loop would not service
 865        # this window. This is tried before IPython because the PyCharm
 866        # console's IPython shell delegates to the same hook.
 867        try:
 868            from pydev_ipython.inputhook import (
 869                GUI_TK,
 870                clear_inputhook,
 871                enable_gui,
 872            )
 873            enable_gui(GUI_TK, app=self._root)
 874        except Exception:
 875            pass
 876        else:
 877            self._release_host_event_loop = clear_inputhook
 878            return True
 879
 880        try:
 881            from IPython import get_ipython
 882            shell = get_ipython()
 883        except Exception:
 884            shell = None
 885
 886        if shell is not None:
 887            try:
 888                shell.enable_gui("tk")
 889                return True
 890            except Exception:
 891                return False
 892
 893        # The interactive CPython prompt pumps Tk events between commands
 894        return bool(getattr(sys, "ps1", None)) or bool(sys.flags.interactive)
 895
 896    # ---- Parameter storage ----
 897
 898    def _load_default_params(self):
 899        self._params = {
 900            name: [value] * 4
 901            for name, value in self._DEFAULT_OUTPUT_PARAMS.items()
 902        }
 903        self._trigger_mode = [0, 0]
 904
 905    def _output_channel(self):
 906        return self._output_channel_var.get()
 907
 908    def _trigger_channel(self):
 909        return self._trigger_channel_var.get()
 910
 911    # ---- Widget construction ----
 912
 913    def _init_fonts(self):
 914        """Derive the header fonts from the platform's default UI font.
 915
 916        The family in a font tuple has to be a font family, and
 917        "TkDefaultFont" is the name of a named font rather than one.
 918        Naming it as a family leaves Tk no match, so it substitutes its
 919        fallback: a scalable face on Windows, which hid the mistake, and
 920        a bitmap face on X11, which rendered these labels pixelated.
 921        """
 922        # Bound to this window's interpreter rather than looked up with
 923        # nametofont, which resolves against the default root: that is a
 924        # different window when the GUI runs inside a host application
 925        # that already created one. (nametofont grew a root argument in
 926        # 3.10, past this package's floor.)
 927        base = tkfont.Font(root=self._root, name="TkDefaultFont", exists=True)
 928
 929        # Named fonts are shared by every widget that does not ask for
 930        # one of its own, so pointing them at the desktop's UI font
 931        # covers the labels, buttons and lists at once. The custom train
 932        # boxes keep TkFixedFont, whose columns line their values up.
 933        family, desktop_size = _detect_desktop_font(self._root)
 934        if family is not None or desktop_size is not None:
 935            changes = {}
 936            if family is not None:
 937                changes["family"] = family
 938            if desktop_size is not None:
 939                changes["size"] = desktop_size
 940            for name in ("TkDefaultFont", "TkTextFont", "TkMenuFont",
 941                         "TkHeadingFont"):
 942                tkfont.Font(
 943                    root=self._root, name=name, exists=True
 944                ).configure(**changes)
 945
 946        size = base.cget("size")
 947
 948        self._title_font = base.copy()
 949        # A font size is in points when positive and pixels when negative
 950        scaled = round(abs(size) * self._TITLE_FONT_SCALE)
 951        self._title_font.configure(
 952            size=-scaled if size < 0 else scaled, weight="bold"
 953        )
 954
 955        # Bold at the default size, rather than at a fixed 9 point, so
 956        # these labels stay in step with the plain ones beside them
 957        self._label_font = base.copy()
 958        self._label_font.configure(weight="bold")
 959
 960        self._ui_scale = (
 961            base.metrics("linespace") / self._REFERENCE_LINESPACE
 962        )
 963
 964    def _scaled(self, pixels):
 965        """Scale a pixel size measured at the reference font size.
 966
 967        Sizes given in pixels do not follow the desktop's UI font the
 968        way the widgets around them do. Under a larger font they end up
 969        cramped, which showed as a crowded parameter panel and undersized
 970        check marks on desktops whose font is larger than Windows'.
 971        """
 972        return max(1, round(pixels * self._ui_scale))
 973
 974    def _build_header(self):
 975        header = ttk.Frame(self._root)
 976        header.pack(fill="x", padx=10, pady=(8, 0))
 977
 978        # The title and the toolbar are stacked on the left, so that the
 979        # trigger controls on the right are centered across both of them
 980        titles = ttk.Frame(header)
 981        titles.pack(side="left", fill="x", expand=True)
 982
 983        ttk.Label(
 984            titles,
 985            text="Pulse Pal Parameter Editor",
 986            font=self._title_font,
 987        ).pack(anchor="w")
 988
 989        trigger_controls = ttk.Frame(header)
 990        trigger_controls.pack(side="right")
 991
 992        # A ttk.Button has no height option, and its width is measured in
 993        # text characters, so it is packed into a fixed size frame with
 994        # geometry propagation off to make it square
 995        fire_box = ttk.Frame(trigger_controls)
 996        fire_box.pack(side="right", padx=(8, 0))
 997        fire_box.pack_propagate(False)
 998        fire = ttk.Button(fire_box, text="FIRE", command=self._fire)
 999        fire.pack(fill="both", expand=True)
1000        self._tooltip(fire, "Trigger the selected output channels")
1001
1002        # A fixed 45 px is only wide enough for "FIRE" in fonts as
1003        # narrow as Windows' 9 point Segoe UI, and clipped the label
1004        # under the larger fonts of Linux desktops. Fitting it takes the
1005        # width of the text plus the room the theme leaves around it,
1006        # which is 10 px under vista and 16 under clam, the theme dark
1007        # mode switches to. A button cannot be asked for that room
1008        # directly, and its requested width is no help: themes ask for a
1009        # standard button width, 11 characters under vista, which has
1010        # nothing to do with the label. Text longer than that minimum
1011        # leaves the theme's own padding as the difference.
1012        style = ttk.Style(self._root)
1013        spec = style.lookup("TButton", "font") or "TkDefaultFont"
1014        button_font = tkfont.Font(root=self._root, font=spec)
1015        probe_text = "FIRE" * 10
1016        probe = ttk.Button(fire_box, text=probe_text)
1017        chrome = probe.winfo_reqwidth() - button_font.measure(probe_text)
1018        probe.destroy()
1019
1020        side = max(
1021            self._scaled(self._FIRE_BUTTON_SIZE),
1022            button_font.measure("FIRE") + chrome,
1023        )
1024        fire_box.configure(width=side, height=side)
1025
1026        checks = ttk.Frame(trigger_controls)
1027        checks.pack(side="right")
1028        ttk.Label(
1029            checks,
1030            text="Trigger Channels:",
1031            font=self._label_font,
1032        ).grid(row=1, column=0, padx=(0, 6))
1033        self._fire_vars = []
1034        for channel in range(1, 5):
1035            var = tk.IntVar(value=0)
1036            self._fire_vars.append(var)
1037            # Padding on the right shifts the digit left by half of
1038            # itself, since grid centers the label and its padding
1039            # together, to place the digit over the indicator below
1040            ttk.Label(
1041                checks,
1042                text=str(channel),
1043                font=self._label_font,
1044            ).grid(
1045                row=0,
1046                column=channel,
1047                padx=(0, self._scaled(2 * self._INDICATOR_OFFSET)),
1048            )
1049            check = ttk.Checkbutton(checks, variable=var)
1050            check.grid(row=1, column=channel)
1051            self._tooltip(
1052                check, f"Include output channel {channel} when firing"
1053            )
1054
1055        toolbar = ttk.Frame(titles)
1056        toolbar.pack(fill="x", pady=(6, 0))
1057        tools = (
1058            ("Restore Defaults", self._restore_defaults,
1059             "Restore default parameters"),
1060            ("Open Program...", self._open_program,
1061             "Open a program from a .json file"),
1062            ("Save Program...", self._save_program,
1063             "Save the current program to a .json file"),
1064            ("Load to Device", self._upload_program,
1065             "Load the current program to the Pulse Pal device"),
1066        )
1067        for text, command, tooltip in tools:
1068            button = ttk.Button(toolbar, text=text, command=command)
1069            button.pack(side="left", padx=(0, 6))
1070            self._tooltip(button, tooltip)
1071
1072    def _build_output_panel(self):
1073        panel = ttk.LabelFrame(self._root, text="Output Channels")
1074        panel.pack(fill="x", padx=10, pady=(8, 0), ipady=4)
1075
1076        # The channel selector spans the first row of fields only, so
1077        # that the second row starts at the panel's left edge as it does
1078        # in the MATLAB GUI. Placing both rows beside the selector
1079        # instead indented the second one by the selector's width, which
1080        # widened the window and left the first row short of the right
1081        # edge, as a gap after the Loop checkbox.
1082        channels = ttk.LabelFrame(panel, text="Channel")
1083        channels.grid(row=0, column=0, padx=6, pady=4, sticky="nw")
1084        self._tooltip(channels, "Select an output channel to edit")
1085        self._output_channel_var = tk.IntVar(value=1)
1086        for index, channel in enumerate((1, 2, 3, 4)):
1087            ttk.Radiobutton(
1088                channels,
1089                text=str(channel),
1090                value=channel,
1091                variable=self._output_channel_var,
1092                command=self._refresh,
1093            ).grid(row=index // 2, column=index % 2, sticky="w", padx=2)
1094
1095        panel.columnconfigure(1, weight=1)
1096        top = ttk.Frame(panel)
1097        top.grid(row=0, column=1, sticky="ew", pady=2)
1098        column = 0
1099
1100        self._pulse_type_box = self._labeled(
1101            top,
1102            column,
1103            "Pulse Type",
1104            lambda parent: self._make_combobox(
1105                parent, self._PULSE_TYPES, self._on_pulse_type, width=11
1106            ),
1107            "Biphasic pulses add an interval at the resting voltage and "
1108            "then a second phase to each pulse",
1109        )
1110        column += 1
1111
1112        for name, label, tooltip in self._VOLTAGE_FIELDS:
1113            self._labeled(
1114                top,
1115                column,
1116                label,
1117                lambda parent, n=name: self._make_entry(parent, n),
1118                tooltip,
1119            )
1120            column += 1
1121
1122        train_ids = ["0 (None)"] + [
1123            str(i) for i in range(1, self._n_custom_trains + 1)
1124        ]
1125        self._custom_id_box = self._labeled(
1126            top,
1127            column,
1128            "Custom Train ID",
1129            lambda parent: self._make_combobox(
1130                parent, train_ids, self._on_custom_train_id, width=9
1131            ),
1132            "Custom pulse train to play on this output channel",
1133        )
1134        column += 1
1135
1136        self._custom_target_box = self._labeled(
1137            top,
1138            column,
1139            "Custom Train of",
1140            lambda parent: self._make_combobox(
1141                parent,
1142                self._CUSTOM_TRAIN_TARGETS,
1143                self._on_custom_train_target,
1144                width=9,
1145            ),
1146            "Custom train timestamps can indicate the onset of either each "
1147            "pulse, or each burst of pulses",
1148        )
1149        column += 1
1150
1151        self._custom_loop_var = tk.IntVar(value=0)
1152        self._custom_loop_check = self._labeled(
1153            top,
1154            column,
1155            "Loop",
1156            lambda parent: ttk.Checkbutton(
1157                parent,
1158                variable=self._custom_loop_var,
1159                command=self._on_custom_train_loop,
1160            ),
1161            "If enabled, the custom pulse train loops until the pulse train "
1162            "duration (Train (s) below)",
1163            center=True,
1164        )
1165
1166        # padx lines the first entry up with the selector's left edge,
1167        # allowing for the padding _labeled puts around each field
1168        bottom = ttk.Frame(panel)
1169        bottom.grid(row=1, column=0, columnspan=2, sticky="ew", padx=2)
1170        for column, (name, label, tooltip) in enumerate(self._TIME_FIELDS):
1171            self._labeled(
1172                bottom,
1173                column,
1174                label,
1175                lambda parent, n=name: self._make_entry(parent, n),
1176                tooltip,
1177            )
1178
1179        # Room left over once the fields have their natural widths is
1180        # divided evenly between the columns, rather than all of it
1181        # falling after the last field, which is how the MATLAB GUI
1182        # spaces the same two rows. The fields stay left aligned in
1183        # their columns, so the space opens up as wider gaps between
1184        # them.
1185        for row in (top, bottom):
1186            for index in range(row.grid_size()[0]):
1187                row.columnconfigure(index, weight=1)
1188
1189    def _build_trigger_panel(self):
1190        panel = ttk.LabelFrame(self._root, text="Trigger Channels")
1191        panel.pack(fill="x", padx=10, pady=(8, 0), ipady=4)
1192
1193        channels = ttk.LabelFrame(panel, text="Channel")
1194        channels.pack(side="left", padx=6, pady=4, anchor="n")
1195        self._tooltip(channels, "Select a trigger channel to edit")
1196        self._trigger_channel_var = tk.IntVar(value=1)
1197        for channel in (1, 2):
1198            ttk.Radiobutton(
1199                channels,
1200                text=str(channel),
1201                value=channel,
1202                variable=self._trigger_channel_var,
1203                command=self._refresh,
1204            ).grid(row=0, column=channel - 1, sticky="w", padx=2)
1205
1206        fields = ttk.Frame(panel)
1207        fields.pack(side="left", pady=2)
1208
1209        self._trigger_mode_box = self._labeled(
1210            fields,
1211            0,
1212            "Trigger Mode",
1213            lambda parent: self._make_combobox(
1214                parent, self._TRIGGER_MODES, self._on_trigger_mode, width=12
1215            ),
1216            "Normal: TTL during pulse train ignored. Toggle: TTL during "
1217            "pulse train stops train. Pulse Gated: Pulse train only runs "
1218            "while trigger is high",
1219        )
1220
1221        links = ttk.Frame(fields)
1222        links.grid(row=0, column=1, padx=(16, 4), sticky="w")
1223        ttk.Label(links, text="Link to outputs").pack(anchor="w")
1224        link_row = ttk.Frame(links)
1225        link_row.pack(anchor="w")
1226        self._link_vars = []
1227        for channel in range(1, 5):
1228            var = tk.IntVar(value=0)
1229            self._link_vars.append(var)
1230            check = ttk.Checkbutton(
1231                link_row,
1232                text=f"Ch{channel}",
1233                variable=var,
1234                command=lambda c=channel: self._on_trigger_link(c),
1235            )
1236            check.pack(side="left", padx=(0, 8))
1237            self._tooltip(check, f"Link trigger channel to output channel "
1238                            f"{channel}")
1239
1240    def _build_custom_train_panel(self):
1241        panel = ttk.LabelFrame(self._root, text="Custom Pulse Trains")
1242        panel.pack(fill="x", padx=10, pady=(8, 0), ipady=4)
1243
1244        selector = ttk.Frame(panel)
1245        selector.pack(side="left", padx=6, pady=4, anchor="n")
1246        ttk.Label(selector, text="Custom Train ID").pack(anchor="w")
1247        self._custom_train_list = tk.Listbox(
1248            selector,
1249            height=min(self._n_custom_trains, 4),
1250            width=6,
1251            exportselection=False,
1252            # A plain Tk border is always drawn black, so the colorable
1253            # focus ring is used as the border instead
1254            relief="flat",
1255            borderwidth=0,
1256            highlightthickness=1,
1257            highlightbackground=self._palette["border"],
1258            highlightcolor=self._palette["select_bg"],
1259            background=self._palette["field"],
1260            foreground=self._palette["fg"],
1261            disabledforeground=self._palette["disabled_fg"],
1262            selectbackground=self._palette["select_bg"],
1263            selectforeground=self._palette["select_fg"],
1264        )
1265        for train_id in range(1, self._n_custom_trains + 1):
1266            self._custom_train_list.insert("end", str(train_id))
1267        self._custom_train_list.selection_set(0)
1268        self._custom_train_list.bind(
1269            "<<ListboxSelect>>", self._on_custom_train_selected
1270        )
1271        # Fills the holder, whose width is set by the wider label above
1272        self._custom_train_list.pack(fill="x")
1273        self._tooltip(self._custom_train_list, "Select the custom train to "
1274                                               "program")
1275
1276        self._timestamp_text = self._make_train_text(
1277            panel,
1278            "Timestamps (s)",
1279            "Enter the onset time of each pulse in the custom pulse train "
1280            "(comma delimited, units = seconds)",
1281            self._commit_timestamps,
1282        )
1283        self._voltage_text = self._make_train_text(
1284            panel,
1285            "Voltages (V)",
1286            "Enter the voltage of each pulse in the custom pulse train "
1287            "(comma delimited, units = volts)",
1288            self._commit_voltages,
1289        )
1290
1291    def _build_status_bar(self):
1292        bar = ttk.Frame(self._root)
1293        bar.pack(fill="x", padx=10, pady=(6, 8))
1294
1295        info = self._device.info
1296        port_name = getattr(self._device.port, "port", "")
1297        ttk.Label(
1298            bar, text=f"HW: Pulse Pal v{info.hardware_version}"
1299        ).pack(side="left", padx=(0, 12))
1300        ttk.Label(
1301            bar, text=f"Firmware: v{info.firmware_version}"
1302        ).pack(side="left", padx=(0, 12))
1303        ttk.Label(bar, text=f"Port: {port_name}").pack(side="left")
1304
1305        self._status_var = tk.StringVar(value="Status: GUI Loaded")
1306        ttk.Label(
1307            bar,
1308            textvariable=self._status_var,
1309            font=self._label_font,
1310        ).pack(side="right")
1311
1312    def _tooltip(self, widget, text):
1313        """Attach a hover tooltip that follows the active theme."""
1314        return _ToolTip(widget, text, self._palette)
1315
1316    def _labeled(
1317        self, parent, column, label, widget_factory, tooltip=None,
1318        center=False,
1319    ):
1320        """Create a labeled widget in a grid column of parent.
1321
1322        The widget lines up with the left edge of its label, unless
1323        center is set, which centers a checkbutton's indicator under the
1324        label instead.
1325        """
1326        padding = self._scaled(self._FIELD_PADDING)
1327        holder = ttk.Frame(parent)
1328        holder.grid(row=0, column=column, padx=padding, pady=2, sticky="w")
1329        ttk.Label(holder, text=label).pack(anchor="w")
1330        widget = widget_factory(holder)
1331        if center:
1332            # The padding shifts the widget right by half of itself,
1333            # which centers the indicator rather than the checkbutton
1334            widget.pack(padx=(self._scaled(2 * self._INDICATOR_OFFSET), 0))
1335        else:
1336            # Widened to its label where the label is the longer of the
1337            # two, as the MATLAB GUI sizes the same fields. A field is
1338            # otherwise as wide as the characters asked of it, which
1339            # leaves labels such as "Custom Train ID" overhanging their
1340            # field by more the larger the desktop's UI font is. The
1341            # holder takes its width from the wider of the pair, so this
1342            # never widens the column.
1343            widget.pack(anchor="w", fill="x")
1344        if tooltip:
1345            self._tooltip(widget, tooltip)
1346        return widget
1347
1348    def _make_entry(self, parent, name):
1349        var = tk.StringVar()
1350        entry = ttk.Entry(parent, textvariable=var, width=10, justify="center")
1351        entry.bind("<Return>", lambda event, n=name: self._commit_entry(n))
1352        entry.bind("<FocusOut>", lambda event, n=name: self._commit_entry(n))
1353        self._entry_vars[name] = var
1354        self._entry_widgets[name] = entry
1355        return entry
1356
1357    def _make_combobox(self, parent, values, callback, width):
1358        box = ttk.Combobox(
1359            parent,
1360            values=list(values),
1361            state="readonly",
1362            width=width,
1363        )
1364        box.current(0)
1365        box.bind("<<ComboboxSelected>>", lambda event: callback())
1366        return box
1367
1368    def _make_train_text(self, parent, label, tooltip, commit):
1369        # The two boxes divide whatever width the panel has beyond the
1370        # train selector, which is what the wider Output Channels panel
1371        # above sets. Their requested width still sets the floor, so
1372        # sharing the spare room never widens the window.
1373        holder = ttk.Frame(parent)
1374        holder.pack(side="left", padx=6, pady=4, anchor="n", fill="x",
1375                    expand=True)
1376        ttk.Label(holder, text=label).pack(anchor="w")
1377        # The text and its scrollbar share a grid, so that the
1378        # scrollbar can leave the layout without the text shifting
1379        body = ttk.Frame(holder)
1380        body.pack(fill="x", expand=True)
1381        body.columnconfigure(0, weight=1)
1382
1383        text = tk.Text(
1384            body,
1385            width=self._TRAIN_TEXT_COLUMNS,
1386            height=self._TRAIN_TEXT_ROWS,
1387            wrap="word",
1388            # A plain Tk border is always drawn black, so the colorable
1389            # focus ring is used as the border instead
1390            relief="flat",
1391            borderwidth=0,
1392            highlightthickness=1,
1393            highlightbackground=self._palette["border"],
1394            highlightcolor=self._palette["select_bg"],
1395            background=self._palette["field"],
1396            foreground=self._palette["fg"],
1397            insertbackground=self._palette["fg"],
1398            selectbackground=self._palette["select_bg"],
1399            selectforeground=self._palette["select_fg"],
1400        )
1401        text.grid(row=0, column=0, sticky="nsew")
1402        text.bind("<FocusOut>", lambda event: commit())
1403        self._tooltip(text, tooltip)
1404
1405        scrollbar = ttk.Scrollbar(body, orient="vertical", command=text.yview)
1406        scrollbar.grid(row=0, column=1, sticky="ns")
1407        # Laid out and then withdrawn, so that _autoscroll can restore it
1408        # with the same grid options once there is something to scroll
1409        scrollbar.grid_remove()
1410        text.configure(
1411            yscrollcommand=lambda first, last: self._on_text_scrolled(
1412                scrollbar, text, first, last
1413            )
1414        )
1415        # Tk measures wrapped lines in the background, and reports a
1416        # complete view to the scroll callback until that pass finishes.
1417        # After a large insert, such as opening a program, the fractions
1418        # the callback is handed therefore say the text fits when it
1419        # does not. This event marks the end of the pass.
1420        text.bind(
1421            "<<WidgetViewSync>>",
1422            lambda event: self._sync_scrollbar(scrollbar, text),
1423            add="+",
1424        )
1425        return text
1426
1427    def _on_text_scrolled(self, scrollbar, text, first, last):
1428        """Track a text widget's view, and show its scrollbar as needed."""
1429        scrollbar.set(first, last)
1430        self._sync_scrollbar(scrollbar, text)
1431
1432    def _sync_scrollbar(self, scrollbar, text):
1433        """Show a scrollbar only while its text has rows out of view.
1434
1435        Tk leaves a scrollbar wherever it is put, whether or not the
1436        widget it drives has anything to scroll, so hiding it is left to
1437        the application, as MATLAB's edit boxes do. The view is read
1438        from the widget rather than taken from the scroll callback,
1439        whose fractions can predate the wrapped line measurements.
1440        """
1441        if self._closed:
1442            return
1443        first, last = text.yview()
1444        if first <= 0.0 and last >= 1.0:
1445            scrollbar.grid_remove()
1446        else:
1447            scrollbar.grid()
1448
1449    # ---- Refreshing the view ----
1450
1451    def _refresh(self):
1452        """Push the local parameter copy to the widgets."""
1453        self._loading = True
1454        try:
1455            channel = self._output_channel()
1456            index = channel - 1
1457
1458            self._pulse_type_box.current(
1459                int(self._params["is_biphasic"][index])
1460            )
1461            self._custom_id_box.current(
1462                int(self._params["custom_train_id"][index])
1463            )
1464            self._custom_target_box.current(
1465                int(self._params["custom_train_target"][index])
1466            )
1467            self._custom_loop_var.set(
1468                int(self._params["custom_train_loop"][index])
1469            )
1470
1471            for name in self._entry_vars:
1472                self._entry_vars[name].set(
1473                    _format_number(self._params[name][index])
1474                )
1475
1476            trigger_channel = self._trigger_channel()
1477            self._trigger_mode_box.current(
1478                int(self._trigger_mode[trigger_channel - 1])
1479            )
1480            link_param = f"link_trigger_channel{trigger_channel}"
1481            for output_index, var in enumerate(self._link_vars):
1482                var.set(int(self._params[link_param][output_index]))
1483        finally:
1484            self._loading = False
1485
1486        self._refresh_custom_train_view()
1487        self._update_enabled_state()
1488
1489    def _refresh_custom_train_view(self):
1490        train_index = self._selected_custom_train() - 1
1491        self._displayed_train = train_index
1492        self._set_text(
1493            self._timestamp_text, self._custom_timestamps[train_index]
1494        )
1495        self._set_text(self._voltage_text, self._custom_voltages[train_index])
1496
1497    def _update_enabled_state(self):
1498        index = self._output_channel() - 1
1499        is_biphasic = bool(self._params["is_biphasic"][index])
1500        for name in self._BIPHASIC_ONLY:
1501            self._entry_widgets[name].configure(
1502                state="normal" if is_biphasic else "disabled"
1503            )
1504
1505        uses_custom = int(self._params["custom_train_id"][index]) > 0
1506        self._custom_target_box.configure(
1507            state="readonly" if uses_custom else "disabled"
1508        )
1509        self._custom_loop_check.configure(
1510            state="normal" if uses_custom else "disabled"
1511        )
1512        self._custom_train_list.configure(
1513            state="normal" if uses_custom else "disabled"
1514        )
1515        for text in (self._timestamp_text, self._voltage_text):
1516            text.configure(
1517                state="normal" if uses_custom else "disabled",
1518                background=self._palette[
1519                    "field" if uses_custom else "disabled_field"
1520                ],
1521            )
1522
1523    def _set_text(self, widget, value):
1524        was_disabled = str(widget.cget("state")) == "disabled"
1525        if was_disabled:
1526            widget.configure(state="normal")
1527        widget.delete("1.0", "end")
1528        widget.insert("1.0", value)
1529        if was_disabled:
1530            widget.configure(state="disabled")
1531
1532    def _set_status(self, message):
1533        self._status_var.set(f"Status: {message}")
1534
1535    def _selected_custom_train(self):
1536        selection = self._custom_train_list.curselection()
1537        return (selection[0] + 1) if selection else 1
1538
1539    # ---- Parameter edit callbacks ----
1540
1541    def _commit_entry(self, name):
1542        if self._loading or self._closed:
1543            return
1544        index = self._output_channel() - 1
1545        var = self._entry_vars[name]
1546        label = self._field_labels[name]
1547        try:
1548            value = float(var.get())
1549        except ValueError:
1550            self._show_error(f"{label} must be a number.")
1551            var.set(_format_number(self._params[name][index]))
1552            return
1553
1554        low, high = self._FIELD_RANGES[name]
1555        if not low <= value <= high:
1556            self._show_error(
1557                f"{label} must be in range {_format_number(low)} to "
1558                f"{_format_number(high)}."
1559            )
1560            var.set(_format_number(self._params[name][index]))
1561            return
1562
1563        self._params[name][index] = value
1564        var.set(_format_number(value))
1565
1566    def _on_pulse_type(self):
1567        index = self._output_channel() - 1
1568        self._params["is_biphasic"][index] = self._pulse_type_box.current()
1569        self._update_enabled_state()
1570
1571    def _on_custom_train_id(self):
1572        index = self._output_channel() - 1
1573        self._params["custom_train_id"][index] = self._custom_id_box.current()
1574        self._update_enabled_state()
1575
1576    def _on_custom_train_target(self):
1577        index = self._output_channel() - 1
1578        self._params["custom_train_target"][index] = (
1579            self._custom_target_box.current()
1580        )
1581
1582    def _on_custom_train_loop(self):
1583        index = self._output_channel() - 1
1584        self._params["custom_train_loop"][index] = self._custom_loop_var.get()
1585
1586    def _on_trigger_mode(self):
1587        channel_index = self._trigger_channel() - 1
1588        self._trigger_mode[channel_index] = self._trigger_mode_box.current()
1589
1590    def _on_trigger_link(self, output_channel):
1591        link_param = f"link_trigger_channel{self._trigger_channel()}"
1592        self._params[link_param][output_channel - 1] = (
1593            self._link_vars[output_channel - 1].get()
1594        )
1595
1596    def _on_custom_train_selected(self, _event=None):
1597        # Both boxes are committed before the new train is loaded over
1598        # them, since this arrives before they lose focus
1599        self._commit_timestamps()
1600        self._commit_voltages()
1601        self._refresh_custom_train_view()
1602
1603    def _commit_timestamps(self):
1604        """Store the timestamps box against the train it is showing.
1605
1606        Not against the selected train: a click on the train list
1607        changes the selection, and loads the newly selected train into
1608        the boxes, before they are told they have lost focus. Committing
1609        to the selection at that point would file the edit under the
1610        train the user had just moved to. _on_custom_train_selected
1611        commits first, so that by the time the focus event arrives the
1612        boxes and this index agree and the commit is a no-op.
1613        """
1614        if self._closed:
1615            return
1616        text = self._timestamp_text.get("1.0", "end-1c")
1617        self._custom_timestamps[self._displayed_train] = text
1618        try:
1619            _parse_number_list(text)
1620        except ValueError:
1621            self._show_error(
1622                "Timestamps must be a comma-delimited list of pulse onset "
1623                "times, given in seconds."
1624            )
1625
1626    def _commit_voltages(self):
1627        """Store the voltages box against the train it is showing."""
1628        if self._closed:
1629            return
1630        text = self._voltage_text.get("1.0", "end-1c")
1631        self._custom_voltages[self._displayed_train] = text
1632        try:
1633            _parse_number_list(text)
1634        except ValueError:
1635            self._show_error(
1636                "Voltages must be a comma-delimited list of pulse voltages, "
1637                "given in volts."
1638            )
1639
1640    # ---- Toolbar actions ----
1641
1642    def _fire(self):
1643        channels = [
1644            channel
1645            for channel, var in enumerate(self._fire_vars, start=1)
1646            if var.get()
1647        ]
1648        device = self._device
1649        if not channels or device is None:
1650            return
1651        try:
1652            device.trigger(channels)
1653        except Exception as exc:
1654            self._show_error(f"Failed to trigger output channels:\n{exc}")
1655            return
1656        self._set_status("Output Channels Triggered")
1657
1658    def _restore_defaults(self):
1659        self._load_default_params()
1660        self._custom_timestamps = [""] * self._n_custom_trains
1661        self._custom_voltages = [""] * self._n_custom_trains
1662        self._reset_selections()
1663        self._refresh()
1664        self._set_status("Default Program Restored")
1665
1666    def _upload_program(self):
1667        device = self._device
1668        if device is None:
1669            return
1670
1671        self._store_train_boxes()
1672        custom_trains = self._collect_custom_trains()
1673        if custom_trains is None:
1674            return
1675
1676        for index in range(4):
1677            if (
1678                int(self._params["custom_train_target"][index]) == 1
1679                and float(self._params["burst_duration"][index]) == 0
1680            ):
1681                self._show_error(
1682                    f"Error in output channel {index + 1}: when custom train "
1683                    "times target burst onsets, a non-zero burst duration "
1684                    "must be defined."
1685                )
1686                return
1687
1688        try:
1689            for name, values in self._params.items():
1690                getattr(device, name)[1:5] = list(values)
1691            device.trigger_mode[1:3] = list(self._trigger_mode)
1692            device.sync_to_device()
1693            for train_id, times, voltages in custom_trains:
1694                device.send_custom_pulse_train(train_id, times, voltages)
1695        except Exception as exc:
1696            self._show_error(f"Failed to load the program to the device:\n"
1697                             f"{exc}")
1698            return
1699        self._set_status("Program Loaded to Device")
1700
1701    def _store_train_boxes(self):
1702        """File what the text boxes hold, without validating it.
1703
1704        The toolbar works from the stored copy of the custom trains, so
1705        anything typed since the boxes last lost focus has to be filed
1706        before it is read. Whether the toolbar waits for the boxes to
1707        lose focus first is up to how the platform orders a click on a
1708        button against the focus change it causes, which is not worth
1709        depending on. Validation is left to the caller, which reports
1710        what it finds in terms of the action the user asked for.
1711        """
1712        if self._closed:
1713            return
1714        index = self._displayed_train
1715        self._custom_timestamps[index] = self._timestamp_text.get(
1716            "1.0", "end-1c"
1717        )
1718        self._custom_voltages[index] = self._voltage_text.get("1.0", "end-1c")
1719
1720    def _collect_custom_trains(self):
1721        """Parse the custom train editor, returning None if it is invalid."""
1722        trains = []
1723        for train_id in range(1, self._n_custom_trains + 1):
1724            timestamp_text = self._custom_timestamps[train_id - 1]
1725            voltage_text = self._custom_voltages[train_id - 1]
1726            if not timestamp_text.strip() and not voltage_text.strip():
1727                continue
1728            try:
1729                times = _parse_number_list(timestamp_text)
1730                voltages = _parse_number_list(voltage_text)
1731            except ValueError:
1732                self._show_error(
1733                    f"Failed to load custom pulse train {train_id}: "
1734                    "timestamps and voltages must be comma-delimited lists "
1735                    "of numbers."
1736                )
1737                return None
1738            if len(times) != len(voltages):
1739                self._show_error(
1740                    f"Failed to load custom pulse train {train_id}: the "
1741                    "number of timestamps and voltages must match."
1742                )
1743                return None
1744            if times:
1745                trains.append((train_id, times, voltages))
1746        return trains
1747
1748    def _save_program(self):
1749        device = self._device
1750        if device is None:
1751            return
1752
1753        self._store_train_boxes()
1754        path = filedialog.asksaveasfilename(
1755            parent=self._root,
1756            title="Save program",
1757            defaultextension=".json",
1758            initialfile="PulsePalProgram.json",
1759            initialdir=self._last_program_dir or None,
1760            filetypes=(("Pulse Pal program", "*.json"), ("All files", "*.*")),
1761        )
1762        if not path:
1763            return
1764
1765        program = {
1766            "params": {
1767                name: list(values) for name, values in self._params.items()
1768            },
1769            "trigger_mode": list(self._trigger_mode),
1770            "custom_train_timestamps": list(self._custom_timestamps),
1771            "custom_train_voltages": list(self._custom_voltages),
1772            "device_info": dataclasses.asdict(device.info),
1773        }
1774        try:
1775            with open(path, "w", encoding="utf-8") as program_file:
1776                json.dump(program, program_file, indent=2)
1777        except OSError as exc:
1778            self._show_error(f"Failed to save the program:\n{exc}")
1779            return
1780
1781        self._last_program_dir = os.path.dirname(path)
1782        self._set_status("Program Saved")
1783        self.focus()
1784
1785    def _open_program(self):
1786        path = filedialog.askopenfilename(
1787            parent=self._root,
1788            title="Open program",
1789            initialdir=self._last_program_dir or None,
1790            filetypes=(("Pulse Pal program", "*.json"), ("All files", "*.*")),
1791        )
1792        if not path:
1793            return
1794
1795        try:
1796            with open(path, encoding="utf-8") as program_file:
1797                program = json.load(program_file)
1798            params = program["params"]
1799            new_params = {}
1800            for name, default in self._DEFAULT_OUTPUT_PARAMS.items():
1801                values = params.get(name, [default] * 4)
1802                if len(values) != 4:
1803                    raise ValueError(
1804                        f"{name} must have one value per output channel."
1805                    )
1806                new_params[name] = [float(value) for value in values]
1807            trigger_mode = [
1808                int(value) for value in program.get("trigger_mode", [0, 0])
1809            ]
1810            if len(trigger_mode) != 2:
1811                raise ValueError(
1812                    "trigger_mode must have one value per trigger channel."
1813                )
1814            timestamps = list(program.get("custom_train_timestamps", []))
1815            voltages = list(program.get("custom_train_voltages", []))
1816        except (OSError, ValueError, KeyError, TypeError) as exc:
1817            self._show_error(f"Failed to open the program:\n{exc}")
1818            return
1819
1820        self._params = new_params
1821        self._trigger_mode = trigger_mode
1822        self._custom_timestamps = self._fit_custom_trains(timestamps)
1823        self._custom_voltages = self._fit_custom_trains(voltages)
1824        self._reset_selections()
1825        self._refresh()
1826        self._last_program_dir = os.path.dirname(path)
1827        self._set_status("Program Opened")
1828        self.focus()
1829
1830    def _fit_custom_trains(self, values):
1831        """Coerce a saved custom train list to this device's train count."""
1832        fitted = [""] * self._n_custom_trains
1833        for index, value in enumerate(values[:self._n_custom_trains]):
1834            if isinstance(value, (list, tuple)):
1835                value = ", ".join(_format_number(item) for item in value)
1836            fitted[index] = str(value)
1837        return fitted
1838
1839    def _reset_selections(self):
1840        self._output_channel_var.set(1)
1841        self._trigger_channel_var.set(1)
1842        # A disabled listbox drops selection changes without complaint,
1843        # and this one is disabled whenever the output channel on show
1844        # plays no custom train, which is the default. Restoring
1845        # defaults or opening a program would then leave the list on the
1846        # train that happened to be selected. The state is put back as
1847        # it was, and _update_enabled_state settles it either way.
1848        state = str(self._custom_train_list.cget("state"))
1849        self._custom_train_list.configure(state="normal")
1850        self._custom_train_list.selection_clear(0, "end")
1851        self._custom_train_list.selection_set(0)
1852        self._custom_train_list.configure(state=state)
1853
1854    def _show_error(self, message):
1855        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)
431    def __init__(self, device, theme=None):
432        # The device is held weakly so that the GUI never keeps a released
433        # PulsePalDevice alive: the device's destructor closes this window.
434        self._device_ref = weakref.ref(device)
435        self._closed = False
436        self._release_host_event_loop = None
437        self._topmost_after_id = None
438
439        # Resolved before any window exists, so an invalid theme argument
440        # raises without leaving a half-built GUI behind
441        theme = _resolve_theme(theme)
442        self._theme = None
443        self._palette = {}
444        self._native_ttk_theme = None
445        self._indicator_element = None
446        self._indicator_images = {}
447        self._loading = True
448        self._last_program_dir = _default_program_dir()
449
450        n_trains = getattr(device.info, "n_custom_pulse_trains", None) or 2
451        self._n_custom_trains = int(n_trains)
452        self._custom_timestamps = [""] * self._n_custom_trains
453        self._custom_voltages = [""] * self._n_custom_trains
454        # The train the text boxes are showing, which is not always the
455        # one selected in the list: see _commit_timestamps
456        self._displayed_train = 0
457
458        self._params = {}
459        self._trigger_mode = []
460        self._load_default_params()
461
462        self._entry_vars = {}
463        self._entry_widgets = {}
464        self._field_labels = {
465            name: label
466            for name, label, _ in self._VOLTAGE_FIELDS + self._TIME_FIELDS
467        }
468
469        self._root = tk.Tk()
470        self._root.title("Pulse Pal Parameter Editor")
471        self._root.resizable(False, False)
472        self._root.protocol("WM_DELETE_WINDOW", self.close)
473        self._init_fonts()
474
475        # Applied before the widgets are built: several of them take their
476        # colors at construction time
477        self.set_theme(theme)
478
479        self._build_header()
480        self._build_output_panel()
481        self._build_trigger_panel()
482        self._build_custom_train_panel()
483        self._build_status_bar()
484
485        self._loading = False
486        self._refresh()
487        self._set_status("GUI Loaded")
is_closed
491    @property
492    def is_closed(self):
493        """True once the GUI window has been closed."""
494        return self._closed

True once the GUI window has been closed.

theme
502    @property
503    def theme(self):
504        """The active color theme, 'light' or 'dark'."""
505        return self._theme

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

def set_theme(self, theme):
507    def set_theme(self, theme):
508        """Switch the GUI between the light and dark color themes.
509
510        Args:
511            theme: ``"light"``, ``"dark"``, or ``None`` to match the
512                desktop theme.
513
514        Raises:
515            ValueError: If the theme name is not recognized.
516        """
517        name = _resolve_theme(theme)
518        if self._closed or name == self._theme:
519            return
520        self._theme = name
521        # Updated in place, since tooltips hold a reference to this dict
522        self._palette.clear()
523        self._palette.update(_PALETTES[name])
524        self._apply_theme_styles()
525        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):
758    def start(self, block=None):
759        """Show the GUI.
760
761        Args:
762            block: If True, run the Tk event loop until the window is closed.
763                If False, return immediately (the host application must pump
764                Tk events). If None, block only when the host does not
765                already provide a Tk event loop.
766        """
767        if self._closed:
768            return
769        if block is None:
770            block = not self._enable_host_event_loop()
771        self._bring_to_front()
772        if block:
773            try:
774                self._root.mainloop()
775            finally:
776                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):
778    def focus(self):
779        """Raise the GUI window and give it keyboard focus."""
780        self._bring_to_front()

Raise the GUI window and give it keyboard focus.

def close(self):
828    def close(self):
829        """Close the GUI window."""
830        if self._closed:
831            return
832        self._closed = True
833
834        device = self._device
835        self._device_ref = None
836        if device is not None and getattr(device, "_gui", None) is self:
837            device._gui = None
838
839        # Unregister before the window is destroyed, so that the host does
840        # not keep pumping events for a dead Tk interpreter
841        self._cancel_topmost_reset()
842
843        release = self._release_host_event_loop
844        self._release_host_event_loop = None
845        if release is not None:
846            try:
847                release()
848            except Exception:
849                pass
850
851        root = self._root
852        self._root = None
853        if root is not None:
854            try:
855                root.destroy()
856            except Exception:
857                # The interpreter may already be tearing down Tk
858                pass

Close the GUI window.