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)
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.
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")
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.
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'.
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", orNoneto match the desktop theme.
Raises:
- ValueError: If the theme name is not recognized.
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.
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.
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.