PulsePalGUI


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


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

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

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

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

True once the GUI window has been closed.

theme
514    @property
515    def theme(self):
516        """The active color theme, 'light' or 'dark'."""
517        return self._theme

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

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

Raise the GUI window and give it keyboard focus.

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

Close the GUI window.