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