Tabbed shell: splash, landing page, Experiment and Analyze tabs - #176
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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.
uvandffmpegwere invisible when GLIDER is launched from the Dock.A Dock/Finder launch inherits launchd's
PATH, not the shell's — measured onone machine, a single directory. Since
uv venvinstalls no pip, uv is theonly 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.
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.
landing page and its targets are on the Dashboard; the tour raised
QTabWidgetpages but not bare stacks. All seven targets hidden.create_main_windowcalledswitch_to_builder()straight past it. The window's own tests never caughtit because they construct
MainWindowdirectly.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.mdandshortcuts.mdwere already wrong onmain— stilldescribing 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
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.
480px. A fifth tab or a longer label will not fit.
mkdocs build --strictbefore publishing.