Skip to content

Tabbed shell: splash, landing page, Experiment and Analyze tabs - #176

Merged
gbradham merged 14 commits into
mainfrom
claude/glider-ui-splash-screen-d4c3df
Sep 9, 2026
Merged

gbradham merged 14 commits into
mainfrom
claude/glider-ui-splash-screen-d4c3df

Conversation

@gbradham

@gbradham gbradham commented Sep 9, 2026

Copy link
Copy Markdown
Member

Reshapes the shell around top-level tabs, gives experiment setup and the tool
windows a visible home, and fixes five bugs found by running the app rather
than by the suite.

What changed

A launch sequence. GLIDER opened straight onto an empty node graph
belonging to an unnamed, unsaved session, with no sign it was still starting
up. Now: a splash while plugin discovery, hardware enumeration and the vision
imports run, then a landing page — New Experiment, Open Experiment…, recent
files. The splash's five seconds are a floor, not a duration; initialisation
that runs longer keeps it up, and the window is not shown until it hands over.

Four tabs, centred: Experiment · Dashboard · Run · Analyze. New and Open
land on Experiment, because naming the experiment and entering its animals
comes before wiring anything up. The tabs share one chrome strip with the
experiment name, run-state pill and board dots — so those are on every tab now,
and they finally report in runner mode, where there was no Builder frame and
every refresh was being dropped.

Experiment setup is a page, not four modals. Experiment Settings, Zone
Configuration and Lab Setup were dialogs you had to know the name of to find,
and no two could be open at once. They are now sections — Metadata, Mice,
Zones, Lab Vocabulary, Plugins — each the same editor, embedded rather than
rewritten. Metadata and Mice share one editor underneath, so a subject added on
one and a protocol typed on the other reach the session through a single
object. The dialogs still work as dialogs for every other caller.

An Analyze tab for the tool windows. They stay windows — each wants a whole
screen — so the cards are front doors; what was missing was never the window,
it was any sign the window existed. A card also does what a greyed menu item
could not: for the three tools needing an optional stack, it prints the exact
install line.

Tools comes off the menu bar. This is the recorded rule being satisfied
rather than bent — a menu leaves the bar only once its actions have another
visible home
— and a test now fails if a tool is added to the menu without a
card, so it cannot quietly become palette-only again.

Icons are vendored Lucide (ISC, licence included),
recoloured per section. ISC was the deciding factor: no attribution obligation,
so nothing has to be tracked or re-checked later.

Bugs fixed

All five were found by running the app; none was caught by the suite.

  • Closing the Multi-Camera window left every camera streaming — capture
    threads ran and camera lights stayed on until the app quit. Streaming is
    claimed by name now, because the manager is shared with the camera panel's
    preview and an unconditional stop blacked out the other window. It declines
    to release while a recording is running. Reopening works again too: close
    stopped the poll timer and nothing restarted it.
  • uv and ffmpeg were invisible when GLIDER is launched from the Dock.
    A Dock/Finder launch inherits launchd's PATH, not the shell's — measured on
    one machine, a single directory. Since uv venv installs no pip, uv is the
    only installer on the documented setup, so plugins could not be installed
    at all on macOS
    unless GLIDER was started from a terminal. ffmpeg was
    missing on the same machines, silently, taking audio recording with it.
  • An externally-managed environment now says so, instead of surfacing
    PEP 668's text as "pip exited with code 1". The marker is deliberately
    ignored inside a venv, as the spec says and pip does.
  • The first-run walkthrough was spotlighting nothing. It starts on the
    landing page and its targets are on the Dashboard; the tour raised
    QTabWidget pages but not bare stacks. All seven targets hidden.
  • The landing page was unreachable at first — create_main_window called
    switch_to_builder() straight past it. The window's own tests never caught
    it because they construct MainWindow directly.

Docs

Six docs-site pages sent people to a Tools menu no longer on the bar. This is
the half of the previous revert that must not repeat: the CHANGELOG records
that taking Experiment and Tools off the bar broke five in-app strings "and
four pages of documentation with them". The guard that catches this class only
scans src/glider, which is why the docs slipped through again.

builder-window.md and shortcuts.md were already wrong on main — still
describing the four-menu bar from before that revert. Corrected here.

Testing

QT_QPA_PLATFORM=offscreen uv run --no-sync pytest -q -m "not slow" —
5121 passed, 3 skipped. ruff clean.

Driven by hand on macOS through the whole flow: splash → landing → New
Experiment → each Experiment section → each tab → Analyze cards.

Worth a look in review

  • Vocabulary. The rail says Mice; the data model, the dialogs, the
    saved session format and the group-box header on that very page all say
    subjects. Worth settling whether GLIDER is mouse-specific or stays generic.
  • The Analyze page heading says "Tools" while its tab says "Analyze".
  • Pi headroom. The tab bar's minimum width is 447px against the runner's
    480px. A fifth tab or a longer label will not fit.
  • The docs site is not built here (mkdocs lives in a separate env) — worth a
    mkdocs build --strict before publishing.

…n tab bar

GLIDER had two surfaces and a hidden menu toggle between them, opened onto an
unnamed unsaved session with no sign of what you were looking at, and kept
experiment setup in modal dialogs nobody could find. Three changes:

* **Splash.** The logo on a translucent window, up before plugin discovery,
  hardware enumeration and the vision imports, down when the window is ready or
  after five seconds -- whichever is *later*. The floor is a minimum, not a
  duration: initialisation that outruns it keeps the splash up rather than
  handing back the empty desktop it exists to cover.

* **Centred tab bar.** Dashboard (nodes, hardware, camera, properties),
  Experiment, Run (the 2x2 operator view). It sits above the page stack, not
  inside any page, and is wired in both directions -- View > Dashboard and the
  Pi layout action move the highlight without the bar calling back.

* **Landing page.** New / Open / Recent, shown when nothing is open and skipped
  when --file named something or the Pi boots into a run. The tab bar is hidden
  there, since the tabs would lead to an experiment that does not exist yet.

The Experiment tab embeds the existing editors rather than reimplementing them:
ExperimentDialog, ZoneDialog and LabSetupDialog each gained an embed() that
drops the window frame and the close buttons, and all three still work as
dialogs for every other caller. Sections build on first visit and are dropped
on New/Open, because they bind to the session and ZoneConfiguration those
replace -- a stale section is edits that appear to work and are discarded.

Fixes _toggle_view, which asked "am I on page 0?" to decide direction. That
stopped meaning "am I on the Builder?" once the stack grew past two pages: from
Landing it fell through and went to the Builder, so F11 appeared to do nothing.
…breathe

Both found by running the app, neither by the suite.

**The landing page was unreachable.** ``create_main_window`` applies the startup
view mode by calling ``switch_to_builder``, which ran immediately after the
window had chosen the landing page for itself -- so every launch went straight
to the Builder. The window's own tests never caught it because they construct
MainWindow directly and never come through ``__main__``. It now asks
``is_on_landing()`` first, and the regression test goes through
``create_main_window`` rather than around it.

**The embedded experiment form was stranded mid-page.** The macOS style
defaults QFormLayout to FieldsStayAtSizeHint with right-aligned labels, which
reads fine in the 600px dialog this was and badly on a tab as wide as the
window: labels marooned in the middle with 200px fields beside them. embed()
now sets the growth policy and label alignment explicitly, and caps the form at
900px so "grows to fill" does not mean a 2000px box for a protocol id on a
27-inch display.
Three things, all from watching it launch.

**The window appeared before the logo.** Two causes, both fixed.
``show_splash`` called ``show()`` and returned, but show() only *queues* the
expose -- and the caller's very next move is init_glider, which blocks this
thread for seconds. The splash's first paint therefore landed after everything
it was supposed to be covering. It now forces a round of event processing so it
is on screen before the blocking work starts. Separately, ``run_app`` showed
the main window and *then* started the splash's timer, so for the rest of the
floor the two sat on screen together. Revealing the window is now the hand-off
itself: ``finish_when_due`` shows, raises and activates it, and __main__ no
longer calls show() when there is a splash.

The first-run welcome moved behind the same hand-off (``on_shown``). It is
modal, and firing it at the old point put a dialog on screen belonging to a
window nobody had been shown yet.

**Experiment is now the first tab**, ahead of Dashboard and Run -- the order the
work actually happens in: name the experiment and enter its subjects, then wire
the graph, then run it.

**New / Open now land on Experiment rather than the Dashboard.** Arriving at a
node graph straight from *New Experiment* skips the step that makes the
recording identifiable afterwards, which is the step that gets forgotten.
**The experiment name, the tabs and the run state now share one strip.** They
were two rows only because StatusStrip lived inside AppShell -- the Dashboard
page -- while the tabs had to sit above the stack to survive a page switch.
That cost a second bar of chrome above every screen and, worse, left the
experiment name and run state invisible on two tabs out of three. The window
owns the strip now and hands it to AppShell, which keeps what was always its
own business: wiring the panel toggles to its panels. AppShell built with no
strip still builds and hosts its own, so every other use is unchanged.

The tabs are centred by overlaying them on the strip's own central gap in a
shared grid cell, not by a stretch between the strip's two groups. A stretch
would centre them in whatever space those groups left over, so the tab group
would slide sideways whenever the experiment name changed length.

Two consequences handled: the panel toggles hide off the Dashboard, where they
would collapse panels you cannot see; and _strip() reads the window's attribute
rather than the Builder frame's, so the strip finally reports in runner mode,
where there is no Builder frame and every refresh used to be dropped.

**Metadata and Mice are now separate rail sections.** They are edited at
different times by different people -- the protocol once when the experiment is
designed, the animal list again before every cohort -- and stacking them meant
scrolling past seven fields you were not there to change. Both are backed by
one ExperimentDialog via detach_sections(), so the two pages still reach the
session through a single object that knows about both.

**Every rail entry has a glyph**, painted by a QIconEngine that reads its
colour from the button's palette at paint time -- so an icon goes accent with
its label when selected, and four glyphs cost one class rather than eight SVGs.
Same pattern, and the same Qt pixmap() workaround, as the sidebar toggles.
Closing the window left every camera streaming: ``closeEvent`` stopped its own
poll timer and did nothing else, so the capture threads ran and the camera
lights stayed on until the app quit.

The fix is not a stop, because this manager is shared -- the camera panel's
multi-camera preview drives the same physical cameras through the same
MultiCameraManager, and an unconditional stop from either window blacks out the
other's live preview while it is still on screen. So streaming is claimed by
name now: ``start_all_streaming(owner=...)`` records a claim,
``stop_all_streaming(owner=...)`` releases one and stops only when the last
claim goes. ``owner=None`` still stops outright, which is the shutdown path and
the historical behaviour, so nothing that never claimed is affected. Only the
manager can see both consumers, which is why the count lives there rather than
being guessed at by each caller.

The window additionally declines to release while the recorder is running:
closing a monitor must never end a run, which is the reason it did nothing on
close in the first place.

Fixes a second bug in the same shape, found on the way: ``closeEvent`` stopped
the poll timer and nothing ever restarted it, so reopening the window -- it is
kept on the main window and reused -- showed dead tiles and a status table
frozen at whatever it last read. Setup moved into ``showEvent``, which makes
open and close symmetric; both calls are idempotent, so re-showing an already
open window costs nothing.
The rail glyphs were monochrome line drawings that took their colour from the
button, so all four were the same grey and went accent together on selection.
That makes them decoration: if every icon is the same colour you still read the
label to find the section, and the icons are just taking up space.

These are pictures instead -- a lilac card, a pink mouse, a mint arena, a peach
list -- each a filled pastel shape with its own detail on top. Colour is now
identity rather than state, which is the point: you find Mice by the pink mouse
without reading four labels.

That means Python names colours here, against the rule the rest of the GUI
follows, and the exception is deliberate: everything else on screen is a
control whose colour is a state, and desktop.qss owns those. The pairs live in
styles/colors.py so a re-theme still has one place to go. Still one QIconEngine
rather than four SVGs -- eight, with selected variants -- and still vector, so
they stay crisp at whatever DPI the panel asks for.
The pastel set was still four flat shapes with a mark on them -- a rectangle
with lines, a circle with a circle. Each one now has enough detail to be a
picture of the thing:

* Metadata is a record card: header band, clip tab, ruled lines of varying
  length ending short the way a paragraph does.
* Mice is a mouse in three-quarter profile -- ear with an inner ear, snout,
  nose, eye, whiskers, hind foot, curling tail.
* Zones is an arena from above: floor, inset wall, a filled centre zone, and an
  outlined corner zone. Outlined as well as tinted, because a tint alone is
  nearly invisible against the floor it sits on -- and an outline is what a
  zone actually is.
* Lab Vocabulary is an open book with curved leaves, a spine, ruled terms and a
  bookmark, rather than the bulleted list it was. A three-line list is what
  half the icons in any toolbar look like, and this section is a reference the
  lab writes once and consults afterwards.

Each is built from three tones now rather than two: the pastel fill, the deeper
detail, and a highlight derived from the fill. Two tones give a silhouette with
a mark on it; the third is what makes a shape read as lit and turns a diagram
into an illustration. The highlight is derived rather than named in colors.py
because it is not a decision -- it is the fill with more light on it, and four
more constants would be four more things to keep in step.

Rail icons go 16px -> 20px. These no longer degrade gracefully: below about
18px the ear, eye and whiskers stop resolving and the mouse is a pink blob. The
rail buttons are 36px tall, so the extra 4px costs nothing.
The plugin browser was a menu item, which is the worst place for it: nobody
opens Tools looking for something they have not been told exists, and plugins
are exactly the feature you cannot know to look for.

It is the only Tools entry that could move as-is -- a QDialog, so it embeds the
way the other sections did. The other four are QMainWindows that want a whole
screen (the multi-camera grid says as much in its own docstring) and are left
alone here.

The catalogue is fetched over the network, though, so this section cannot be a
widget that simply exists: PluginsSection is a host that is instantly
constructible, says it is loading, and swaps the browser in underneath itself
when the fetch lands -- or prints the reason it could not, on its own surface
rather than in a modal thrown over the tab the user just opened. A rig with no
route to the internet is a normal lab machine, not an edge case.

To support that, PluginManagerDialog.open_for is split: build_for constructs
without showing and raises rather than reporting, and open_for keeps the
QMessageBox it had. Each caller renders the failure in the form that suits it
-- a menu item that silently does nothing is the worst outcome for one, a modal
over an open page is the worst for the other -- and neither may swallow it.

The rail gains a fifth glyph, a jigsaw piece with both a tab and a socket:
a piece with only a tab is a shape with a bump, and the pair is what says
"this fits into something".
The four tool windows lived on the Tools menu, which is the worst place for
them: nobody opens a menu looking for something they have not been told exists,
and the complaint was never that a tool was bad, it was that nobody knew it was
there.

They stay windows -- each is a QMainWindow that wants a whole screen, and the
multi-camera grid says so in its own docstring -- so the Analyze tab is a set
of front doors rather than an attempt to inline them. What was missing was
never the window; it was any sign that the window existed.

A card can also do something the menu could not. Three of the five need an
optional dependency stack, and a greyed menu item with a tooltip is a dead end
you have to hover to read; the card prints the pip line instead, so "Behavior
Analysis is greyed out" stops being a support question. Cards route to the same
_open_* handlers the menu used rather than duplicating their lazy imports and
their already-open bookkeeping.

Tools then leaves the menu bar, which is the documented rule being satisfied
rather than bent: "a menu leaves the bar only when its actions already have
another *visible* home." The menu object stays, off the bar, so the command
palette keeps every entry -- the same treatment Hardware and Run already had.

Four guard tests caught real breakage from this and were fixed at the source
rather than weakened:

* Five strings across four files still said "Tools > Batch Pose Tracking" and
  "Tools > GPU / Device Check", sending people along a bar for a menu no longer
  on it. They now name the Analyze tab. This is exactly the class
  test_menu_paths_are_followable exists to catch, and it caught it.
* The named "menus with no other home" guard drops Tools, and gains
  test_the_analyze_tab_offers_every_tool in its place -- so a tool added to the
  menu without a card fails the suite instead of quietly making the menu
  palette-only again.
* The plugin-manager reachability test now looks for the Experiment tab's
  Plugins section rather than the Tools menu. Same question, moved answer.

The glyph engine moves to gui/widgets/pastel_glyphs.py now that two pages need
it, and gains five drawings: an ethogram, a keypoint skeleton, a play frame, a
camera grid with one tile live, and a processor die.
A plugin install failed with PEP 668's own wall of text, surfaced as "pip
exited with code 1" plus the raw output. Accurate, unreadable, and silent on
what to do about it.

installer_command now checks for the marker before choosing between pip and uv,
because it defeats both -- uv refuses the same environment for the same reason
pip does, so falling through to it would only produce a second wall of text.
The new ExternallyManagedError names the actual problem and gives the fix:
create a virtual environment and run GLIDER from it.

It subclasses NoInstallerError so every existing handler keeps catching it --
the caller's job is unchanged, which is to put the message on the plugin's row
-- but it is its own type because the two are not the same problem and do not
share a fix. "Install pip or uv" is useless advice to somebody whose Python is
refusing on principle.

The marker is deliberately ignored inside a virtual environment. That is what
PEP 668 specifies and what pip itself does, and it is the case that matters
here: GLIDER's documented setup is `uv venv`, and uv marks the base
interpreters it manages -- so a venv built from one is perfectly installable
while its base is not. Checking the marker without that test would refuse every
install GLIDER actually supports, which is why it has a test of its own.
Reported as "plugins cannot be installed"; the cause is that shutil.which
searches PATH, and an app launched from the Dock or Finder does not inherit the
shell's. It gets launchd's. Measured on the machine this was found on, a
GUI-launched GLIDER had a PATH of exactly one directory, and it was Flutter's.

Everything a lab installs lands outside that. uv's own installer writes to
~/.local/bin; Homebrew uses /opt/homebrew/bin or /usr/local/bin. So which()
answered "not installed" for tools that were installed -- and only when GLIDER
was started the way users actually start it, which is why it survived: from a
terminal, everything works.

Two things were broken by it, and the second is why this is a shared helper
rather than a patch to the installer:

* Plugins could not be installed at all on macOS. `uv venv` does not install
  pip, so on GLIDER's *documented* setup uv is the only installer there is --
  and it was invisible.
* ffmpeg went missing on the same machines, taking audio recording and the
  camera manager's ffmpeg paths with it, silently.

glider.core.executables.find_executable checks PATH first -- so a shell launch
and anything the user has deliberately put in front still win -- then the
directories a GUI launch misses. Deliberately not a PATH fix-up: rewriting the
process environment would change what every subprocess GLIDER launches can see,
to answer a question two modules ask.

nvidia-smi is left on shutil.which: it is Linux and Windows only, and installs
into directories that are on the default PATH there.

The audio recorder's availability test caught the change and was repointed
rather than relaxed -- it patched shutil.which, which had stopped being the
whole answer, and so was finding the developer's real ffmpeg through the new
fallback.
ISC is what decided it. It permits commercial use and redistribution with no
attribution obligation, so vendoring the files into a public repository carries
nothing that has to be tracked, credited, or re-checked if the project's own
licence ever changes -- which is not true of the "free" icon sets that are
actually CC BY. The licence text travels with the icons in their directory and
ships via package-data.

The drawings that were here are gone. They existed because a house set was the
only thing with no licensing tail; Lucide's are better, and maintaining an
icon set is a burden nobody asked for. Net deletion of about 250 lines of
QPainter paths.

Colour stays ours. Lucide ships monochrome `stroke="currentColor"`, which Qt's
SVG renderer does not resolve -- left alone it renders black, invisible on this
theme -- so each icon is recoloured to its pastel on the way to the painter.
That is still identity rather than state: the icon keeps its hue when its row is
selected while the label goes accent, because an icon that changed colour on
selection would throw away the thing that made it findable.

The light tone of each pair is used, not the deep one: these are 2px strokes on
a near-black ground, and the deeper halves -- which exist for filled shapes --
go muddy at that weight.

Shapes chosen for what they actually depict rather than by name: chart-gantt is
an ethogram's lanes, waypoints is a keypoint skeleton, rat is a rodent rather
than a computer mouse. Zones is square-dashed rather than scan -- a dashed
boundary is what a drawn zone looks like on the camera view, where scan's four
corner brackets are nearly empty at 20px.
…tch up

**The tour was pointing at nothing.** It starts on a first run, which now means
it starts on the landing page, and every one of its targets lives on the
Dashboard. `_raise_tabs` walked the ancestor chain and switched a
QStackedWidget owned by a QTabWidget, but skipped a bare one -- and the window's
pages are a bare stack. Measured on a fresh window: all seven targets hidden, so
each step rendered as a centred caption with nothing highlighted. A tour of
nothing, on the one path where a tour is the point. Fixed in the one place that
walks the chain, which also covers the Experiment tab's section stack.

**Six documentation pages sent people to a Tools menu that is not on the bar.**
This is the half of the previous revert that must not repeat: the CHANGELOG
records that taking Experiment and Tools off the bar broke five in-app strings
"and four pages of documentation with them". The in-app strings were fixed when
Tools moved; the docs were not, and the guard that catches this class only scans
src/glider.

While there: builder-window.md and shortcuts.md were *already* wrong on main --
they still described the four-menu bar from before that revert, claiming
Experiment was off it. Both corrected, along with the page's frame: it is the
Dashboard tab now, the strip is on every tab, and the panel toggles appear only
where there are panels to toggle.

CHANGELOG gets the twelve commits it was missing.
Windows CI only. `find_executable` was right and the test was wrong: Windows
resolves a bare name through PATHEXT, which is uppercase, so
`shutil.which("widget")` hands back `widget.EXE` for a file the fixture wrote as
`widget.exe`. Both name the same file -- the filesystem is case-insensitive --
so comparing the strings was comparing the registry's capitalisation rather
than the answer.

os.path.normcase on both sides. It is a no-op on POSIX, where the distinction
between those two names is real and worth keeping.
@gbradham
gbradham merged commit 7e83219 into main Sep 9, 2026
4 checks passed
@gbradham
gbradham deleted the claude/glider-ui-splash-screen-d4c3df branch September 9, 2026 15:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant