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