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