pulsepal.gui


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

Version of the program file format, written to each file as format_version. The MATLAB GUI reads and writes the same format: see "Program files" in /USING.md.

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

True once the GUI window has been closed.

theme
517    @property
518    def theme(self):
519        """The active color theme, 'light' or 'dark'."""
520        return self._theme

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

def set_theme(self, theme):
522    def set_theme(self, theme):
523        """Switch the GUI between the light and dark color themes.
524
525        Args:
526            theme: ``"light"``, ``"dark"``, or ``None`` to match the
527                desktop theme.
528
529        Raises:
530            ValueError: If the theme name is not recognized.
531        """
532        name = _resolve_theme(theme)
533        if self._closed or name == self._theme:
534            return
535        self._theme = name
536        # Updated in place, since tooltips hold a reference to this dict
537        self._palette.clear()
538        self._palette.update(_PALETTES[name])
539        self._apply_theme_styles()
540        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):
773    def start(self, block=None):
774        """Show the GUI.
775
776        Args:
777            block: If True, run the Tk event loop until the window is closed.
778                If False, return immediately (the host application must pump
779                Tk events). If None, block only when the host does not
780                already provide a Tk event loop.
781        """
782        if self._closed:
783            return
784        if block is None:
785            block = not self._enable_host_event_loop()
786        self._bring_to_front()
787        if block:
788            try:
789                self._root.mainloop()
790            finally:
791                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):
793    def focus(self):
794        """Raise the GUI window and give it keyboard focus."""
795        self._bring_to_front()

Raise the GUI window and give it keyboard focus.

def close(self):
843    def close(self):
844        """Close the GUI window."""
845        if self._closed:
846            return
847        self._closed = True
848
849        device = self._device
850        self._device_ref = None
851        if device is not None and getattr(device, "_gui", None) is self:
852            device._gui = None
853
854        # Unregister before the window is destroyed, so that the host does
855        # not keep pumping events for a dead Tk interpreter
856        self._cancel_topmost_reset()
857
858        release = self._release_host_event_loop
859        self._release_host_event_loop = None
860        if release is not None:
861            try:
862                release()
863            except Exception:
864                pass
865
866        root = self._root
867        self._root = None
868        if root is not None:
869            try:
870                root.destroy()
871            except Exception:
872                # The interpreter may already be tearing down Tk
873                pass

Close the GUI window.