From 65c3cb7105c17f960d61808a599c52678de9beda Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Sat, 24 Oct 2026 15:32:02 -0500 Subject: [PATCH 1/4] fix(gui): run an extra's install dialogs and restart outside the install task (#206) Opened inside the asyncio task, the result dialogs' nested event loop made qasync raise "Cannot enter into task", and Restart's close() left shutdown waiting out its 10 s timeout. The task now only installs; the follow-up runs on the next turn of the Qt loop. --- src/glider/gui/main_window.py | 40 +++++++++++-------- tests/unit/gui/shell/test_shell_navigation.py | 30 ++++++++++++++ 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/src/glider/gui/main_window.py b/src/glider/gui/main_window.py index 1fd54bd8..c55200f2 100644 --- a/src/glider/gui/main_window.py +++ b/src/glider/gui/main_window.py @@ -1111,25 +1111,33 @@ async def _install() -> None: extra, on_output=lambda line: page.set_install_status(extra, line[-120:], busy=True), ) - if not result.ok: - page.set_install_status(extra, f"Install failed: {result.message}", busy=False) - box = QMessageBox( - QMessageBox.Icon.Warning, "Install failed", result.message, parent=self - ) - box.setDetailedText(result.output[-8000:]) - box.exec() - return - page.set_install_status(extra, "Installed. Restart GLIDER to use it.", busy=True) - answer = QMessageBox.question( - self, - "Restart GLIDER?", - f"The {extra} extra is installed. GLIDER needs a restart to load it. Restart now?", - ) - if answer == QMessageBox.StandardButton.Yes: - self._restart() + # The dialogs run a nested event loop, and the restart closes the + # window; inside this task both deadlock qasync ("Cannot enter into + # task"), so they run on the next turn of the Qt loop instead. + QTimer.singleShot(0, lambda: self._after_install_extra(extra, result)) self._run_async(_install()) + def _after_install_extra(self, extra: str, result) -> None: + """Report an extra's install, and offer the restart if it worked.""" + page = self._analyze_page + if not result.ok: + page.set_install_status(extra, f"Install failed: {result.message}", busy=False) + box = QMessageBox( + QMessageBox.Icon.Warning, "Install failed", result.message, parent=self + ) + box.setDetailedText(result.output[-8000:]) + box.exec() + return + page.set_install_status(extra, "Installed. Restart GLIDER to use it.", busy=True) + answer = QMessageBox.question( + self, + "Restart GLIDER?", + f"The {extra} extra is installed. GLIDER needs a restart to load it. Restart now?", + ) + if answer == QMessageBox.StandardButton.Yes: + self._restart() + def _restart(self) -> None: """Close through the normal path (save prompt included), then relaunch. diff --git a/tests/unit/gui/shell/test_shell_navigation.py b/tests/unit/gui/shell/test_shell_navigation.py index 6a1dd64b..03b73d4d 100644 --- a/tests/unit/gui/shell/test_shell_navigation.py +++ b/tests/unit/gui/shell/test_shell_navigation.py @@ -419,3 +419,33 @@ def test_landing_title_has_no_subtitle_or_letter_spacing(qtbot, tmp_path): assert page.findChild(QLabel, "landingSubtitle") is None rule = load_stylesheet("desktop").split("QLabel#landingTitle", 1)[1].split("}", 1)[0] assert "letter-spacing" not in rule + + +def test_an_extra_install_reports_outside_its_task(qtbot, main_window_factory, monkeypatch): + """The result dialogs run a nested event loop and Restart closes the window. + Run inside the install task, both broke qasync ("Cannot enter into task") + and left shutdown waiting out its 10 s timeout (#206). The task must hand + them to the Qt loop instead of calling them.""" + import asyncio + + import glider.gui.main_window as main_window_module + import glider.plugins.installer as installer + + async def fake_install(extra, on_output=None): + return installer.InstallResult(ok=True, message="done") + + monkeypatch.setattr(installer, "install_extra", fake_install) + deferred = [] + monkeypatch.setattr(main_window_module.QTimer, "singleShot", lambda ms, fn: deferred.append(fn)) + window = main_window_factory(desktop_mode=True) + called = [] + monkeypatch.setattr(window, "_after_install_extra", lambda *args: called.append(args)) + tasks = [] + monkeypatch.setattr(window, "_run_async", tasks.append) + + window._on_install_extra("vision") + asyncio.run(tasks[0]) + + assert called == [] # nothing modal ran inside the task + deferred[-1]() + assert [(extra, result.ok) for extra, result in called] == [("vision", True)] From e78dbd76d398dcb94048d06cfbb52023354b1a8e Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Sat, 24 Oct 2026 15:38:24 -0500 Subject: [PATCH 2/4] feat(gui): assign a run to an animal from the Animals list (#215) An Animal picker in the run header (desktop and Pi) and the multi-camera window sets the session's active subject, which the data recorder and the tracking logger already write into their CSV headers. Multi-camera runs are video only, so there the animal's ID also joins the file prefix. --- docs-site/runner/runner.md | 3 + src/glider/gui/dashboard/panels/run_header.py | 12 +++ src/glider/gui/main_window.py | 1 + src/glider/gui/multi_camera/window.py | 20 ++++- src/glider/gui/styles/touch.qss | 7 ++ src/glider/gui/widgets/animal_picker.py | 76 +++++++++++++++++++ .../gui/dashboard/panels/test_run_header.py | 27 +++++++ .../gui/multi_camera/test_window_controls.py | 32 ++++++++ 8 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 src/glider/gui/widgets/animal_picker.py diff --git a/docs-site/runner/runner.md b/docs-site/runner/runner.md index 51189960..a1735c70 100644 --- a/docs-site/runner/runner.md +++ b/docs-site/runner/runner.md @@ -219,6 +219,9 @@ bottom: filled in. (The fields come from the experiment file you opened; there is no preset picker on the Pi, so choose or edit presets on the desktop.) The details lock while a run is live. +- **Animal.** Beside the pill, pick which animal from the experiment's + **Animals** list this run is of. Its details are written into the run's CSV + headers. The choice locks while a run is live. - **A status pill** at the top right, above the details: **NOT READY** (no board connected, or no runnable flow), **NEEDS DETAILS** (a required field is empty), or **READY**. An experiment with no boards configured (video only, diff --git a/src/glider/gui/dashboard/panels/run_header.py b/src/glider/gui/dashboard/panels/run_header.py index 3c2867cd..e9a90580 100644 --- a/src/glider/gui/dashboard/panels/run_header.py +++ b/src/glider/gui/dashboard/panels/run_header.py @@ -19,6 +19,7 @@ from glider.core import presets from glider.gui.dashboard.panels.field_tags import FieldTags, TagField +from glider.gui.widgets.animal_picker import AnimalPicker _LIVE = ("RUNNING", "PAUSED") # Non-live states the pill names as they are instead of READY / NEEDS DETAILS. @@ -86,6 +87,15 @@ def __init__(self, core, parent=None, *, touch: bool = False): self._picker.hide() # Takes the slack, so the title, ▾ and pill keep their size hints. top.addStretch() + # Which animal from Experiment > Animals this run is of (#215). + animal_label = QLabel("Animal") + animal_label.setProperty("textRole", "muted") + top.addWidget(animal_label) + self.animal = AnimalPicker(lambda: self._core.session) + # Styled to the pill's height on the Pi: a full 42 px touch combo pushed + # the run page past an 800x480 screen. Its popup rows stay full size. + self.animal.setProperty("runHeaderAnimal", True) + top.addWidget(self.animal) self._run_label = QLabel() self._run_label.setProperty("textRole", "muted") top.addWidget(self._run_label) @@ -113,6 +123,7 @@ def refresh(self) -> None: self._title.setVisible(not preset and not self._touch) self._picker.setText(f"{_type(md) or md.name} ▾" if preset else "▾") self._tags.set_fields(self.tag_fields()) + self.animal.refresh() self._apply_state() def tag_fields(self) -> list[TagField]: @@ -221,6 +232,7 @@ def _apply_state(self) -> None: self._pill.style().unpolish(self._pill) self._pill.style().polish(self._pill) self._picker.setEnabled(not live) + self.animal.setEnabled(not live) self._title.setReadOnly(live) self._tags.set_locked(live) diff --git a/src/glider/gui/main_window.py b/src/glider/gui/main_window.py index c55200f2..42ae0cd9 100644 --- a/src/glider/gui/main_window.py +++ b/src/glider/gui/main_window.py @@ -2373,6 +2373,7 @@ def _open_multi_camera(self) -> None: self._core.multi_camera_manager, recorder=self._core.multi_video_recorder, base_settings=self._core.camera_manager.settings, + session=lambda: self._core.session, parent=None, ) # Nothing to re-attach here: the window reconnects and re-enumerates in diff --git a/src/glider/gui/multi_camera/window.py b/src/glider/gui/multi_camera/window.py index de2bbf91..00307d58 100644 --- a/src/glider/gui/multi_camera/window.py +++ b/src/glider/gui/multi_camera/window.py @@ -40,6 +40,7 @@ from glider.gui.multi_camera.camera_selection import CameraSelection from glider.gui.multi_camera.status_table import CameraStatusTable, format_elapsed from glider.gui.styles import colors +from glider.gui.widgets.animal_picker import AnimalPicker from glider.gui.widgets.multi_camera_preview import MultiCameraPreviewWidget from glider.gui.widgets.tool_ui import Card, apply_tool_theme, hint, set_button_role from glider.vision.camera_manager import CameraSettings @@ -93,9 +94,13 @@ def __init__( experiment_name: str = DEFAULT_EXPERIMENT_NAME, labels: CameraLabels | None = None, selection: CameraSelection | None = None, + session=None, parent=None, ): + """``session``: a callable returning the experiment session, whose + Animals list the run can be assigned from (#215). None hides the picker.""" super().__init__(parent) + self._session = session self._manager = multi_camera_manager self._recorder = recorder # What the operator configured in the Settings dialog. Registering @@ -153,6 +158,14 @@ def _build_ui(self) -> None: self.name_edit.setMaximumWidth(220) controls.addWidget(self.name_edit) + # The animal from Experiment > Animals this run is of. Its ID joins the + # file prefix, since these recordings are video only (#215). + self.animal_picker = None + if self._session is not None: + controls.addWidget(QLabel("Animal")) + self.animal_picker = AnimalPicker(self._session) + controls.addWidget(self.animal_picker) + self.record_button = QPushButton("Record All") self.record_button.setToolTip( "Start recording every camera that is switched on and not already recording" @@ -511,7 +524,10 @@ def _experiment_name(self) -> str: writing files that begin with an underscore. """ typed = self.name_edit.text().strip() if hasattr(self, "name_edit") else "" - return typed or self._default_experiment_name or DEFAULT_EXPERIMENT_NAME + name = typed or self._default_experiment_name or DEFAULT_EXPERIMENT_NAME + animal = self.animal_picker.active_subject() if self.animal_picker else None + tag = animal and (animal.subject_id or animal.name) + return f"{name}_{tag}" if tag else name def _recording_ids(self) -> set[str]: """The cameras with a file open right now.""" @@ -722,6 +738,8 @@ def showEvent(self, event): # noqa: N802 - Qt override super().showEvent(event) self._timer.start(_POLL_MS) self.connect_cameras() + if self.animal_picker is not None: + self.animal_picker.refresh() def closeEvent(self, event): # noqa: N802 - Qt override """Stop polling and let the cameras go, unless a recording needs them. diff --git a/src/glider/gui/styles/touch.qss b/src/glider/gui/styles/touch.qss index 4d1a8305..1acfa65b 100644 --- a/src/glider/gui/styles/touch.qss +++ b/src/glider/gui/styles/touch.qss @@ -894,6 +894,13 @@ QLineEdit[fieldTagEditor="true"] { } /* READY / NOT READY / NEEDS DETAILS, and the live states. */ +/* The run header's animal picker sits on the pill's row; at the 42 px touch + * height it pushed the Pi run page past 480 px. */ +QComboBox[runHeaderAnimal="true"] { + min-height: 0; + padding: 2px 12px; +} + QLabel[runnerStatus="true"], QLabel[statusIndicator="true"] { background-color: #151c25; /* SURFACE_2 */ diff --git a/src/glider/gui/widgets/animal_picker.py b/src/glider/gui/widgets/animal_picker.py new file mode 100644 index 00000000..e51eed93 --- /dev/null +++ b/src/glider/gui/widgets/animal_picker.py @@ -0,0 +1,76 @@ +"""Pick which animal from the Experiment > Animals list a run is of. + +The choice is the session's ``active_subject_id``, which the data recorder and +the tracking logger already write into their CSV headers -- so picking here is +all a run needs to carry the animal. Shared by the run header (desktop and Pi) +and the multi-camera window, which is why it reads the session through a +callable: a New or Open replaces the session object under it. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from PyQt6.QtWidgets import QComboBox + +_NONE = "No animal" + + +def subject_label(subject) -> str: + """How an animal is named in a list: its ID, then its name and group.""" + parts = [subject.subject_id or subject.name or "Unnamed"] + if subject.name and subject.subject_id: + parts.append(subject.name) + if subject.group: + parts.append(f"({subject.group})") + return " ".join(parts) + + +class AnimalPicker(QComboBox): + """A combo of the session's animals, bound to its active subject.""" + + def __init__(self, session: Callable[[], object | None], parent=None) -> None: + super().__init__(parent) + self._session = session + self.setToolTip("The animal this run is of. Add animals in Experiment > Animals.") + self.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents) + self.currentIndexChanged.connect(self._on_chosen) + self.refresh() + + def _metadata(self): + session = self._session() + return getattr(session, "metadata", None) if session is not None else None + + def refresh(self) -> None: + """Rebuild from the session; the Animals list may have changed.""" + md = self._metadata() + subjects = list(getattr(md, "subjects", None) or []) + self.blockSignals(True) + self.clear() + self.addItem(_NONE, None) + for subject in subjects: + self.addItem(subject_label(subject), subject.id) + active = getattr(md, "active_subject_id", None) + self.setCurrentIndex(max(0, self.findData(active)) if active else 0) + self.blockSignals(False) + + def showPopup(self) -> None: # noqa: N802 - Qt override + # Animals added since the last refresh should be in the list. + self.refresh() + super().showPopup() + + def active_subject(self): + md = self._metadata() + return md.get_active_subject() if md is not None else None + + def _on_chosen(self, _index: int) -> None: + md = self._metadata() + if md is None: + return + chosen = self.currentData() + if chosen == md.active_subject_id: + return + md.active_subject_id = chosen + session = self._session() + if hasattr(session, "mark_dirty"): + session.mark_dirty() diff --git a/tests/unit/gui/dashboard/panels/test_run_header.py b/tests/unit/gui/dashboard/panels/test_run_header.py index ce70cc61..aa8d7aad 100644 --- a/tests/unit/gui/dashboard/panels/test_run_header.py +++ b/tests/unit/gui/dashboard/panels/test_run_header.py @@ -282,3 +282,30 @@ def test_desktop_header_keeps_title_picker_and_one_row(qtbot, mock_core): h = _header(qtbot, mock_core) assert h._title.isVisibleTo(h) and h._picker.isVisibleTo(h) assert isinstance(h._tags.layout(), QHBoxLayout) + + +def test_the_run_can_be_assigned_an_animal_from_the_animals_list(qtbot, mock_core): + """#215: the animal picked here is the session's active subject, which the + recorders already write into every CSV header.""" + from glider.core.experiment_session import Subject + + h = _header(qtbot, mock_core) + md = mock_core.session.metadata + first, second = Subject(subject_id="M001", group="Control"), Subject(subject_id="M002") + md.add_subject(first) + md.add_subject(second) + h.refresh() + + assert [h.animal.itemText(i) for i in range(h.animal.count())] == [ + "No animal", + "M001 (Control)", + "M002", + ] + assert h.animal.currentData() == first.id # the first animal starts active + + h.animal.setCurrentIndex(2) + assert md.get_active_subject() is second + assert mock_core.session.is_dirty + + h.update_state("RUNNING") + assert not h.animal.isEnabled() # a live run's animal is fixed diff --git a/tests/unit/gui/multi_camera/test_window_controls.py b/tests/unit/gui/multi_camera/test_window_controls.py index f1c61493..4a3179ce 100644 --- a/tests/unit/gui/multi_camera/test_window_controls.py +++ b/tests/unit/gui/multi_camera/test_window_controls.py @@ -240,3 +240,35 @@ def test_it_emits_a_copy_not_the_managers_own_settings(self, qtbot): panel._brightness.setValue(200) panel._emit_settings() assert original.brightness == 100 + + +class TestAnimalAssignment: + """#215: a multi-camera run can be of an animal from Experiment > Animals.""" + + def _window(self, qtbot, tmp_path, session): + win = MultiCameraWindow( + _manager(2), + recorder=_recorder(), + labels=CameraLabels(tmp_path / "labels.json"), + session=lambda: session, + parent=None, + ) + qtbot.addWidget(win) + return win + + def test_the_chosen_animal_names_the_files(self, qtbot, tmp_path): + from glider.core.experiment_session import ExperimentSession, Subject + + session = ExperimentSession() + dam = Subject(subject_id="D07") + session.metadata.add_subject(dam) + win = self._window(qtbot, tmp_path, session) + win.name_edit.setText("retrieval") + + assert win._experiment_name == "retrieval_D07" + win.animal_picker.setCurrentIndex(0) # "No animal" + assert win._experiment_name == "retrieval" + assert session.metadata.active_subject_id is None + + def test_without_a_session_there_is_no_picker(self, window): + assert window.animal_picker is None From abf845f84294226a0356193f473406d30cf9ff4f Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Sat, 24 Oct 2026 15:45:40 -0500 Subject: [PATCH 3/4] feat: glider shortcut, so GLIDER opens from Spotlight or the Start Menu (#216) Creates ~/Applications/GLIDER.app on macOS, a Start Menu shortcut on Windows and a .desktop entry on Linux, each launching the interpreter it was run from. install.sh and install.ps1 run it (not on the Pi, whose image ships its own launcher). --- docs-site/getting-started/installation.md | 15 +++ install.ps1 | 6 ++ install.sh | 8 ++ src/glider/__main__.py | 7 ++ src/glider/shortcut.py | 115 ++++++++++++++++++++++ tests/unit/test_shortcut.py | 45 +++++++++ 6 files changed, 196 insertions(+) create mode 100644 src/glider/shortcut.py create mode 100644 tests/unit/test_shortcut.py diff --git a/docs-site/getting-started/installation.md b/docs-site/getting-started/installation.md index 8484dacf..57e7b4fd 100644 --- a/docs-site/getting-started/installation.md +++ b/docs-site/getting-started/installation.md @@ -142,6 +142,21 @@ and Python version. `uv run glider` launches the app. The first time you run it, GLIDER offers a short interactive tour — take it or skip it. +### Open GLIDER by searching for it + +The install script adds GLIDER to your applications, so you can open it +from **Spotlight** (macOS), the **Start Menu** search (Windows) or your +desktop's app menu (Linux), like any other app. If you installed by hand, +or the step was skipped, add the launcher yourself from the GLIDER folder: + +```bash +uv run glider shortcut +``` + +On macOS this creates `GLIDER.app` in your home folder's **Applications**; +on Windows, a **GLIDER** Start Menu shortcut. Both start GLIDER from the +install they were made from. + !!! tip "Launch straight into Runner mode" Add the `--runner` flag to start in the touchscreen [Runner](../runner/index.md): `uv run glider --runner`. diff --git a/install.ps1 b/install.ps1 index d932a8fc..5aa83028 100644 --- a/install.ps1 +++ b/install.ps1 @@ -61,6 +61,11 @@ function Install-Glider([string[]]$Extra) { foreach ($name in $Extra) { $sync += @("--extra", $name) } Run uv @sync + # A Start Menu shortcut, so GLIDER can be opened by searching for it. + Say "Adding GLIDER to the Start Menu" + & uv run --directory $dir glider shortcut + if ($LASTEXITCODE -ne 0) { Write-Host "Could not add the shortcut; GLIDER still runs from the command below." } + Say "Checking the install" & uv run --directory $dir glider doctor if ($LASTEXITCODE -eq 0) { @@ -69,6 +74,7 @@ function Install-Glider([string[]]$Extra) { Say "Installed, but doctor found problems (above). Once they are fixed, start GLIDER with:" } Write-Host " cd $dir; uv run glider" + Write-Host "or search for GLIDER in the Start Menu." } Install-Glider $Extra diff --git a/install.sh b/install.sh index ba2a38df..6cfc5192 100755 --- a/install.sh +++ b/install.sh @@ -92,6 +92,13 @@ else GLIDER="uv run glider" fi +# A launcher where Spotlight (or the desktop's app menu) looks, so GLIDER can be +# opened by searching for it. Not on the Pi, whose image ships its own. +if ! $PI; then + say "Adding GLIDER to your applications" + $GLIDER shortcut || echo "Could not add the launcher; GLIDER still runs from the command below." +fi + say "Checking the install" if $GLIDER doctor; then say "Done. Start GLIDER with:" @@ -99,3 +106,4 @@ else say "Installed, but doctor found problems (above). Once they are fixed, start GLIDER with:" fi echo " cd $DIR && $GLIDER" +$PI || echo "or search for GLIDER in Spotlight / your app menu." diff --git a/src/glider/__main__.py b/src/glider/__main__.py index 389cecc5..580d29b9 100644 --- a/src/glider/__main__.py +++ b/src/glider/__main__.py @@ -10,6 +10,7 @@ python -m glider --runner # Force Runner (touch) mode python -m glider --file path # Open an experiment file python -m glider doctor # Check this install can run GLIDER + python -m glider shortcut # Make GLIDER openable from Spotlight / Start """ import os @@ -49,6 +50,12 @@ sys.exit(run_doctor()) +# `glider shortcut`: put a launcher where Spotlight / Windows Search look (#216). +if sys.argv[1:2] == ["shortcut"]: + from glider.shortcut import main as make_shortcut + + sys.exit(make_shortcut()) + import argparse import asyncio import logging diff --git a/src/glider/shortcut.py b/src/glider/shortcut.py new file mode 100644 index 00000000..9ab1ce04 --- /dev/null +++ b/src/glider/shortcut.py @@ -0,0 +1,115 @@ +"""``glider shortcut``: make GLIDER findable by name from the OS's own search. + +A source install (``install.sh`` / ``install.ps1``) used to end with "cd ~/glider +&& uv run glider", which nobody remembers a week later. This puts a launcher +where the OS search looks (#216): + +* macOS: ``~/Applications/GLIDER.app``, which Spotlight and Launchpad index. +* Windows: a Start Menu shortcut, which Windows Search finds. +* Linux: a ``.desktop`` entry in ``~/.local/share/applications``. + +Each launches *this* interpreter with ``-m glider``, so it starts the same +environment the command was run from. The packaged DMG and installer builds do +not need this: they are already apps. +""" + +from __future__ import annotations + +import os +import plistlib +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + +#: The repo's icons; present in a source checkout, which is what this is for. +_ICONS = Path(__file__).resolve().parents[2] / "packaging" / "icons" + + +def _mac_app(home: Path, python: Path) -> Path: + app = home / "Applications" / "GLIDER.app" + contents = app / "Contents" + (contents / "MacOS").mkdir(parents=True, exist_ok=True) + (contents / "Resources").mkdir(parents=True, exist_ok=True) + launcher = contents / "MacOS" / "GLIDER" + launcher.write_text(f'#!/bin/sh\nexec {shlex.quote(str(python))} -m glider "$@"\n') + launcher.chmod(0o755) + icon = _ICONS / "glider.icns" + if icon.exists(): + shutil.copyfile(icon, contents / "Resources" / "glider.icns") + with open(contents / "Info.plist", "wb") as f: + plistlib.dump( + { + "CFBundleName": "GLIDER", + "CFBundleDisplayName": "GLIDER", + "CFBundleExecutable": "GLIDER", + # Not the DMG build's identifier, so the two never collide. + "CFBundleIdentifier": "com.lainglab.glider.source", + "CFBundleIconFile": "glider.icns", + "CFBundlePackageType": "APPL", + "NSHighResolutionCapable": True, + }, + f, + ) + return app + + +def _windows_shortcut(python: Path, run=subprocess.run) -> Path: + programs = Path(os.environ["APPDATA"]) / "Microsoft" / "Windows" / "Start Menu" / "Programs" + link = programs / "GLIDER.lnk" + # pythonw: no console window behind the app. + pythonw = python.with_name("pythonw.exe") + target = pythonw if pythonw.exists() else python + # Values travel as environment variables, so no path needs PowerShell quoting. + env = dict( + os.environ, + GLIDER_LNK=str(link), + GLIDER_TARGET=str(target), + GLIDER_WORKDIR=str(python.parents[2]), # .venv\Scripts\python.exe -> checkout + GLIDER_ICON=str(_ICONS / "glider.ico"), + ) + script = ( + "$s = (New-Object -ComObject WScript.Shell).CreateShortcut($env:GLIDER_LNK);" + "$s.TargetPath = $env:GLIDER_TARGET; $s.Arguments = '-m glider';" + "$s.WorkingDirectory = $env:GLIDER_WORKDIR;" + "if (Test-Path $env:GLIDER_ICON) { $s.IconLocation = $env:GLIDER_ICON };" + "$s.Save()" + ) + run(["powershell", "-NoProfile", "-Command", script], env=env, check=True) + return link + + +def _linux_entry(home: Path, python: Path) -> Path: + entry = home / ".local" / "share" / "applications" / "glider.desktop" + entry.parent.mkdir(parents=True, exist_ok=True) + icon = _ICONS.parent / "linux" / "glider.png" + entry.write_text( + "[Desktop Entry]\nType=Application\nName=GLIDER\n" + "Comment=General Laboratory Interface for Design, Experimentation, and Recording\n" + f"Exec={shlex.quote(str(python))} -m glider\n" + f"Icon={icon if icon.exists() else 'glider'}\n" + "Terminal=false\nCategories=Science;Education;\nStartupWMClass=GLIDER\n" + ) + return entry + + +def create_shortcut(platform: str = sys.platform, home: Path | None = None, run=subprocess.run): + """Create the launcher for *platform*; return where it was put.""" + python = Path(sys.executable) + home = home or Path.home() + if platform == "darwin": + return _mac_app(home, python) + if platform == "win32": + return _windows_shortcut(python, run=run) + return _linux_entry(home, python) + + +def main() -> int: + try: + where = create_shortcut() + except (OSError, subprocess.CalledProcessError) as exc: + print(f"Could not create the GLIDER shortcut: {exc}", file=sys.stderr) + return 1 + print(f"GLIDER can now be opened by searching for it. Shortcut: {where}") + return 0 diff --git a/tests/unit/test_shortcut.py b/tests/unit/test_shortcut.py new file mode 100644 index 00000000..2d9da055 --- /dev/null +++ b/tests/unit/test_shortcut.py @@ -0,0 +1,45 @@ +"""`glider shortcut` puts a launcher where the OS search looks (#216).""" + +from __future__ import annotations + +import plistlib +import sys + +from glider.shortcut import create_shortcut + + +def test_macos_gets_an_app_in_the_users_applications(tmp_path): + app = create_shortcut("darwin", home=tmp_path) + + assert app == tmp_path / "Applications" / "GLIDER.app" + with open(app / "Contents" / "Info.plist", "rb") as f: + info = plistlib.load(f) + assert info["CFBundleExecutable"] == "GLIDER" + launcher = app / "Contents" / "MacOS" / "GLIDER" + assert launcher.stat().st_mode & 0o111 # executable + assert sys.executable in launcher.read_text() + assert "-m glider" in launcher.read_text() + + +def test_making_it_twice_just_refreshes_it(tmp_path): + create_shortcut("darwin", home=tmp_path) + create_shortcut("darwin", home=tmp_path) + + +def test_windows_gets_a_start_menu_shortcut_to_this_python(tmp_path, monkeypatch): + monkeypatch.setenv("APPDATA", str(tmp_path)) + calls = [] + link = create_shortcut("win32", run=lambda args, env, check: calls.append((args, env))) + + assert link == tmp_path / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "GLIDER.lnk" + args, env = calls[0] + assert args[0] == "powershell" and "CreateShortcut" in args[-1] + assert env["GLIDER_LNK"] == str(link) + assert "python" in env["GLIDER_TARGET"].lower() + + +def test_linux_gets_a_desktop_entry(tmp_path): + entry = create_shortcut("linux", home=tmp_path) + + assert entry == tmp_path / ".local" / "share" / "applications" / "glider.desktop" + assert "-m glider" in entry.read_text() From 7a78af51684496f39b8b7b1c5c2dc8b8a8a6ec3d Mon Sep 17 00:00:00 2001 From: Garrett Bradham Date: Sat, 24 Oct 2026 16:05:33 -0500 Subject: [PATCH 4/4] fix(ci): keep the Pi header inside 480 px on Windows; no exec bit there Windows' wider fonts put the Pi window 3 px over 480 with the Animal caption beside the picker; the Pi now shows the picker alone. The shortcut test only checks the launcher's executable bit on POSIX. --- src/glider/gui/dashboard/panels/run_header.py | 9 ++++++--- tests/unit/test_shortcut.py | 4 +++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/glider/gui/dashboard/panels/run_header.py b/src/glider/gui/dashboard/panels/run_header.py index e9a90580..5a27f6dd 100644 --- a/src/glider/gui/dashboard/panels/run_header.py +++ b/src/glider/gui/dashboard/panels/run_header.py @@ -88,9 +88,12 @@ def __init__(self, core, parent=None, *, touch: bool = False): # Takes the slack, so the title, ▾ and pill keep their size hints. top.addStretch() # Which animal from Experiment > Animals this run is of (#215). - animal_label = QLabel("Animal") - animal_label.setProperty("textRole", "muted") - top.addWidget(animal_label) + # No caption on the Pi: at 480 px portrait it pushed the window past the + # screen, and the combo's own text ("No animal" or the ID) says enough. + if not touch: + animal_label = QLabel("Animal") + animal_label.setProperty("textRole", "muted") + top.addWidget(animal_label) self.animal = AnimalPicker(lambda: self._core.session) # Styled to the pill's height on the Pi: a full 42 px touch combo pushed # the run page past an 800x480 screen. Its popup rows stay full size. diff --git a/tests/unit/test_shortcut.py b/tests/unit/test_shortcut.py index 2d9da055..1235a176 100644 --- a/tests/unit/test_shortcut.py +++ b/tests/unit/test_shortcut.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import plistlib import sys @@ -16,7 +17,8 @@ def test_macos_gets_an_app_in_the_users_applications(tmp_path): info = plistlib.load(f) assert info["CFBundleExecutable"] == "GLIDER" launcher = app / "Contents" / "MacOS" / "GLIDER" - assert launcher.stat().st_mode & 0o111 # executable + if os.name == "posix": # Windows files carry no executable bit + assert launcher.stat().st_mode & 0o111 assert sys.executable in launcher.read_text() assert "-m glider" in launcher.read_text()