Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs-site/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
3 changes: 3 additions & 0 deletions docs-site/runner/runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
8 changes: 8 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,18 @@ 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:"
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."
7 changes: 7 additions & 0 deletions src/glider/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/glider/gui/dashboard/panels/run_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -86,6 +87,18 @@ 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).
# 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.
self.animal.setProperty("runHeaderAnimal", True)
top.addWidget(self.animal)
self._run_label = QLabel()
self._run_label.setProperty("textRole", "muted")
top.addWidget(self._run_label)
Expand Down Expand Up @@ -113,6 +126,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]:
Expand Down Expand Up @@ -221,6 +235,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)

Expand Down
41 changes: 25 additions & 16 deletions src/glider/gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -2365,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
Expand Down
20 changes: 19 additions & 1 deletion src/glider/gui/multi_camera/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions src/glider/gui/styles/touch.qss
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
76 changes: 76 additions & 0 deletions src/glider/gui/widgets/animal_picker.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading