Frontends
The core is a library, not a service: a frontend opens it in its own process. There is no IPC to
speak, no schema to negotiate, and no second implementation to keep in step — a frontend either
links the Rust crate, or imports universe_core (the PyO3 module) and calls the same methods the
CLI calls. api.md is the full surface.
Two frontends ship. universe-ui (Universe Big Screen) lives in ui/: a PySide6 host around three
QML looks, the Universe look (a port of the Reprise Pegasus theme), the
Switch 2 HOME menu (qml/switch2/) and the PS5 home screen (qml/ps5/), each of the last two with
its README. universe-desktop (Universe Desktop) lives in
crates/universe-desktop/: a GTK 4 and libadwaita app for mouse and keyboard, § Universe Desktop.
Most of what follows is the Qt host’s contract and the parts of it that were expensive to get
right; another frontend owes none of it except the “Changes” section, which is a property of the
core: a Rust frontend takes it from universe::changes (api.md § Changes).
What QML sees
Section titled “What QML sees”One context property, api:
| Member | What it is |
|---|---|
api.keys |
is{Accept,Cancel,Details,Filters,PageUp,PageDown,PrevPage,NextPage,Menu}(event) — the Pegasus key-action contract (Backspace is a Cancel too) — plus isScreenUp / isScreenDown ([ / ], the right stick up and down): a screenful in a long list, and isFirst / isLast (Home / End): the ends of one; cancelHeld() fires when B (Escape, not Backspace) is held 450 ms on the window, whatever has the focus (an event filter, so the press still lands): every look opens its power menu on it; dropHold() from a question B just closed keeps the hold from asking again. mode is the last device used, pad, keyboard or mouse (the pad’s presses are told from the keyboard’s by gamepad.POSTED, which every posted key goes through; a finger on a touchscreen counts as the pad, and the mouse events Qt makes from an unhandled touch count as nothing; the cursor is shown under a mouse only), labels maps a pad glyph to its key (A → Enter), press / hold / release(action) post an action’s key as the pad would, for a click or the wheel, and layout is the physical keyboard’s rows for the on-screen ones (keyboard.rows over the core’s keyboard_layout()): name (fr, fr:bepo) and rows — AE (the number row), AD, AC, AB (the letter rows), each a list of {value, shift} left to right, dead keys left out, the digits leading the number row whichever level the layout keeps them on; us when libxkbcommon or the layout is missing. The Universe look’s ui/VirtualKeyboard.qml shows eleven keys of the first three rows and ten of the last, staggered, ⌫ closing the last; the Switch 2 ui/KeyPanel.qml eleven of each, the PS5 ps5/ui/KeyPanel.qml eleven of each over a row of wide keys (⇧, symbols, Space, ⌫, Done) |
api.allGames |
the library model |
api.collections |
collections, one per platform |
api.memory |
get/set/has/unset, persisted to $XDG_STATE_HOME/universe/ui-memory.json |
api.universe |
the client: every core call, plus the signals below. adoptScope() and pendingJournals() wrap adopt_scope and pending_journals; their failures get a log line, not a toast. recordings(id) is the client’s own: sessions(id) kept to the rows with a recording |
api.pad |
rightX: the right stick as a value, 0 without a controller |
api.focus |
whether the launcher has the desktop’s focus (see “Focus”): active (it has, or nobody can tell), elsewhere (another app has: neither the launcher nor its game), mode (always, host, qt), changed() |
api.power |
the batteries the kernel lists under /sys/class/power_supply: sources (kind system or pad, percent, charging, inputs — the pad’s evdev nodes), count; polled every 10 s, plus what the controller watcher reads off a pad the kernel keeps no supply for (an 8BitDo’s HID report, BlueZ’s Battery1 for a pad in BLE mode), reported through report(event, name, battery) and dropped with the pad; the kernel’s reading wins where both exist. The Universe look and the PS5 look draw them next to every clock (ui/PowerBadge.qml: one glyph and percent per source, the pad the controller page has current in green, one at 15 % or under in red); the Switch 2 look, like the console, puts the machine’s own battery by its clock as a bare cell and a pad’s charge on its controller page, next to the pad it belongs to; --fake reads fixtures/power_supply |
api.network |
the link the machine is online through, NetworkManager’s while the network watch runs and NM answers (api.screens.network follows it), else read from /sys/class/net every 10 s: kind (wifi, wired — a cable wins — or "" offline; links with no device, such as lo or a VPN, never count), bars (the Wi-Fi signal as 1–3 arcs, from NM’s percent or /proc/net/wireless, 0 otherwise). The Switch 2 look draws it between its clock and battery; --fake reads fixtures/net |
api.system |
what logind will do with the machine: actions, the ones of suspend, reboot and power_off it would carry out (the core’s power_actions(), read once at startup), run(action) (power(action) off the UI thread; reboot and power_off stop a running session first, so its session-end runs before the machine goes down), failed(action, message) when logind refuses. --fake records the call and does nothing. steam: the launcher runs in Steam’s Game Mode (the core’s under_steam()): no power actions (the menu keeps Quit Universe alone, and says Steam’s menu has the rest), no Sound section, no dock over a game, no MangoHud, frame limit or Pause on HOME rows. session: the launcher runs as the Universe session a display manager started (the core’s nest::session()): its way out logs out, so every look’s power menu says Log out for Quit Universe (action logout), and the PS5 Control Center’s Power panel adds a Log Out of its own. deck: lcd or oled on a Steam Deck. controls: the core’s system_controls(), read after apply_system() at startup, on reload() and as a session starts or ends (a game’s own [system] goes on and off with it); set(id, value) (the machine’s own), setFor(game, id, value) (the game’s own, the machine’s own for an empty game) and setAll(game, id, value) (the machine’s own, the game letting its own go) show the value at once and write it off the UI thread, a refusal raising controlFailed(id, message) and reading the machine back; control(id) one of them. The Universe look lists them under Settings › Performance and in the dock’s System group (a step writes once the cursor rests, 400 ms), Switch 2 under System Settings › Performance, the PS5 look under Settings › Performance and in the Control Center’s System panel; ui/Controls.js turns one into rows any look uses. network / bluetooth: NetworkManager lists a Wi-Fi card / BlueZ an adapter, outside Game Mode: the looks’ Network and Bluetooth pages show. --fake lists an OLED Deck’s under UNIVERSE_DECK, and plays Game Mode under GAMESCOPE_WAYLAND_DISPLAY with UNIVERSE_FAKE_STEAM=1, the Universe session with UNIVERSE_FAKE_SESSION=1 |
api.screens |
data for the added screens (settings, sources, media, the folder picker, the controller, the journals being written, a game’s sessions and their logs, a game’s data and the storage view, the changelog, Wi-Fi and Bluetooth) |
api.fullscreen |
whether the host runs fullscreen (the default; --windowed and --size turn it off) |
api.theme |
the looks: themes (id, name, entry, overlay, osd, frame, ground, accent, detail, unlocked, screenshot, installed, unavailable; the built-in ones, then the installed themes, see “Themes”), current, frame, ground and accent (the current look’s colours), set(id) (false for one that is unavailable), rescan(), failed(), escape(), takeNotice(), landing / takeLanding(), fontPath (the current look’s own font file, stored under <id>Font: switch2Font, ps5Font; “” while what it names is no file, a folder or a font since gone), soundsPath (a folder of WAVs, stored under <id>Sounds) and soundFiles ({name: url} of the WAVs in it, the name lowercased: each replaces the look’s bundled sound of that name, sound/SoundPool.qml’s overrides; Switch 2 offers it in Settings › Appearance), bootIntro (the startup animation’s switch, every look’s: on until ui-memory.json’s bootIntro is false) |
api.home |
the HOME button over a running game (see “HOME and the dock”): shown (game / launcher), underGame (the game is on screen over the launcher, inside gamescope), open, loading (a session this client launched has no window up yet), paused, pauseOnHome, pausable (false in the overlay alone: the dock and the Control Center drop Pause on HOME), overlayOnly (universe-ui --overlay), quitting (stop() was asked and the session has not ended yet), flipped, frame, nextFrame (the frame the last Guide press took, as soon as it lands), volumePercent, muted, outputs (loadOutputs() fills it); pressed(), stopping(title); openDock(), closeDock(), dockClosed(), toGame(), toLauncher(landing?) / takeLanding(), covered(), stop(), setPauseOnHome(on), screenshot() (→ screenshotGrabbed() at the grab, then screenshotTaken(path), and screenshotLost() when it fails after the grab), volume(change, value), setOutput(id), runtime() / setRuntime(key, value) (the running game’s HUD, frame limit and filter, for this session alone), launchChoices(key), setLaunchValueAll(key, value) (every game’s default, the game following it), screenRefresh(), escapeHeld() (HOME held 5 s) |
api.boot |
the startup animation (see “Startup animation”): running, started() (its first frame is on screen), skipped(), landed(skipped) (the ground opens: each look builds its home), land(), finish() |
A Game exposes id, title, sortTitle, favorite (writable), hidden, playTime,
playCount, lastPlayed, releaseYear, developerList, publisherList, genreList, players,
description, summary, source, platform, runner, runnerName, tags, extra, raw,
collections, and assets (boxFront, square, banner, background, logo, screenshotList), plus
launch(). version is the core’s (api.md § Games), installedVersion its installed, updateState how it
stands in words (empty while Universe can’t tell), updateAction the game page’s update action: update while its
store applies one, else empty, and updateMark the one mark a game page shows: update while upstream is ahead, empty
for every other status. No game page offers Mark as current: each look’s updates list does. api.updateGame(id) runs
the store’s update as Downloads’ job, told by a notice wherever the player is, or on a markable game not current
api.universe.markCurrent(id, title). Every look’s game page asks
api.universe.checkVersions(id) as it opens, and the host every game’s 5 s after the start (not in a --keys run).
api.screens.network and api.screens.bluetooth run on universe network watch --json and
universe bluetooth watch --json (api.md § Network and Bluetooth), each read once through
network() / bluetooth() first so a page fills before its watcher speaks. A page listing their
devices calls open() as it shows and close() as it goes: the card scans and the adapter searches
while one is open. network: available (NM and a Wi-Fi card), enabled, link, ssid,
connectivity, networks (ssid, strength, bars, security, secured, joinable — open,
OWE and WPA2/3 personal, or any a desktop saved — saved, active), connecting, error, checking;
needsPassword(ssid), join(ssid, password), forget(ssid), setEnabled(on), check(); signals
joined(ssid, connectivity), forgot(ssid), failed(ssid, reason, message, action) (action connect or forget; a join’s password asks for it again),
checked(connectivity). bluetooth: available, powered, discovering, paired, found
(address, name, kind, icon, paired, connected, battery), pairing; pair,
connectDevice, disconnectDevice, forget (an address each), setPowered(on); request, the
agent’s question waiting on the user (id, kind — confirm, authorize, passkey, pin,
display — address, name, deviceKind, code, entered, title, detail), answered with
answer(yes) or answerText(value), and null again once answered or withdrawn. Each look’s theme
puts it up over whatever page shows; a question that comes while a game holds the screen is refused
with a notice instead. The text sheets take secret (the Universe look’s prompt(…, "secret"), the consoles’
{secret: true}): the password shows as dots until Start (the Universe look) or R shows it. On the consoles’ sheets
B takes a letter back and, on an empty field, closes the sheet as X does. --fake plays
both from fake_radios.py: UNIVERSE_FAKE_NETWORK=offline starts off every network (the first-run
setup then opens on “Connect to the internet”), none has no NetworkManager,
UNIVERSE_FAKE_BLUETOOTH=none no adapter; every fixture network takes hunter22.
api.screens.recordings maps the session rows that carry a recording (sessions(id), see
api.md) to rows — session, path, url, size, sizeText, duration_s, durationText,
dateText, created_at, hasJournal, gameId, gameTitle — and samples 16 frames per recording
with ffmpeg into $XDG_CACHE_HOME/universe/frames/<sha1 of the path>/NN.jpg (universe::frames samples the same cache for a Rust frontend); the seeks are spread
over the row’s recording.duration_s (the media’s length the core probed when the file was filed),
or the session’s span for a line filed before the core kept lengths. Two extractions run at a time,
through VAAPI (a 4K AV1 frame takes 0.4 s and 180 MB against 1 s, 2.4 s of CPU and 630 MB in
software; the first frame that fails on the GPU turns the run to software, hardware). A row’s
thumbnail is queued when it is listed; select(session) puts that recording’s 16 frames first and
drops the frames another session was still waiting for, its thumbnails included in neither. The page
calls it on A, not as the cursor moves. frameMap[session] carries thumbnail, frames (""
until extracted), complete and duration, for the listed sessions only, built once per change.
api.screens.journal maps a game’s entries to rows — session, title, state, reason,
retry_at, retryText, started_at, dateText, duration_s, durationText, paragraphs,
blocks, next_up, images (file:// URLs of the paths the core hands out), hasRecording
(from the session rows), gameId, gameTitle — sorted by session id, last first. state is
written, pending (the module is still writing: no title, no paragraphs; the row pulses with the
time since started_at and cannot be opened), deferred (reason and retryText, the instant it
is owed another run), failed (reason is the module’s message, its one paragraph) or none —
a session the module never wrote for, which carries the session’s own date and duration and nothing
else, so an imported recording is one action away from an entry; the Universe look’s row reads that date over
that duration, and its pane “No entry yet”. write(gameId, session, rewrite)
hands it to the core (journal_write): A on a row that is not written, “Write the entry” / “Try
again now” / “Write it again” in its Start menu. durationText is the session’s length — 42 min,
1 h 05 — next to the date in the row and in the article header; the date is written_at, or
started_at while there is none.
api.screens.sessions maps a game’s played sessions (the rows with a unit; imports are left out)
to rows — session, started_at, dateText, durationText, end, endText (Quit, Stopped,
Crashed (exit 6), Killed), bad (crashed or killed), exit, hasRecording, hasJournal,
debugLog, live — newest first, from sessions(id) (load(id)), reloaded on libraryChanged
and currentSessionChanged; the running session leads as a live row with an empty session.
openLog(session) reads that session’s last 400 journal lines off the UI thread (session_log)
into log (time as a clock, source, message, error, warning) with logSession,
logLoading and logError beside it; closeLog() drops it. The Universe look’s pages/SessionsPage.qml
(the game menu’s Manage › Sessions and logs) lists the rows on the left and the
highlighted one’s log on the right, A scrolling the log; Switch 2’s pages/PlayLogPage.qml
(Software Options → Play Log) lists them and A pushes pages/SessionLogPage.qml.
api.screens.shots maps the player’s own screenshots to rows — name, path, url,
taken_at, dateText, session, hasJournal (a written or pending entry covers the session),
gameId, gameTitle — newest first, from screenshots(id) (load(id)) or screenshots("")
(loadAll()), reloaded on libraryChanged for the game it holds; remove(gameId, name) is
remove_screenshot. api.screens.media is every visible game’s shots, recordings and written
journal entries as one list (load(), rows, count, loading), newest first by when: the
core’s media("") (api.md), read and shaped on a worker thread, so rows lands after load()
returns and loading says so. kind is shot, recording or journal; thumb the shot’s or the
entry’s first picture’s thumbnail path (thumbReady when the core has made it), image a
recording’s cached frame (warmed through api.screens.recordings so the two share one cache);
url the shot itself, for the lightbox; title the recording’s length or the entry’s title;
gameId, gameTitle, dateText, session, name (a shot’s file name), path, hasJournal.
It follows libraryChanged, recordingFiled and entryWritten while loaded, one reload per burst
(unload() stops that), and announces rows again only when a recording’s frame landed.
api.screens.thumbs is the thumbnails on their way: url(path) is the file’s URL once it is there
("" before), and version bumps as they land (a poll while any is missing), so a card binds
source: (api.screens.thumbs.version, api.screens.thumbs.url(modelData.thumb)) and repaints in
place, no list reset. api.screens.shots rows carry the same thumb and thumbReady.
api.screens.pendingJournals is pending_journals() as rows and count, refreshed on
entryWritten, on sessionEnded and every 10 s while any is pending (so the elapsed time and
the module’s 30-min timeout show up); appeared(session, title) and
resolved(session, game, state, text) fire once per session and become the “Journal: writing …”,
“Journal: <title>” and “Journal failed: <reason>” toasts. Whenever nothing is being written it calls
sweep_journals(): the core starts the oldest owed entry, and the reply’s next arms a one-shot
timer (a minute at least, half an hour at most) so a session put off until the quota lifts is
picked up without anyone asking. That is the retry loop — one entry at a time, none while a game
runs, and the frontend is what keeps time.
The settings forms (api.screens.launch, runner, module, source, gameSettings, controller)
hand QML a flat rows list and groups that index it; a row’s advanced puts it in an advanced
group. groups holds the basic groups and — while showAdvanced is set (load clears it: a
page opens with Advanced hidden, however it was left), else only those holding a game or runner
value, so a marked card always shows its change — the advanced rows: an advanced group
titled like a basic one, or naming one as its home (HOMES: Scaling in Display, Environment in
Launch, Sync / Upscaling / Logs in the runner’s card, Artwork in Desktop and library), folds into it
after the group’s divider (an index into rows, −1 when nothing is folded), the card’s own
advanced rows first, then each homed card under a rule of its own (dividers: [{at, label}],
“Advanced”, “Advanced · Scaling”, “Sync”…); a group with no such card follows the basic ones as a
group of its own (basicGroups and advancedGroups are always there, for a look that lays them
out itself). hasAdvanced says whether there are any, the pages flip showAdvanced from Y, and
reveal(key, module) returns a row’s index and sets showAdvanced when the row sits behind it —
what a search hit lands on. The controller screen alone keeps a gate row (key: "advanced", an
action, its group wide: true). The launch, runner, game, module and source forms are the core’s
(client.form(kind, id, screen) over Core::form, see api.md): screens/settings.py only lays
the fields out and words them (field_row, entry_rows), and writes through setField and
promoteField (Core::set_field, Core::promote_field) — what a value is, where it comes from,
what a reset or a promotion does and which games’ rows show a module’s setting are the core’s
alone. The fake core (fake_core.py) builds the same forms from its fixture through
universe_core‘s internal _form_fields, _set_writes and _all_games_writes. A row’s field
is the key set_field takes (a module’s game setting keeps its own key and its module). On a
game’s or a runner’s page a row that can inherit carries origin: game (set on the game),
runner (set on the runner), global (config.toml sets it), default (nothing does) or found
(a runner’s program found on PATH); inherited says the value comes from elsewhere and changed
that the page sets it itself — the looks mark a changed row CHANGED and color its value, and name
where an inherited one comes from beside it, dim (originLabel: Global, Runner, Default, Found). A value
is overridden by changing it; X is reset(index) where resettable(row) says so (Field.resettable:
the page’s own value goes, the key leaves the file, so the row inherits again; a map’s entry goes
out of the map). Where promotable(row) says so (Field.promotable, Entry.promotable: a game’s
own value of a key with a global twin) promote(index) makes it the global value and clears the
game’s, which follows it from then on, other games keeping theirs: the Universe look’s More menu and Switch
2’s + list it as Apply to all Dolphin games (the row’s reach), PS5’s Options as Apply to All
Dolphin Games, the GTK dialog as a button beside the reset one.
On a game’s page a row with a global twin carries its reach (Field.reach, Dolphin games),
and setValueAll(index, value), toggleAll(index) and setMapEntryAll(index, name, value) write
the value for all those games instead of this one (Core::set_field_all): the global default
changes, the game’s own goes, games with their own keep theirs. After either, the form’s message
says what came of it (Wii controller is now Remote sideways for all Nintendo Wii games without their own), which the page shows as a toast.
A picker over an inheriting enum, int or toggle opens on a first choice that clears the
page’s own value (choiceValues[0] is empty), worded by where the value then comes from and what
it reads: Default · auto, Global · 60. A runner picker’s valueIcon is the picked runner’s logo, drawn by the value;
icons are the choices’. A path row under a pad opens the folder sheet (ui/PathSheet.qml,
Switch 2 FolderSheet, over api.screens.paths: the value’s nearest folder, Y to type the path
instead; Switch 2 shows the shortcuts as chips, the Universe look’s Start opens a menu of Use this folder,
Type a path… and Go to ›, the shortcuts, X and Y still doing the first two); under a keyboard or a mouse the Universe look opens the keyboard sheet
first, the value in the field, the folders one hop away (Start / F1, or the field’s Browse
button). A map key (launch.env, launch.dll_overrides) is one string row per
entry (launch.env.FOO, entry naming the map, on a game’s page origin per entry: the game’s
own or the global’s), then an action row with map: true, fields (["Variable", "Value"]) and
action: "Add"; setMapEntry(index, name, value) from either writes one entry, the name cleaned
to one key, an empty value removing it. api.screens.search is the settings index: the look hands it
its sections ([{id, label}]: the Universe look’s tab bar search from ui/Sections.js, the Switch 2 settings page its own), load() rebuilds the index off the UI thread from the same
row builders the forms use (build_launch, build_runner, build_page, build_game), setting
query recomputes results at once — rows with path, display, detail, tag, kind
(setting, section, game, gamekey, gamerow) and a target ({page, id, key, module})
the page opens; expand(index) unfolds a gamekey head into its games.
Themes
Section titled “Themes”main.qml is a window with one Loader whose source is api.theme.entry, so a theme is a root
QML file under qml/ and switching one for another rebuilds the tree in place: no restart, and
the new look opens on its own Settings › Appearance (set(id) leaves landing = "themes", which the
theme reads and takeLanding() clears). overlay.qml is the second window, the one gamescope
paints over the game; its Loader takes api.theme.overlay — the Universe look’s ui/Dock.qml, the PS5
look’s ps5/ui/ControlCenter.qml, nothing for the Switch 2 look, whose HOME goes straight to its HOME
menu. A second Loader takes api.theme.osd, the level a pad’s volume macro shows over the game for
a moment while the dock is closed (api.home.osd): the Universe look’s ui/VolumeOsd.qml, a card in its own
colours, Archivo and timings; the PS5 and Switch 2 looks share ui/VolumePill.qml. Each scales by the
window’s unit (its height over 1080) the overlay hands it, not Theme.dp, which only the Universe look’s root
drives. The choice lives in ui-memory.json (theme), --theme ID overrides it
for one run, and every look offers it in Settings › Appearance. A theme calls the same api and the same api.screens objects;
api.screens.album and api.screens.news are the recordings and journal lists across every
visible game (loadAll(), over sessions("")), which the Switch 2 look shows as its Album and News
and the PS5 look as its Media Gallery and Journal;
the Album lays api.screens.shots (loadAll()) on the same grid, newest first, a Show pick
narrowing it to screenshots or videos, A on a shot opening it full-screen (◀ ▶ step between shots).
Beside the three built-in looks, api.theme.themes lists the installed themes
(extensions.md): a folder under $XDG_DATA_HOME/universe/extensions/theme/<id>/
holding a theme.toml and a QML tree of its own whose root is theme.qml. themes.py reads them
through the core’s themes() at start and again on rescan(): after an add-on install, update or
removal, and whenever a look opens its Settings › Appearance. An installed theme’s row carries
installed, screenshot (the manifest’s picture as a file URL: the Universe look’s Theme menu draws it by the
name, the Switch 2 and PS5 rows in place of the swatch) and unavailable (why it cannot be picked:
an api this Universe does not read, or no theme.qml); set(id) refuses such a one and the looks
list it dimmed. It draws in the main window alone: its overlay is empty, so over a game a HOME
hold brings the launcher back (the host’s own toLauncher()) and a press does what the theme makes
of api.home’s pressed(), as Switch 2’s theme.qml does, its osd is the host’s ui/VolumePill.qml, its ground
black. A theme that fails to load (the Loader’s status turns Error; QML logs why) makes
main.qml call failed(), which puts the default look back, writes it to ui-memory.json and leaves
a notice that main.qml hands to Notices.fail once the default look has loaded (takeNotice()).
So do the current theme gone or unusable on a rescan(), and a remembered theme found unusable at
start, which leaves ui-memory.json without a theme. A theme that loads but leaves no way out (it
breaks once running, or offers no other look) is left by holding HOME on the pad for 5 s: the host
reads that hold (api.home’s escapeHeld()), not the theme, and escape() puts the default look
back with the same notice and writes it to ui-memory.json. Each look’s Settings › Appearance follows
the looks with a “Get more…” row (key: "more", addons: "theme") that lists the index’s themes,
as Features and Stores do (see “The launch and modules sections”). examples/theme/ is a minimal
theme, the one the tests install.
What a theme can rely on
Section titled “What a theme can rely on”A theme imports none of the built-in looks’ QML (qml/core, qml/ui, a look’s own folders): Qt’s
modules, its own files and api. Of api, the extension API a theme.toml names (api, 2 today,
see extensions.md) holds these, and an older theme keeps working while they only grow:
| Member | What a theme gets |
|---|---|
api.keys |
isAccept, isCancel, isDetails, isFilters, isPageUp, isPageDown, isPrevPage, isNextPage, isMenu, isScreenUp, isScreenDown, isFirst, isLast (each over a key event), cancelHeld(), mode |
api.allGames |
the library: count, get(row), byId(id), a modelData role; each Game as listed above, launch() included |
api.collections |
one per platform: id, name, games (count, get(row)) |
api.memory |
get, set, has, unset; a theme keeps its keys under its own id (<id>.…) |
api.theme |
themes, current, set(id), landing / takeLanding() (themes when a switch has just landed on it: it opens the looks it offers) |
api.home |
shown, underGame, toGame(), stop() |
api.power, api.network |
as above, read only |
api.system |
actions, run(action), steam, session |
api.boot |
running, landed(skipped) |
api.fullscreen |
as above |
The rest (api.screens, api.universe, api.focus, api.pad and the members not listed) is the
built-in looks’ own, and changes with them. A theme offers a way to the other looks, as each built-in
one does in Settings › Appearance: the sample’s Start lists api.theme.themes and A calls set(id).
Startup animation
Section titled “Startup animation”A fullscreen start opens on the Universe mark: ui/Boot.qml, in main.qml over the theme’s
Loader and outside it, so a theme switch never replays it. It lasts 1.9 s and covers no wait: the
app is ready in about 250 ms. On api.theme.ground, hairlines of one ink stream toward the viewer
faster and faster (assets/shaders/hairlines.frag, one full-screen ShaderEffect pass), then brake
at 900 ms: they shrink back to points and go out, the mark punches in (scale 1.22 to 1, OutBack;
qml/assets/brand/, the icon’s ring-back, planet and ring-front layers and the wordmark for a
light or a dark ground) and sends one thin ring out from its planet. At 1.35 s api.boot.land()
emits landed(false) and the ground opens: a hole edged by a thin circle grows from the centre past
the corners in 520 ms (a Shape, odd-even fill), and through it each look builds its home: PS5 runs
its back-from-a-game home.rebuild(), the Universe look brings in the backdrop, the page and the bars in turn
(backdropReveal, pageReveal, chromeReveal on its root), Switch 2 slides its row in and settles
its bars (rowReveal, chromeReveal). Each root blanks those parts in Component.onCompleted while
api.boot.running, and only then runs start(), its Themes landing, the first-run setup or what’s
new (see “What’s new”), which otherwise runs there at once.
Every part is a function of one t, the milliseconds since the first frame, so the hand-off and the
end hold their time through a stalled frame. Its colours come from the ground and the accent alone,
so any look, an installed theme’s included, recolours it: the ink is #101116 on a light ground
(HSL lightness over 0.5) and #f2f3f5 on a dark one, and accent (api.theme.accent) falls back
to the ink under 2.2:1 against the ground. Before the first tick every part is drawn once all but
transparent, so none builds its pipeline or uploads its texture mid-intro. Under the software
scenegraph (offscreen) the hairlines are left out; the mark, the ring and the opening still play.
hairlines.frag.qsb is rebuilt from hairlines.frag with
qsb --glsl "100 es,120,150" --hlsl 50 --msl 12 -o hairlines.frag.qsb hairlines.frag.
The animation starts on the window’s first swapped frame (started), not when QML loads, so a
compositor slow to map the window loses none of it. It plays the look’s boot.wav (each look’s
assets/sounds/generate.py synthesizes it on the intro’s beats: a rise under the warp, the hit on
the brake, a tail on the opening; quieter than the look’s other cues; api.theme.soundFiles.boot
from the look’s sound folder replaces it), unless the output is muted.
While running, the input is the intro’s: api.keys’ window filter swallows every key, click,
touch and wheel, and api.home the HOME button, so focus moves under the layer as usual but nothing
reaches the look. The first press skips it (skipped, then landed(true): each look shows its home
at once, no reveal), and its release, autorepeats included, is swallowed after running ends; a held
B starts no power-menu hold. finish() ends it once the layer is gone, and CAP_MS (4 s after the
first frame) ends it whatever the layer does.
host.boot_wanted decides whether a start plays it: fullscreen, not --fake or --keys, not a
restart in place after a wedge (UNIVERSE_UI_RESTARTS), no session running, not inside Steam’s
Game Mode (Steam played its own). So the tests (Api(boot=False) by default), just ui-shot and
just ui-record (--size, --keys) never see it. --boot and --no-boot force it on or off for
one run, the hook for a session chain that covers the machine’s boot itself (Plymouth hands over to
gamescope, whose first frame is the intro’s). Settings › Appearance › Startup animation (boot_intro,
api.theme.bootIntro) turns it off in every look, and wins over --boot.
Changes
Section titled “Changes”The core pushes nothing — the files are the truth, and anything may write them: the CLI, systemd’s
session-end, a hook. A frontend therefore derives its own change notifications — a Rust one takes
the file-derived ones (session start and end, library, media, journal) from universe::changes. CoreClient
(ui/universe_ui/universe_client.py) is one client over one core object — universe_core.Core
in production, FakeCore (fake_core.py, the same methods over fixtures/library.json, laying
out games/ and state/ under a temporary root and writing them the way
the core does) under --fake and in the tests — and its slots call the core’s methods directly.
What it derives is derived this way, and any frontend needs the equivalent:
| Signal | Derived from |
|---|---|
sessionStarted |
a successful launch |
sessionShown |
(session_id, ok): the game’s window is on screen and has the focus — wait_session_window on a host thread, waited again for as long as the session lives (a runtime download, a launcher before the game). Inside gamescope ok is true once gamescope shows the game’s window (a stand-in toplevel carrying the gamescope’s pid when no extension lists it); on the desktop, false when nobody can tell: no GNOME, no shell extension, or the session ended first |
sessionEnded |
(session_id, id, duration_s, end), end the session row’s (quit, stopped, crashed, killed): the current-session marker going empty — the state/ watch sees session-end remove it (debounced 300 ms), a 2 s poll stands behind it, since the game is a systemd unit, not a child. currentSessionChanged fires first; the pinned tile and the badge follow that property, and only the toast, the stats refresh and a pending launch follow the signal |
libraryChanged, mediaChanged, entryWritten, recordingFiled |
a QFileSystemWatcher on games/, games/<id>/{,journal,journal/attachments,media,media/picked,media/picked/screenshots,screenshots} and state/ (a pick made from the CLI shows up), debounced 300 ms; mediaChanged also follows a pick or its removal made through the client |
progress, jobFinished |
the job’s own callback — install, update, scan and media refresh run on a host thread; cancel(job) stops an install or update (SIGTERM, the job fails) or a media refresh (media_cancel, it ends ok after the game in hand), cancelled set on the job either way; jobResult(job) is what the finished work returned (a scan: how many games became the source’s), where jobFinished’s text is only for showing |
launched, launchFailed, error |
the call’s result |
A frontend decides who owns the game’s lifetime. adopt_scope(), called once at startup, moves the
frontend into universe-launcher-<pid>.scope; every game it launches from then on is bound to that
scope and goes down with the frontend, session-end included. host.py calls adoptScope() once
the client exists — not with --fake — so closing universe-ui closes the
game; a frontend that never calls it leaves the game to systemd, as universe play --no-wait and
the hooks do. The host also quits cleanly on SIGINT and SIGTERM (a wakeup-fd QSocketNotifier
lets the Python handlers run under the Qt loop) and, with a session running, calls stop("")
before api.shutdown(), so session-end has run before the scope goes. No confirmation is asked.
Two consequences worth knowing before you design around them. launch blocks on the pre-launch
hooks — up to the summed timeout_s of every blocking hook, 20 s by default — so it must not run
on the UI thread if you want the launch animation to keep moving. And long jobs die with the
process: closing the frontend mid-install interrupts it, by design.
Launch and the running view
Section titled “Launch and the running view”The launcher is gamescope’s base app (docs/api.md § Gamescope): fullscreen, host.py starts
gamescope around itself (universe_core.host_gamescope(""), a free function that reads the
config alone) before it opens the core or the app, so the library is loaded once, inside gamescope,
and no display is held outside it; --fake goes through its client instead. The outer process
waits on gamescope and exits with its code once the launcher inside has said it is up (it touches
the file UNIVERSE_HOST_READY names, after main.qml loads: gamescope closes inherited fds). A
gamescope that exits before that, or shows nothing within 30 s (a nested start that fails on
NVIDIA), leaves the launcher on the desktop, in the outer process. SIGTERM and SIGHUP are passed on
to gamescope. It skips gamescope when already inside one (GAMESCOPE_WAYLAND_DISPLAY), forces the xcb platform there, and every game lands on that
gamescope, which shows the most recently mapped window — the game’s, once it has one. The launcher
never lowers, raises or hides itself; api.home flips which window gamescope shows (toGame,
toLauncher: focus_session / focus_pid), and Api.screenName() is "" inside gamescope, where
the window’s screen is the Xwayland’s and not a connector. --windowed and --size skip gamescope
and keep the desktop path below, where the game gets a gamescope of its own and the extension
focuses windows.
launchGame raises ui/LaunchOverlay.qml over the page: the poster (ui/LaunchFrame.qml) fades
in over Theme.durLaunch as the page fades out, then grabs itself at the screen’s pixel size
(grabToImage, Game.launchWith(result): the QImage is written off the UI thread in universe splash’s format under $XDG_RUNTIME_DIR/universe/ and its path goes with launch; a grab that
fails launches without one). The poster — art, logo and title — then holds (waiting) until
sessionShown says the game’s window is up and focused: gamescope’s window maps over it within
about a second, showing that same grab from its keep-alive window (docs/api.md § Gamescope) until
the game’s own window, so the handover is poster over poster; the launcher’s poster fades out under
it. sessionShown with ok false (on the desktop: no GNOME,
no extension) holds 1500 ms instead; a session that ends before its window, or launchFailed,
ends the poster at once (a toast for the failure). Every key is swallowed while it runs, except
Cancel once the session is up: it drops the poster and leaves the game starting behind the
launcher. HOME once the session is up raises the dock over the poster, reduced to Home and Quit
(api.home.loading: the host sets it on sessionStarted and clears it on sessionShown or when
gamescope shows the game’s window); nothing freezes while it loads, whatever pause_on_home says.
Home from it is Cancel — the host lands home without a frame (toLauncher takes none from a game
that has painted nothing) and the theme drops the poster on flipped — and where there is no
overlay window (the desktop) HOME itself is that. A dock still up when the window shows grows into
the full one in place, the cursor back on Resume, and freezes the game then if pause_on_home asks.
From there the launcher is home again, with the game pinned first on the rail (RecentGames. playingId, played before or not; once its session ends it stays there, ranked by that end, with no
new layout: a view takes a hint-less layout change as a reset of every delegate) under a PLAYING
mark (PAUSED while frozen, QUITTING from stop() on, fading out with the session), its art the last
frame gamescope painted when HOME brought the launcher up (api.home.frame, nest_frame), its
hero’s first button reading “Resume”, and the tab bar’s badge “<title> · m:ss” on every tab; the badge is a
chrome slot past the glass and the power button: A resumes, Start opens the game menu. A resumes on the pinned game
wherever it is (api.home.toGame()), the game menu offers “Resume” and “Quit <title>” for it.
Play on another game asks “Quit X and start Y?” (ui/ConfirmDialog.qml, the pop-up list in the middle of the screen); yes stops the session,
and sessionEnded starts the pending launch. Every quit goes through api.home.stop(): the
launcher comes up first (a flip, when the game is on screen), the unit is stopped once it has,
stopping(title) is the theme’s cue for a “Quitting…” toast the end line replaces, and nothing freezes a game that is
on its way out. The game’s own exit brings the launcher back by
itself (gamescope shows what is left). A session already running when the host starts
(CoreClient tracks the marker at construction), or one the CLI started (the state/ watch), is
the same state: home, pinned, badge.
HOME and the dock
Section titled “HOME and the dock”The Guide button is HOME. It comes from the evdev watcher (button events with slot guide),
never from the SDL mapper — the game holds the focus, so no key would reach the launcher — and
api.home turns it into pressed(). The button is the launcher’s alone: no macro binds on the
guide slot (the core refuses one, macros_for ignores an old config’s, the Controller section
offers only Learn on its row), and a hold of controller.hold_ms from the game goes home
(toLauncher) whatever the press opened. What a press does is the theme’s: the Switch 2 look
flips to its HOME menu over the game (toLauncher) and back (toGame); the Universe look opens its
dock over the live game (openDock), a second press or B closes it, and from home a press
resumes; the PS5 look opens its Control Center the same way (the dock contract: open,
closeDock(), dockClosed() once its fade ends, loading for the reduced row), the hold going
home as the console’s does. With no session, the Universe look treats it as Start (the game menu). Held 5 s
(home.ESCAPE_MS), whatever the look, it raises escapeHeld(), on which api.theme.escape() leaves
an installed theme for the default look (see “Themes”); under a built-in look it does nothing.
HOME pressed while another app has the focus is not HOME: see “Focus”.
The dock is ui/Dock.qml in the overlay window: the game’s card at the left (its art, PLAYING or
PAUSED, the title), a row of round buttons at the right (row in Dock.qml), a group’s settings
in a card above its button — Resume, Home, Quit, then Game (drawn with the playing game’s square art, else its box front, the glyph
without either; Details, Journal, Recordings, Sessions), Screenshot,
Controllers (a row per pad of api.screens.controller.devices, its bus and charge as the value, “Reconnect” in its place on the
focused row (verb), A reconnect(id); then Find controllers, rescan(), and Controller settings, toLauncher("controller")), Achievements (while the game’s achievementsTotal is not 0 and its source’s achievements switch,
read through sourceSettingsOf on opening, is not off), Performance (MangoHud, FPS limit, Filter, Sharpness while the filter is FSR or NIS, the two
gamescope sharpens, and Pause on HOME) and Sound (Volume, where A mutes, and Output). A recording shows as REC by the
clock; what it records is the game’s Video capture settings. ◀ ▶ move
along the row or change the focused value, ▲ ▼ the rows of a card, A acts, flips or opens, B closes
the card or the dock, X takes a screenshot with the band faded out so the shell grabs the game alone.
▼ from the row raises ui/DockShots.qml over the whole frame: the playing game’s own screenshots
(api.screens.shots, load(id) on opening) on a ui/ShotGrid.qml — this session’s first (taken
since the session’s started_at: a running session is not in sessions.jsonl yet, so its shots
carry no session), a hairline, the rest — A a Lightbox, Y “Remove this screenshot?” through the
dock’s ConfirmDialog (Keep it focused, Trash the screenshot → shots.remove), B or ▲ past the top
row lowers it onto the dock. While the dock lists achievements, LB RB switch the tray between
Screenshots and Achievements, and the Achievements button opens it on the second: the list of
the launcher’s page (ui/AchievementList.qml) — n / m and a thin bar over one column of rows,
each with its description and its date once unlocked or its rarity while locked, the hidden ones
folded into one last row — over
api.screens.dockAchievements, a store of its own so the page left open in the launcher keeps its
game; ▲ ▼ move, ▲ past the first lowers it. A shot taken meanwhile lands through the screenshots watcher. The
Game card’s rows call toLauncher with their id (details, journal, recordings, sessions): the
launcher comes up as for Home, and the theme’s landHome takes the landing (takeLanding(), once)
and opens that page on the playing game over Home, the frame fading rather than shrinking into the tile.
The controller landing needs no game: the Universe look opens Settings › Controller, the PS5 look its Controllers page.
The shutter is the launcher’s, not the shell’s, so it is the same for the dock’s camera, a pad
macro and universe screenshot: at the grab, before the file is written, api.home plays
qml/assets/sounds/shutter.wav (loaded at start) and emits screenshotGrabbed(), then
screenshotTaken(path) once the file is there — the dock’s through its own call (the core’s
grabbed callback), the pad’s through the watcher’s two screenshot events
(api.screens.controller.screenshotGrabbed, screenshotTaken). overlay.qml, outside the theme’s
loader, answers the grab with a white flash, and the dock brings its band back then; the “saved”
toast and the shots’ refresh wait for the path. Inside gamescope api.home lifts the overlay window
to opaque for 450 ms around the flash when the dock is not already holding it up; on the desktop
there is no window over the game, so only the shutter is heard (see api.md § Screenshots). A failed
shot emits an empty path: the dock toasts it. One that fails after its shutter also emits
screenshotLost(), and overlay.qml says “Screenshot not saved” over the game (api.home holds the
window up for it) unless the dock is open.
Its volume row is the controller’s macro by another route (◀ ▶ volume("up" | "down"), ▶ unmuting, A "mute",
controller.volume_step per step, GNOME’s OSD through desktop::show_osd on every change but a
get). Muted, the row keeps the level, dimmed beside the mute glyph, as the PS5 Control Center’s
Volume row and the overlay’s OSD do. Its Output row steps through api.home.outputs and switches once the cursor has rested
500 ms (setOutput(id): a switch can change a card’s profile); the reply is the new sink’s level.
Quit asks, then api.home.stop().
The swap between the game and the launcher is gamescope’s, one cut, so the last frame it painted
bridges it: the Guide press from the game asks for it (nest_frame) and the flip — the hold, the
dock’s Home or Quit — reuses what the press took, or waits for it to land. Home asks for a new one
when the game ran on under the dock past FRESH_S; Quit takes the press’s however old. An open dock
stays up over the game until the swap, so the launcher comes up under it and nothing stands still in
between; Reprise’s ui/HomeFlip.qml decodes nextFrame while the dock is up, so the flip paints it
from the cache at once. A look registered with
frame: False (the Switch 2 and PS5 ones) paints nothing and waits for none; the PS5 look blanks
its home while the game is shown and builds it back up in beats when it returns. toLauncher then sets
frame ("" when none came) and shown; with a frame it waits for the theme’s covered() —
the Universe look’s ui/HomeFlip.qml paints the frame full screen and calls it once the image is up — before
focusLauncher (COVER_MS later regardless), without one it swaps at once; flipped says the
host did it (a game that exits by itself leaves the launcher on screen too, with nothing to zoom). The Universe look then lands on Home with the
cursor on the playing game (HomePage.landOnPlaying, every detail, sub page and search closed)
and shrinks the frame into that tile, which shows the same frame as its art: both ask for it at
Theme.frameSize, one decode. A quit lands on the cover instead (HomePage.playingCover): the tile
shows it from quitting on, and the box fades from the frame into it over the shrink’s last 40 %, so
the session ending seconds later changes no picture. A tile’s art is ui/CrossfadeImage.qml in its
opaque mode: a new picture fades in over the old one, never over the empty card. Resuming grows the
tile back to full screen (HomePage.playingTileRect) and only then toGame().
The host owns what the QML cannot: the overlay window is created once (create_overlay,
Home.attachOverlay) with STEAM_OVERLAY=1, mapped at opacity 0 and never unmapped — gamescope
keeps painting an unmapped overlay’s last buffer; opening sets STEAM_INPUT_FOCUS=1 and full
opacity, the fade-out done (dockClosed()) drops both and the game gets its input back. The
watcher’s macros are suspended while the dock has the pad (suspend {dock: true}: volume, mute
and the MangoHud toggle still fire, nothing that grabs the screen, types or ends the game). Nothing takes the pad away from the
game — gamescope routes keyboard and mouse only, and the game keeps its own evdev or hidraw
readers — so pause_on_home (a launch key, global and per game, on by default) freezes the game
whenever the launcher covers it: as the dock opens, and as toLauncher flips (after the frame is
taken: a frozen game paints nothing); dockClosed and toGame thaw it — on the Guide release
when that is what closed the dock, so the game never sees Guide held. The recording pauses and
resumes with the freeze (the capture module’s freeze / thaw hooks), so menu time never lands
in the file. Off, the game runs on behind the
launcher and answers every press the menu gets; turning it on from the dock while the launcher
is up freezes at once. The session ending drops the overlay whatever state it was in. Without an
overlay window (the launcher on the desktop) openDock is toLauncher, and only toGame
thaws: a game window raised from the desktop’s own switcher stays frozen until Resume.
A game started from Universe Desktop (or universe play) in a gamescope of its own gets the
overlay alone: the core’s universe overlay unit, bound to the game’s, runs universe-ui --overlay <file> (see api.md), and universe splash writes that gamescope’s display to the file. The host waits
for it (join_display, up to 30 s), joins that display and loads overlay.qml and nothing else,
with api.home.overlayOnly set: no main window, no scope adopted, no radios or version checks,
the pad thread and its watcher as usual. With no launcher under the game, overlay.qml answers
pressed() itself (the dock opens and closes; a look without one, Switch 2’s, calls
toLauncher). toLauncher, whatever its landing, closes the dock and raises the window of the
app that launched, when it has one (summonApp(), the core’s summon_app; universe play’s has
none); stop() stops the game where it is;
no frame is taken. FreezeUnit on the game’s unit would freeze the gamescope painting the dock,
so pausable is false and nothing pauses. The process quits on sessionEnded, and its own end
stops no game.
A frozen game reads no key, so what the dock asks of the game’s MangoHud types none. The
MangoHud, FPS limit, Filter and Sharpness rows change the running game alone: they read
api.home.runtime() on opening (the core’s runtime(), which the pad’s MangoHud macro and the GTK
app’s now-playing panel change too) and step through setRuntime(key, value), never the game’s or
every game’s settings, so the next launch starts from those again. The MangoHud row calls
set_mangohud, which tells the HUD itself, so it lands frozen or not. The FPS limit row calls
set_fps_limit(value), which rewrites the layer’s conf: MangoHud rereads it by itself, a frozen
game on the thaw. The Filter and Sharpness rows hand both to the launcher’s gamescope
(nest_filter); Default on Sharpness removes the card, so gamescope’s own default (2) is back.
Those are what reach a running game: the rest of the launch form — the runner, Proton, the
resolution, the Launch section — is baked into the process or the gamescope it started in, so the
dock does not offer them.
Pause on HOME is the playing game’s own setting: under it the card says so,
For Mirror's Edge only, beside a Y for All games. Y gives the row’s value to every game
(setLaunchValueAll(key, value) → Core::set_field_all): it becomes the global default, the game’s
own value goes so it follows it, games with their own keep theirs, and a notice says so. The game’s
value is the same either way, so nothing is applied again. The PS5 Control Center’s Pause on
HOME does the same on △; its Performance panel, like the dock’s, changes the running game alone.
The System group’s rows (a Steam Deck’s brightness, refresh, power limit, GPU clock and fan) are
the game’s own the same way: a step lands in the game’s game.toml [system]
(api.system.setFor(game, id, value) → set_system_for), on at once, put back by each of its
launches and taken off by its session’s end, which returns the machine to its own value. Y makes
the row’s value the machine’s own (setAll → set_system_all): the game lets its own go, and the
value stays after the game, for every game without its own. The Control Center’s System panel does
the same on △. Settings › Performance (in every look) set the machine’s own value.
The launcher acts on the pad only while it has the desktop’s focus. Inside its own nested
gamescope (the default fullscreen mode) Qt never learns that the focus left: gamescope passes no
focus change on to its clients. So api.focus (universe_ui/focus.py) asks the desktop every
500 ms through the core’s host_focus() (api.md § Desktop): the focused toplevel is the
launcher’s when its pid is this process’s or the gamescope’s it runs in, its game’s when the pid
runs in the session’s unit. On the desktop (--windowed, or no gamescope) it follows Qt’s
application state, and asks the desktop only while Qt says another window has the focus, to tell
the launcher’s game from another app. On a screen of its own (UNIVERSE_OWN_GAMESCOPE=drm), under
Steam’s Game Mode and offscreen nothing else can have the focus: always focused. Where the desktop
cannot tell (GNOME without the extension, Cinnamon on Wayland, no window list, an error; the reason
logged once) the launcher behaves as if focused, as before.
While another app has the focus (active false):
- the pad thread reads nothing (
GamepadThread.setCovered, as under a game: held keys are released, the right stick is centred, a press already on its way is dropped with its release); - B held as the focus leaves opens no power menu (
keys.dropHold()); - the Controller section’s learn, walk and test stop (
focusLost()); Theme.coveredholds every loop and clock, and a playing recording pauses (the Universe look’s Recordings, the PS5 and Switch 2 players), staying paused when the focus comes back;- HOME from that app brings the launcher up and does nothing more:
summon()activates its window (gamescope’s when nested) and asks the desktop again. Withcontroller.home_summonsoff (the Controller section’s “HOME opens Universe”, under Advanced; the GTK app’s Preferences › Controller) the press is dropped. HOME from the launcher or from its own game is HOME as before, and so is HOME where the desktop cannot tell.
Pad macros (screenshot, volume, MangoHud) fire whatever has the focus: the watcher reads the pads
for the whole system. A focus change is seen within a poll; on KDE the core’s answer comes from a
script left running in KWin, which pushes each change. UNIVERSE_UI_INPUT_LOG logs every change,
every pad key the gate drops and every power-menu hold.
Qt and QML notes
Section titled “Qt and QML notes”These cost real time to discover; they are properties of Qt 6.11 / PySide6 6.11, not of Universe.
QtQml.Models.SortFilterProxyModelcannot carry the theme’s filters. It has noget(), noExpressionFilter, and itsFunctionFiltersegfaults. Every proxy the theme needs is instead aQSortFilterProxyModelsubclass.SortedGames,RecentGames,RecentFirstGames(the Universe look’s Library: every game inRecentGames’ order, the never played last),LimitedGames,SearchGamesandLibraryGamesare exported to QML asimport Universe;CollectionGamesis instantiated Python-side, one per collection.get(i)returns theGame;sourceRow(i)stands in formapToSource(i), whose C++ name is virtual and breaks sorting if shadowed.RecentGamesadmits a game played or added (added_at) and orders by the later of the two (recentAt). Every proxy breaks a tie bysortTitle, A to Z whichever way it sorts.HeadedGames(a list model, not a proxy) putshead—api.screens.sources. arriving, theArrivingGameof a store install under way,installingtrue andprogress0–1 — beforesource’s rows; the arrival stays through the job’s end until the library reload brings the real game, so the first tile turns into it in place.- A subclass cannot redefine a
Property. PySide keeps the base class’s meta property; the override never reaches QML.Game.installingandprogresslive onGamefor that reason. - QML import paths. The host sets none:
QML2_IMPORT_PATHcomes from the flake — the dev shell’s hook (everyjustrecipe),wrapQtAppsHookfor the package, the pytest check — and so doesQT_PLUGIN_PATHfor the first two; a plugin must link the same qtbase as PySide6, or it is refused..venv/bin/universe-uifrom a plain shell finds no QML modules. - Software rendering has no shaders. The
offscreenQPA loads the software scenegraph, whereOpacityMask,FastBlur,ColorOverlayandShaderEffectrender nothing. The affected components testGraphicsInfo.api === GraphicsInfo.Softwareand degrade — square corners, no blur, untinted icons, a still focus ring instead of the Switch 2 look’s shader. On screen nothing changes. - Keys posted while another window holds the focus are dropped by Qt on the desktop
(
--windowed):focusWindow()is null, so--keysscripting cannot drive the host behind a running game. Inside the launcher’s nested gamescope Qt keeps the focus whatever the desktop does, so the pad’s gate there isapi.focus, not Qt (see “Focus”).Pad:A,PadHold:AandPadRelease:Apost through the pad thread and meet that gate as the pad’s presses do;Press:slotandUnpress:slotreach the controller screen as a watcher line, HOME’s included. - A list property is re-read for every element when a method is called on it in place.
store.rows.filter(...),store.rows.indexOf(x), orstore.rows[i]inside a loop overstore.rows.length, makes the engine fetch and convert the whole property (every dict of every row) once per element: the Media page’s 1 400 rows cost 5.8 s of UI thread that way, one second per 250 rows. Bind the property to a local first (var all = store.rows; all.filter(...), or aproperty varof the page) and it is read once.readonly property var rows: store.rowsis fine; so is a singlestore.rows.lengthorstore.rows[3]. - Behind a game or another app the scene holds still.
Theme.covered(the coreTheme, which the PS5 and Switch 2 ones read) isapi.home.underGame || !api.focus.active, bound by each look’s root: the game is on screen over the launcher, which happens inside gamescope alone, or another app has the focus. The hero’s drift, the session badge’s pulse and second hand, the Switch 2 focus ring’s sweep, its marquees and its tiles’ install stripes, and the clock pause on it and catch up when the launcher is back, so nothing repaints unseen. The host’s polls keep their cadence (the HOME flip is as fast as before) but answer off the UI thread, and the pad thread reads no button while covered, so a press meant for the game costs the launcher nothing. - Pages load asynchronously. The tab, detail and sub-page
Loaders areasynchronous, soactivePageis null for a frame or two after a switch; every binding on it null-checks, and a--keysscript presses after aWait. - A view’s cursor is a row, not a game.
RecentGamespins the playing game first and the stats re-sort a game after its session; no view index follows, and a positionalmodel.get(index)binding does not re-evaluate on a reorder.GameAnchor(import Universe) does: bindmodelandindexto the view’s andclienttoapi.universe, readgame, followmoved(index);hold(id)picks the game to follow, and the anchor holds the one whose session just ended. - Collections are platforms. A collection’s
shortNamecomes from the core’s platform string, whichapi.pymaps (windows,switch,wii,gamecube,nds,ps3, …), falling back to the source id. The Switch 2 look groups its software by them; the Universe look shows none. - Gamepad. SDL3 through ctypes in a
QThread,SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS=1, hot-plug. Each button becomes the keyboard key Pegasus already bound to that action, so the theme’s key logic is untouched. Axes use hysteresis — 0.5 to press, 0.3 to release — and arrows repeat after 350 ms every 90 ms. A held arrow keeps moving the cursor in every list, grid, menu and text of every look; it stops at the end of what it scrolls, and only a fresh press goes on to the next part of the page (a journal entry’s cards, a game page’s next section). Nothing is read while the game is on screen or another app has the focus (setCovered, see “Focus”).
| Pad | Key | Action |
|---|---|---|
| A / B | Return / Esc | Accept / Cancel |
| X / Y | I / F | Details / Filters |
| LB / RB | Q / E | previous / next tab |
| LT / RT | PageUp / PageDown | settings section, keyboard page |
| Start | F1 | context menu of the game on screen, whichever part of the page has focus |
| Guide | — | HOME, through the watcher (api.home), not the mapper |
| d-pad, left stick | arrows | navigation |
| right stick up / down | [ / ] |
a screenful up or down, same column, repeating like the arrows: the library and software grids, the settings rows and cards, the media grids and lists |
| right stick left / right | api.pad.rightX |
analog, past a 0.18 deadzone: scrubs the recording player, faster the further it tilts and, held, faster still — the pace doubles each second (by the tilt squared) up to eight times (ui/Scrubber.qml); a held ◀ ▶ grows its 10 s step the same way |
api.pad.muted (set by Api while the controller section’s live view is on) drops the presses;
releases still land, so nothing stays held across it. The hint bar names buttons the Xbox way
(ButtonGlyph: A, LB RB, Start+Select, dpad — a space lists buttons that each do the
thing, a + joins a chord and is drawn between them) and draws them the way the connected pad
prints them (PadGlyph, named by PadNames.js: × ○ △ □ and L1/R1 on a DualSense, B/A and L/R on
a Switch pad); with no pad the watcher sees, they stay Xbox letters. The glyphs are vector paths
(ui/Prompts.js, Kenney’s Input Prompts and the PlayStation mark from Simple Icons, both CC0)
drawn by a Shape on a 64-unit sheet — o the outline, f the filled disc with the mark cut
out, which the Switch 2 look lays over the outline for its discs — so one component serves a
14 px hint and a 48 px caption; a button no set draws (a paddle, a star) keeps its canvas
outline and letters.
The game menu
Section titled “The game menu”Start, A held 450 ms or Guide with no session opens ui/ActionMenu.qml beside the game on
screen (theme.qml openMenu, fed by focusTarget.currentGame and menuAnchor; a page with an
openMenu() of its own, the Media tab, gets that one instead): the everyday rows, the rest one
list away. Play / Continue (Resume and Quit title for the running game), a hairline, then Details
(dropped when the detail page is already open on it), the favourite toggle, Game settings, Achievements (only
when the game’s achievementsTotal is not 0), Media › (only when the
game has a screenshot, a recording or a journal entry) and Manage ›. Media › pushes the non-empty
pages, Manage › Artwork, Sessions and logs, Data and saves (see “Game data and
storage”) and, past a
hairline, Remove from library…; either opens on A or Right, and B or Left comes back to the first.
Remove asks in place (Keep it / Remove from the library) and goes through
api.universe.remove(id, false): the watcher drops the game, the detail page closes if it was on it. An item’s gap draws the hairline above it, detail a
secondary text at the right, more a chevron that Right opens; push(list, heading, after) stacks a list over the
one showing, cancel() pops before it closes. A list swapped in (push, pop, a show() while open) takes its
cursor before its rows, so no other row lights on the way, slides in from the right (deeper) or the left (back) as
it fades in, over Theme.durQuick, and the panel eases to its new size meanwhile; a list that comes back stands
where it was scrolled. refresh(list) reads the showing list again in place (Settings’ add-on list as the
index lands), the cursor kept on its index or the new last row. The PS5 Menu and the Switch 2 Picker
set their cursor before their rows too, and have the same refresh(list) for their add-on lists.
The hint bar
Section titled “The hint bar”Every page and overlay exposes hints, [{ glyph, label, dim }], and the bar on screen shows
the one with the focus (theme.qml picks the menu’s, the tab bar’s or the page’s). In the Universe look every
button that acts on what has the focus is listed: the main action, X Game page and Y Favourite or
Unfavourite on a game (Home’s rail and hero, the Library; the game page lists Y, and drops it on the
heart, whose A does the same), Y Journal on a Media item that has an entry, More (Start) and Back.
A menu, a question or the text sheet over a page puts its own hints on the bar (theme.qml
overlayHints), the game page’s bar included.
theme.qml adds LB RB Tabs itself, on the tab bar and on a tab page that is not modal.
ui/Hints.js splits them: the page’s hints on the left in the order the page wrote them, the
near-global LB RB on the right, so it never moves as the labels around it change. The clock and the pad’s
battery live in the tab bar alone; a sub page’s bar carries hints only.
A hint whose action the page has nothing for
right now (More on a settings row with nothing to list, a media grid with nothing on it) is kept in place and
dimmed (dim: true), never dropped; hints for what the pad makes obvious — moving with the d-pad
— are not written, only a d-pad with a specific meaning is (Seek 10 s, Previous / next).
Toasts are core/Notices.qml, a singleton the looks, the dock and the Control Center share: show(text, key),
fail(text, key) for a failure (a red mark, 8 s on screen instead of 4 s). One shows at a time,
the rest wait their turn, and a message repeating the last one is dropped. A message with a key
takes the place of the one with the same key, on screen or waiting: “Journal: writing …” becomes
its result (journal:<session>), “Quitting …” the session’s end line (stop). The Universe look draws
them in the hint bar’s middle (ui/NoticePill.qml, in every HintBar, so the bar of a sub page
has it too): centred, pushed aside by the hints and then elided, never over them or the page.
The dock draws them at the left of its status row, Switch 2 in its card at the top left.
Under a keyboard or a mouse (api.keys.mode not pad) ui/ButtonGlyph.qml draws a key cap
with the key’s name (api.keys.labels) in place of the pad button, the d-pad as arrows; a chord
(Start+Select) and a stick stay the pad’s, they have no key.
The mouse and the touchscreen
Section titled “The mouse and the touchscreen”The Universe look takes a mouse or a finger on top of the pad without a second path. Hovering only brightens
what is under the cursor; a click or a tap picks it — the focus ring lands where the pad would have
moved it — and one on the item that already holds the ring is A, pressed at the release (so a
second tap on a cover launches it, as on the Deck’s own UI). Held past 180 ms it is A held until the
release, so a long press opens the game menu as a held A does. A button, a menu row, a tab, a
settings row, a key of the on-screen keyboard is direct: one tap picks and presses. The pick
waits for the release (or for the press to be held): a finger that lands to scroll moves no ring.
A drag, the finger’s or a pressed mouse’s, moves the view under it along its axis and flings it on
the release, and the press it started is no tap; under gamescope a touch may come as an emulated
left click, and scrolls the same. A right click on a cover (ui/CoverCard.qml) picks it and opens its
game menu, as Start does; the menu’s backdrop lets a right click through, and one on a cover then closes
the menu. Anywhere else a right click is B. On the Switch 2 and PS5 looks (ui/Touch.qml) a right click on
an item with a menu does what its long press does: picks it and presses +. The wheel
scrolls the view under it, the ring staying where it is — a notch adds a fixed step to where the
view is heading and it eases after, so notches run into one motion, and a touchpad’s pixels move
it as they come — a strip that scrolls sideways (the Home rail, a screenshot strip) by a card from the wheel’s y, its x or Shift+y; the next key brings the view back to the
ring. The keys go through
api.keys.press, so a page handles a click exactly as it handles the pad.
ui/Pointer.qml is the one piece: dropped into an item, it fills it, draws the brightening
(radius follows the item’s corners, wash: 0 for a pane), and picked fires on a click with
the page’s own move as the handler (page.index = index, a forceActiveFocus() on the scope that
owns it). current is the site’s word for “this item holds the lit ring”, the same condition its
ring reads, so a click on an idle-ringed cover focuses instead of launching. accept: false
makes a click that picks and never presses (the article of a journal entry, so the wheel reads
it). Every pick also raises Theme.pointed(item): theme.qml hands the focus between the tab
bar and the page by it.
ui/Scroller.qml moves its parent Flickable‘s content, whoever asks — the cursor, the wheel, a
finger — so none of them fights another (horizontal for a strip, nested for a strip inside a
page that scrolls — plain y then goes to the page), its DragHandler taking the axis the view
scrolls on, so a vertical drag on a sideways strip goes to the page. The cursor’s moves slide in
Theme.durView (300 ms) OutQuint on every page of the Universe look, each step setting off from where the
content is, so a held key never leaves the cursor behind. In a ListView or GridView it follows
currentIndex by itself, keeping the item between rangeBegin and rangeEnd (the view sets
highlightFollowsCurrentItem: false, or Qt also drifts it after the cursor at 400 px/s); a cursor
moved in the same turn as the model or the view’s size lands at once. A view whose cursor is its
own calls reveal(start, end) (Settings’ cards, a menu), to, by and heading() (a text read
by steps), place to jump;
CoverCard and LibraryTile carry their own Pointer inside the scaled art (pointable,
current, picked), hovering lifting the idle dimming rather than washing the card;
ui/WheelKeys.qml is the wheel as keys, for a surface with nothing to scroll (the dock’s value
lists, the lightbox). Qt stops a
hover at the first handler it finds under the cursor, so a modal layer’s scrim (the menus, the
dialogs, the sheets, the lightbox, the search overlay) carries a bare HoverHandler and a
TapHandler — B on the scrim, nothing on the panel — and the page beneath never sees the mouse.
B held anywhere (api.keys.cancelHeld) opens the power menu, in the Universe look also the top bar’s power
button (past the search glass; both say their name while the bar’s frame is on them) and Settings › About ›
Power: Quit Universe first, then Suspend, Reboot and Power off as api.system.actions lists them,
then Stay. The Universe look asks it with ConfirmDialog.choose, the centered question with more than two
answers; the Switch 2 look with its picker (Power Options: Quit Universe, Sleep Mode, Restart, Turn
Off). Quit Universe and Suspend act at once; Reboot and Power off ask again, the focus on Cancel, and
say when the running game closes first. Suspend leaves the game running. A refusal (an inhibitor,
polkit) is a notice with logind’s own message. In the Universe session (api.system.session) Quit
Universe reads Log out: quitting the launcher ends the session, the running game first. Each look
builds its list in powerItems(), which askPower() shows.
The Settings tab
Section titled “The Settings tab”pages/SettingsPage.qml is a sidebar (ui/SectionList.qml over ui/Sections.js, under the headers
of their group: Games (Stores, Downloads, Controller, Game defaults, Emulators & Proton), Extras
(Features, Artwork, Appearance), System (Network, Bluetooth, Sound, Performance, Storage) and Universe
(System check, About); no title, the tab says it) beside one column of ui/SettingsCards.qml
(columns: 1). The ids stay those of the old names (sources, install, launch, runners,
modules, themes, system, doctor), and the settings search still finds a section by its old name
(search.py‘s FORMER_NAMES, matched as a word of its name). Switch 2 and PS5 group their own sections
under the same headers and words, each keeping its own for what it has alone (Updates, Internet,
Accessories, Data Management), and Universe Desktop’s Preferences pages read the same. A
landing on a section folded into another opens that one (Sections.aliases: components → Emulators &
Proton, updates → Downloads, quit and power → About; Switch 2’s aliases and PS5’s Sections.aliases
fold components the same way). Sound is one row per api.home.outputs entry, the device as its value and the one
in use tagged; A on another plays through it (setOutput). About is its Version
(api.universe.version(), the version with the short git rev behind it; A opens What’s new too), What’s
new (Open: every release, the newest one’s version as its value, see “What’s new”), the switch “Show what’s
new after an update” (api.screens.changelog.opensAfterUpdate), First-run setup (Run again) and Power, which opens the power menu B held opens (see “Power”). Up and Down in the sidebar switch the section as they go, Right or A enter the cards, Left or
B come back, L2/R2 cycle the section from anywhere. In the cards the hints are A, More and Back:
Start lists what X and Y do there (Refresh the sections that fetch — Runners, Install, Sound, Storage, Doctor —
Remove a Launch variable, Enable or Disable a module or a source, Show or Hide advanced), and X and
Y still do it straight away. Game settings and a runner’s, module’s or source’s page do the same
with Reset to default and the advanced rows. The tab bar’s search glass is on every tab and finds
the games and the settings (see Search); the store’s catalogue search is Switch 2’s alone. A row’s icon names a MenuGlyph kind; a pad
button’s row reads its macros’ labels as its value, the hold one after “Hold ·”, and nothing
without one. A source’s game row shows the library’s art when the
game is in it — the square slot, else the cover — cropped to a square thumbnail; the home
rail’s tiles pick their art the same way.
The Install page
Section titled “The Install page”api.screens.sources (SourcesBrowser) lists one source’s games — source, one of stores (the
enabled, available sources, {id, name}), the first until pick(id) lists another from its cache
and the disk as load() does, dropping a search: rows of {id, title, game_id, image, owned, linked, dir, detail, installed, pending, partial, busy, status, action, disk_size, download_size, partial_bytes, size, sizeText, sizeKind}, installed first. linked is the core’s
game_id (see source_library): a library game stands for the install, the store’s own or a Lutris
import in its folder. An installed row reads status “In your library” when linked, “Not in your
library” otherwise, with dir the folder the store found it in and detail “Found in <dir>” (both
empty on any other row); the looks branch on owned, installed and linked, never on status.
folders are the folders the store’s games are found in (games_dir, then scan_dirs), for the On
this PC meta; uninstalls says the store declares uninstall, so Delete files… goes through it.
load() (opening the page) serves the core’s cache and the
disk, re-listed once 15 min old, never the store; refresh() (Y) asks the store, so a game
bought since shows up — libraryAt and libraryAge say when that last happened, for the header.
A refresh the store refuses keeps the listing shown, toasts the reason and leaves it in error
until a reload succeeds. freeSpace is the install folder’s free bytes (games_dir; 0 when it
is missing), for the confirm and a disk strip.
Sizes: an install’s is measured (sizeKind disk); a game not installed shows its download size
once known (download), and peek(index) — call it as the cursor lands on a row — fetches one
info at a time, off the busy flag, and the core remembers it. install(index) installs, resumes
a stopped download (the same call: the source continues over its folder) or updates. An installed
row the library lacks (linked false, action “Add to library”) takes adopt(index): an adopt
job (source_adopt), which brings that one game in and no other, then a toast once the reload shows
the row linked. The first run’s adoption and Universe Desktop’s Find Installed Games stay the
store’s whole scan. trash(id), by the store’s id since the confirm outlives reloads, removes
such a row’s folder (source_trash: through the store, or to the trash), never while that row’s
add runs; the core’s refusal is the toast.
While an install or update runs
job is {id, game, title, label, message, done, total, ok, cancelled}, done/total in bytes and
message the live line (“Installing X · 42% · 3.4 GB of 8.2 GB”); the rows are rebuilt at its start
and end only, the busy row’s status “Installing…” and its action Cancel. cancel() stops it: the job ends
failed with cancelled, the toast says what was kept, and the row turns partial — status
“Paused · X of Y kept”, action Resume. Reloads after a job, an uninstall or a removal stay off
the network.
The Universe look keeps it as Settings › Downloads: with two stores or more a Store card first (one row, the
store’s name; A opens the menu of them), an Installing card (running and paused, a hairline of
progress under a paused row), an Updates card when some are pending (one row, Update everything,
updateAll(); a game’s own Update is in its row’s menu), then On this PC (its meta the folders
looked in) and Not installed (“in your GOG account”), every game with its size in figures of one
width, the library’s age or the store’s failure in the card meta, a row’s detail under the cards
while it has the cursor; A opens the row’s menu (Cancel install · Resume · Update · Add to library ·
Delete files… · Game settings · Uninstall… · Remove…; Delete files… on a row the library lacks,
after a confirm), Install first asks in a
ConfirmDialog with the download, the disk and the free space; X refreshes; the cursor on a game
without a size peeks it. The Switch 2 look’s pages/InstallPage.qml is two tabs on the bumpers:
Store, Not installed, the tiles of what is owned and not installed (a download or a pause painted
on the tile’s foot) and the search’s results, A installs after the same confirm; Manage, the
install folder’s strip (used · free), then Installing and On this PC as rows with a size and an
action (an unlinked row’s line ends in its detail) — A is the row’s menu (Add to Library and
Delete Files… for a row the library lacks), X cancels the running job from either tab. The PS5
look’s Store lists Ready to install, Updates and On this PC; a card the library lacks offers Add to
Library and, under its Options button, Delete Files…, its detail under the status line. With two stores or more the rail’s first entry,
the store’s name, opens the menu of them; in the PS5 look’s Store, a Change Store icon heads the top
row and the Options menu, with the same menu.
Detail, recordings and journal
Section titled “Detail, recordings and journal”The detail page is the game’s logo over its background, one line of the play time and when it was last played
(ui/StatRow.qml, its facts keyed playTime and lastPlayed, each left out while zero or empty) ending in the
game’s updateMark (Update available, May be out of date), and a row of buttons (pills: Play, the heart, Update
when updateAction is update, and ··· for the game menu). Below, the description takes the left of the body and
the store’s screenshots (assets.screenshotList) a column on the right, a third of its width, nothing else. Down
from the buttons reaches the description (the screenshots when there is none) and scrolls it, Right from it the
screenshot already on screen, Up and Down step through them (Up from the first back to the buttons), Left returns to
the description, A views one full screen. ··· and Start open the game menu beside ···: Game settings and
Achievements at its top, the recordings, journal and screenshots under Media ›, the rest under
Manage ›. B fades the page out where it stands; the same game opened again starts at the top. Home’s hero holds the same entry points for the game under the
cursor: Continue (or Play, Resume), Game page and ···, one press up from the rail.
Every page about one game — Game settings, Artwork, Sessions and logs, Screenshots, Recordings,
Journal — opens under the same ui/GameHeader.qml: the page’s name in caps over the game’s title,
no cover and no count. Their hints are the main action, More and Back (Seek 10 s while a video has
the cursor); what X and Y do is in More too.
pages/RecordingsPage.qml plays in a pane beside the list; □ (X) toggles it fullscreen — the
pane fills the page, the hint bar rides the controls’ auto-hide, ○ leaves fullscreen first, then
the video, then the page. Moving the cursor touches no file: the pane shows the row’s cached
thumbnail once the cursor has rested 200 ms (the 16-frame mosaic instead when every frame is
cached), and A hands the file to the player and asks select for the frames, so a game with
ninety 4K recordings scrolls as fast as one with none. Start on a row is an ActionMenu (Play, Fullscreen, Journal entry, Remove
recording…); Remove asks in place — Keep it, Trash the recording, or trash it and its journal
entry when it has one — and calls api.screens.recordings.remove(gameId, session), which goes
through remove_recording, drops the row’s cached frames and reloads the list on the signal.
pages/JournalPage.qml has the same menu (Read, Recording, Remove entry… — Cancel the writing…
on a pending row) through api.screens.journal.remove, which cancels a pending entry’s writer
before trashing; the offer to take the recording along works the other way round.
The two pages preview each other for the session under the cursor, so a jump (Y, jumpRequested)
is a choice and not a guess. The journal article shows its screenshots right under the title and
date — ▲ from the top of the text lands on them, ◀ ▶ step, A a Lightbox, ▼ is back on the text —
and ends with a RECORDING card (ui/RecordingCard.qml) under NEXT UP — ▼ from the end of the text
lands on it, A opens the recordings page on that session. A held arrow stops at either edge.
Under the recordings pane a JOURNAL block shows the entry’s title and first paragraph; ▼ from the
video focuses it, A reads it. Each page loads the other’s store for the game
unless it already holds it, so the jump finds them warm.
Media › Screenshots in the game menu (when screenshots(id) lists any) opens
pages/ScreenshotsPage.qml on api.screens.shots: a four-wide
16:9 grid of the player’s own shots, newest first, A a Lightbox (◀ ▶ step, B closes), Y the
journal entry covering the shot (jumpRequested to the journal page on that session), Start an
ActionMenu — View, Journal entry, Remove screenshot… (Keep it / Trash the screenshot, through
shots.remove). An args.name lands the cursor on that file. While this game is the one playing
a hairline parts the session’s shots from the rest, as in the dock’s panel. The detail strip keeps
the store’s promotional shots only (assets.screenshotList).
Game data and storage
Section titled “Game data and storage”api.screens.gameData is one game’s data as the core’s game_data(id) (api.md › Game data)
lists it, read off the UI thread: load(gameId) (loading, error), unload(), gameId,
title, total (the size of it all) and rows, a flat list every look’s settings rows draw: a
heading row (heading: true, label, display the section’s size) per section — Saves, Backups
(one row per backup kept, restore:<id>), Wine Prefix, Storage — and under each static rows
(saves_status, saves_folder, prefix, install, universe, recordings, logs: display a
size, detail the path) and action rows: backup, restore, export (a zip in the home
folder), move (only for a prefix move_prefix takes), winecfg, winetricks, run, kill,
and reset (Universe’s own prefix, used by no other game, danger). question(key) is the
confirmation a look asks before act(key) ({title, detail, confirm, danger}, none for the rest);
the move question of a prefix Universe did not make (lutris, elsewhere) also says that the other
launcher keeps pointing at the old path, and carries that path as stale; act(key) runs it on the worker, runProgram(path) is run with the file the look’s folder sheet
picked (files: true). While one runs, busy names it and its row reads “Working…”, disabled; it
ends in finished(key, ok, message), the looks’ toast, and the rows are read again.
api.screens.storage is storage() the same way: load(), rows — Folders (root:<id>, the
size and the free space), Games (game:<id>, biggest first, gameId set: A opens that game’s
data), Leftovers (trash:<path>, the path in target, danger) — used, leftovers (their
size), question(key), act(key) (the leftover to the trash, then a reload) and finished.
loadFree() reads disk_free() alone, no folder walked, and free is the games root’s free space
from whichever came last.
Where each look has them:
- The Universe look: the game menu’s Manage › “Data and saves” opens
pages/DataPage.qml(openSub,{game}): the headings become the sidebar’s sections and the rows a card each (CardSections), A runs a row, aquestionasks first (ConfirmDialog),runopens the value editor’s folder sheet on a file. Settings › Storage listsapi.screens.storagein the settings cards, loaded when the section opens; a game’s row raisesdataRequested(game), a leftover’s asks then trashes. - Switch 2: Software Options › “Data Management” pushes
switch2/pages/DataPage.qml; System Settings › Data Management (System group) has one row that pushesswitch2/pages/StoragePage.qml, as the console’s own. Both areSettingsRowsover the rows, the questions throughshell.dialogAsk. - PS5: the game options (Home, Library and Software Information) list “Saved Data and Storage”, and the
game’s Manage cards a card of it, both pushing
ps5/pages/DataPage.qml, each row with its glyph; the Welcome hub’s Console Storage tile pushesps5/pages/StoragePage.qml, and shows the free space fromloadFree(), read each time the hub shows (it no longer waits on the sources).
Achievements
Section titled “Achievements”pages/AchievementsPage.qml (the game menu’s Achievements; Switch 2: switch2/pages/AchievementsPage.qml,
a row of Software Options showing unlocked / total; PS5: ps5/pages/AchievementsPage.qml, the
game’s Trophies) reads api.screens.achievements (screens/achievements.py, achievements(id) on a
worker): the unlocked ones first, the newest on top, then the locked ones from the most to the least
common. The hidden locked ones are not rows: their icon_locked is a greyed copy of the art, so they
fold into one last row, key hidden, carrying their count in hidden (31 hidden achievements,
Keep playing to find out., no icon, no rarity; the PS5 look says trophies). Each row says a fact
once: its name over its description, its date once unlocked or its rarity while locked, the row
dimmed while locked. The Universe look lists them in one column under n / m and a thin bar
(ui/AchievementList.qml, 80 dp rows, the dock’s tray too); Switch 2 as SettingsRows (the
description as the row’s secondary line, the store’s icon, n / m in the header); PS5 as wide
ps5/ui/TrophyCard.qml cards under one n/m · % line and a gold bar, the console’s grade (Common,
Rare, Very rare, Ultra rare) beside the rarity, Options sorting them (the folded row stays last). X
(PS5: Options › Ask the Store Again) asks the store again (refresh()); when the store last
answered (fetchedText, 4 days ago) is said there and nowhere else: Ask the store again ·
updated 4 days ago in the hint. ▲ ▼ step, the right stick a screenful, Home and End the first
and the last. The store follows the game’s libraryChanged, so an unlock filed while the page is
open shows at once. The icons are the store’s URLs; one that does not load is drawn as a trophy with
a tick (a lock while locked), ui/AchievementBadge.qml, the tick only then.
The desktop’s page (pages/achievements.rs) is the same list as one boxed group, titled with the
game, under n of m Unlocked and a bar; its refresh button’s tooltip says when the store last
answered.
A game has the menu row once its cache holds a list. The first time a game’s detail page (Switch 2:
its options) opens in a run, achievements.prime(id, source, total) asks for it in the background
when the game has a store source and no list yet; the cache it writes brings the row.
While a game runs, Home’s UnlockWatch rereads its list on every change to the game’s folder and
emits achievementUnlocked({gameId, gameTitle, key, name, description, icon, rarityText}) for each
unlock newer than the session’s start, once. overlay.qml draws it, outside the theme’s loader so
every look gets it: a card in the top right corner for 4.8 s, three at most, headed by the look’s
unlocked words (api.theme.unlocked, from its THEMES entry: TROPHY EARNED under the PS5 look,
ACHIEVEMENT UNLOCKED under the others). Home queues the rest
(a game that files a save’s worth at once) and sends the next as a card calls bannerDone(); a
pill under the cards counts the queue (bannersWaiting), +n more unlocked. Home lifts the
overlay window to opaque without taking the game’s input for 5 s on every card it sends (the same
lift as the screenshot flash, the longer of the two standing), unless the dock is already holding
it up. With no overlay attached, nothing would call bannerDone(): every unlock goes out at once. On the desktop
there is no window over the game, so the unlock shows on the Achievements page alone.
A replay (universe achievements <name> --replay, see api.md § Achievements) comes the same way:
a replay stamp newer than the one the first look of the session found is emitted key by key,
whether or not the keys were news. The first look swallows its stamp, so a UI restarted mid-session
does not show an old replay again.
The Media tab
Section titled “The Media tab”pages/MediaPage.qml, the second tab, is api.screens.media on one four-wide grid of
ui/MediaCard.qml under the title of the game the cursor is on, as in the Library: nothing to
sort, the screenshots, recordings and journal entries of every game, newest first, with the kinds
left on in the toggle row under the title (mediaFilters: Screenshots · Recordings · Journal). A
cell is 16:9 and shaped by its kind so the three read apart at a glance: a screenshot is the
picture alone, a recording its frame under a play disc with the length in a pill, a journal entry
its first picture (the row’s thumb) under a scrim with a book disc and its heading, or its words
when it has none (a book glyph, the heading, the entry’s excerpt, the session’s length); nothing
is written under a card. The pictures are the core’s thumbnails, never the 4K
originals, fitted whole (a capture off 16:9 gets bars, never a cut, as in every other screenshot
frame): a shot’s card shows a camera until its thumbnail lands, which the first open after a
session fills in over a few seconds and every open after that has at once. ▲ from the top row is
the toggle row: ◀ ▶ move along it, A shows or hides a kind (the last one on stays on; every kind is
on again at each start), ▼ is back on the grid and ▲ is the tab bar. The grid, its cursor and the
lightbox’s shots are the kinds shown, and the page finds a row again by its key. A opens the
cell: a shot in the Lightbox (◀ ▶ step between the shots), a recording on the game’s recordings
page at that session (recordingsRequested(game, session)), an entry on its journal page
(journalRequested(game, session)); Start is an ActionMenu with that, Details,
the journal entry covering a shot or a recording, and Remove screenshot…; X and Y do the details
and the entry straight away. The hints are View, Play or Read, More and Back. The shell opens each
request as a sub-page over the tab, so B comes back to the grid where it was.
Search
Section titled “Search”The tab bar’s glass opens ui/SearchOverlay.qml over any tab: the on-screen keyboard at the
bottom, the games whose title holds the query as covers along the top (SearchGames, the
recently played first; every game while the query is empty), and under them, once a word is
typed, the settings that match (api.screens.search, the index’s own game entries left out —
the covers are those; a query that is only a game’s title, titleOnly, lists none). While typing
with both, the covers keep the room and the settings are one line under them (“Settings: Resolution,
Launch…”); ▲ from the keyboard reaches the settings, ▲ again the covers, and while a list has the
cursor the keyboard lowers to its field so the lists get the height. A on a cover plays
(Start is the game’s menu, X its details); A on a setting closes the search and opens it
(theme.qml openSetting): a runner’s, a module’s or a source’s row on its own page, a game’s
row on the game’s settings, the rest on its Settings section with the row revealed — a gamekey
row instead unfolds into its games. B closes the search from anywhere in it.
The launch and modules sections
Section titled “The launch and modules sections”api.screens.launch is Settings › Game defaults, what every game starts with, each row a config.toml
key: the core’s launch form (client.form("launch", "", screen)), the launch keys tied to no
runner in the catalogue’s order, then the config’s own keys. Each field’s section is the card —
Display (the gamescope switch, resolution, refresh rate, adaptive sync; the card’s meta is the
screen, screen: DP-1 3840×2160 @ 60 Hz), Overlay (MangoHud, the frame rate limit, pause on HOME,
desktop.hide_cursor, desktop.keep_awake), Saves (saves.auto_backup, with saves.keep under
its Advanced rule) and Updates (desktop.whats_new, see “What’s new”), then behind Y Scaling
(scaler, filter, sharpness, the raw gamescope arguments), folded into Display, and Environment (one
row per variable, then Add a variable…, X removing one), Programs, Folders, API keys, Desktop and
Proton builds as cards of their own — beginner first, expert last. Its label and description (the row’s detail) come with it,
and its choices are sized by the screen the window is on (screen_mode: auto, the screen’s
mode, the standard heights below it at its aspect ratio; the rates below its own). field_row is
the presentation over a field: a proton field lists the protons (the [proton] names and the
builds found), auto displays as auto · 60, the rate it stands for (resolved), a toggle
(adaptive sync, the upscaler upgrades) on auto as auto · On or auto · Off, a scaling key left
unset as default · linear. The keys tied to a runner (proton, the sync modes, Wayland, HDR, the
upscaler upgrades) are set on that runner’s page instead (below). load() reads the form again.
The game settings page (pages/GameSettingsPage.qml, switch2/pages/GameSettingsPage.qml: a
sidebar of the form’s cards — the game’s own, then the modules’ — beside the picked card’s rows,
Y showing the power user’s rows inside them under their rules (the sidebar never moves with it);
the Universe look’s sidebar and rows are ui/CardSections.qml, shared with the form page, LT / RT stepping
the cards, B going back to the sidebar, then closing) is the core’s game form (client.form("game", id, screen): the launch keys of the runner’s kind, the module and source settings the game’s runner
and platform take), and mirrors the cards — Display (with Scaling folded in),
Overlay — then the runner’s own, named after it (Proton: the build, Wayland, HDR; folded in, the
Wine prefix, the DLL overrides, Sync, Upscaling, Logs), then Launch (the runner picker, the program,
an emulator’s options; folded in, the wrapper, arguments, working directory, the environment), then
Desktop and library (with Artwork folded in), then each module’s game-scope settings and the game’s
source’s (GOG’s Achievements switch, written as sources.gog.achievements); a both key inherited from the global value until set
(a toggle inherits the global switch, not what effective resolved it to). X on a row resets the
game’s own value or removes a variable (reset above); an inherited value is overridden by
changing it. Adding a variable is one sheet with two fields (ValueEditor.promptPair,
KeyboardSheet.showPair; Switch 2 shell.promptPair, TextSheet.showPair): Done on the name
moves to the value, Done on the value saves.
A value changed on a game’s page goes to this game or to all the games its row reaches, in every
look the same way. A picker over a row with a reach takes A for this game and Y (△) for all of
them (ValueEditor.edit(row, after, all); PS5 and Switch 2 rows.edit(row, apply, all) with
shell.pick’s alt, which the hints name: All Dolphin games, PS5 All Dolphin Games); a value
typed or browsed (a sheet, a folder, a new variable) is asked about once Done: a two-item picker,
This game on A, all of them on Y. A switch flips for this game on A; the Universe look’s More, PS5’s Options
and Switch 2’s + list it flipped for all of them (toggleAll), with Apply to all of them for a
value the game already sets (promote), Reset and Advanced. A row without a reach — the runner,
the program, a module’s switch — offers this game alone. The form’s message says what came of
it, as a toast.
Upscaling’s meta names the GPU (client.gpu(): label, AMD Radeon RX 7900 GRE · RDNA 3) and each
of its rows ends its detail with Works on your GPU. or Not for your GPU. (fits), nothing when
no GPU is known. In the Universe look, ui/SettingsCards.qml shows the focused row’s detail as a caption
under the cards (two lines, reserved whenever a row has one); the Switch 2 look prints it under
every row.
api.screens.modules is Settings › Features and api.screens.sourceList Settings › Stores, the
same list (screens/settings.py‘s ListForm): one action row per entry (module its id, its
name, value whether it runs, display On / Off / Set it up / Unavailable, switch true for On and
Off, detail the entry’s description, or what is missing or waiting, meta an add-on’s version and
where it came from, warning what is missing, source which list it is), the ones running first, the
others in an “Off” card (off: true). The Universe look draws a switch row’s state as a switch
ahead of the chevron (ui/SettingsToggle.qml: the accent track when on, a solid outlined one when
off) and the Off card whole, as bright as the others; the consoles draw display as text. A opens the
entry’s page, which starts with its Enabled switch; Y in the Universe look, X in the consoles’
(each hinted) toggles it in the list (toggle(index), refused with a warning while it is off).
indexOf(id) finds an entry’s row for the cursor to land on again, and the cursor follows a toggled
entry to its new card. The Universe look’s Settings bar names X and Y as the consoles’ do: X Refresh
(Remove on a variable); Y Enable or Disable on an entry, Show or Hide advanced on Launch, Hide progress
while a component job shows. A built-in’s meta is empty: its
version is the app’s own. An add-on’s reads v1.2.0 · from the index, · Unlisted or · your own folder by its origin, and unlisted is also its tag. The consoles mark the row with opens: true
for their chevron. Each list ends on a
“Get more…” row of its own card (key: "more", addons the kind, module or source; no switch,
toggle leaves it); each look’s Settings › Appearance builds one of its own, addons: "theme" (see
“Themes”). Picking it opens api.screens.addons (AddonsForm, over the core’s
extensions(), fetched again when a list loads, or at once after an install or a removal, once a
minute at most) in the look’s menu: items(kind) is one item per add-on of the kind, its action
the add-on’s id, an empty one picking nothing, its state saying why (loading, error as
“Couldn’t load add-ons”, empty). The open list takes items(kind) again on listingChanged, in
place (addonsShown names its kind while it is open).
actions(id) is what the pick does: Install for one not installed, nothing for one written for
another Universe (incompatible), Remove for an installed one, after Update when the index lists
another version. A single action goes straight to its confirmation, more open a menu
(addonsMenu(kind) and addonAction(id, action): the Universe look’s Settings page’s, the stacked looks’
root). confirm(id, action) is the question: an install’s names the kind, that it runs programs as
you, and its origin, the index’s host (kind, origin, runsAsYou say the same to a test).
act(id, action) installs or updates as a client job, or removes off the UI thread. Each one
ends in a message, a modulesChanged plus sourcesChanged that reloads both lists, and settled,
on which api.theme reads the installed themes again. The UI
installs from the index only: universe extension install takes a URL, an archive or a folder.
pages/FormPage.qml with { module } or { source } (theme.qml openSub;
switch2/pages/FormPage.qml on the stack; both lay the form out as the game settings page does, a
sidebar of its cards beside the picked one, Y for the advanced rows) is on api.screens.module or
api.screens.source (PageForm): load(id) builds its head (info: name, meta, description
from the manifest, warning, enabled, source, and a source’s logged_in and user) and a
Settings card headed by the switch (enabled, disabled while the entry’s programs are missing)
and, once on, the settings of its core form (client.form("module" | "source", id)), a
dynamic setting’s choices fetched off the UI thread, then a Game defaults card: the global
values of its game settings, where a value for all games shows and X undoes it. A source’s page adds a Sign-in card: Signed in (an info row, the user as its detail), Get a sign-in link (link: api.screens.login.begin,
the QR code and the URL then show under the cards) and Enter the code (code: a prompt into
login.submit); login.finished reloads both source screens. Listing the sources probes their
logins once per process, on the network, so the sources list and page load off the UI thread and
announce rowsChanged when they land. Doctor’s checks stay on api.screens.modules
(loadDoctor, doctor, doctorGroups), grouped by module or source name and run off the UI thread too. The failing
checks leave their card for a “Needs attention” one on top, each row carrying its card as path and its fix; a row
with a fix wraps its problem and fix under the label in every look, and a card keeps counting the checks that left it.
The runners section
Section titled “The runners section”api.screens.runners is the Runners section of the Settings tab, in every look: the runners and
what Universe installs for them, over the core’s runners() and components() (see api.md, Runners
and Components). One row per runner, its logo (assets/runners/<id>.svg|png, logo(id) says which
exists), its name and how many library games run through it (effective.runner) as display,
sorted by that count, then by those games’ hours, then by name. The row carries the state of the
components its page installs — the runner’s own (runner in the listing: Wine and every emulator),
or for Proton its builds and umu-run — read from api.screens.components: the build in use as
detail (and, for a newer build than the system’s, that Universe can install it beside it), a tag
for what to do (Update X, Install X for that newer build, Needed when a game
waits on it, Installing…, Updated) with accent, the download’s size, progress while its job
runs, and component, the one the tag is about. The cards: the runners found; “Not installed”,
those Universe can download, a runner a game waits on first; a dimmed “No download” for the others
(before the listing is in, a dimmed “Not found” holds both: unavailable: true, every look dims it); and a “Tools” card at the foot for the
components that are no runner — gogdl, legendary, comet, butler, and the system’s gamescope, MangoHud
and gpu-screen-recorder (meta “N of M installed”), whose row is the component’s (key: "component", no runner) and whose A opens its options. load() builds the list at once and asks
the components again, which rebuild it when they land, as does libraryChanged; refresh() (Y)
fetches the catalogue first. indexOf(id) finds a runner’s row, or a tool’s by its component id.
Settings’ components section id lands here in every look.
A runner’s row opens pages/FormPage.qml with { runner } over the tab (theme.qml openSub, the
same loader as the game’s sub pages), on api.screens.runner: load(id) builds its head (info:
name, platforms and where its program was found, a warning) and, from the core’s runner form
(client.form("runner", id, screen)), a Runner card for the program (exe, a path; the detected
one shown as the value, origin: "found", where it was found as the detail; origin: "runner" once
set here, X clearing it back) — on Proton’s page umu-run’s component row right under it —
arguments and the gamescope switch (origin default, global or runner, X likewise);
then a Builds card, where the build is picked: on Proton’s page first the launch.proton row, the
default build, whose choices are every Proton found (config.protons: the families’ builds, Steam’s
own, [proton]‘s, the local ones), then one component row per build family: the runner’s own, or
Proton’s (GE-Proton, Proton-CachyOS, Proton-EM, umu-proton; the one in use first). There is no Build
row, and Proton’s launch card leaves the proton key to the Builds card. Then — for
Proton and Wine — the launch keys tied to its kind (the Proton card, with Sync, Upscaling and Logs
folded in behind Y, or into the Runner card on plain Wine; the global [launch] values), each
option by its type in an Options card (origin: "runner" once set here), and a Games card — one game row per library game running through it (gameId, its
square or box art as image, its hours as the display, installed when it has an install folder),
by title — ending in an “Add a game…” action. setValue(index, value) writes through
set_runner_setting (a launch.* row through set_setting); on the add row it keeps the picked
file and pendingTitle() proposes a title from it, which addGame(title) sends to add_game. A
game row opens the Install page’s options: Game settings (the Universe look: settingsRequested(game), which
theme.qml pushSubs over the runner page, B coming back to the row: the runner page comes back
as a new page, so the game rides in its args as returnGame; Switch 2: its
GameSettingsPage), Uninstall… when installed, Remove from library…, through uninstall(id) /
remove(id) on the form (a message when done); Uninstall’s confirmation names the store when
api.universe.uninstallVia(id) does (“Uninstall through Steam”, “Steam removes the files”), the
trash otherwise. The page follows libraryChanged and the components’ listing. Back on the tab,
the cursor finds the runner again. The Switch 2 look has the same list as System Settings › Emulators & Proton
and the same page as switch2/pages/FormPage.qml, pushed on its stack, PS5 likewise. The game
settings page’s Launch group follows the runner: a Runner picker (names shown, ids written), then
the rows the runner takes.
In the Universe look, Game defaults’ Updates card ends in a Game updates row (game_updates, added by
pages/SettingsPage.qml, not the core’s form) that opens pages/FormPage.qml with { updates: true } on
api.screens.updates (screens/updates.py): one update_game row per game not hidden whose status is update,
by title (a “No update available” info row when none is), then behind Y an advanced Can’t tell card of the
unknown games markable takes. A row’s how is apply (A runs the store’s update) or mark (A asks first with
its confirm spec: the installed version and the day of upstream’s newest build, then api.updateGame(id) marks it
current); a game updated by hand that is not markable is an info row. The page follows libraryChanged while open.
The Switch 2 and PS5 looks list the same games in Settings › Updates, under the store’s own updates (rows of
api.screens.sources): an Updated by hand caption over the update rows whose how is not apply, then a Show the
games Universe can’t check row flipping api.screens.updates.showAdvanced, which adds the Can’t check ones; A on a
game asks with the row’s confirm (shell.dialogAsk) before api.updateGame(id). Its game page (the Universe look’s) facts line adds the installed version while the status is
current or update, and Up to date while it is current.
A component row’s A is its options, on a runner’s page and in Tools alike: the Universe look’s page and
section componentMenu(id, …) over their menu.show and ConfirmDialog, Switch 2’s and PS5’s
componentOptions(id, title) on the shell, over shell.menu and dialogAsk. The rows’ tags in Switch
2 and PS5 stand in for the value, Updated aside.
api.screens.components (ComponentsForm) is the engine behind them, with no section of its own:
load() reads the listing off the UI thread (the cached catalogue), refresh() fetches the
catalogue first; listing() and row(component) (the component’s row: name and icon, the build
that runs as display, tag, accent, size, a detail sentence, progress, action Options
or Cancel) are for the forms above, which rebuild on listingChanged. actions(id) — {icon, label, action, danger}: install, update, use:<build> (use:latest follows the newest again once
one build is held), versions (which opens versionActions(id) as a second menu), rollback,
remove:<version>, uninstall (with the size of Universe’s builds, there whenever Universe holds
one, the one in use included), cancel while its job runs. confirm(id, action) is what to ask
first or null — an install’s size and the room left, the notice an install must show, a system
tool’s packages and, outside the Universe session, that it asks for the password, a removal,
an uninstall (what goes and what runs in its place), a rollback — and act(id, action) does it: an
install or an update is a client job (componentInstall, componentUpdate, job the running one
like the Install page’s, drawn by the same bar on Settings › Emulators & Proton), an install from a proposal
over a system build switches to it (use:latest) once in, a finished job toasts through message
(every root shows it, a runner’s page included, where the row’s tag and hairline follow the job).
hideJob() takes the bar away, running or finished, until the next job: the row keeps its progress
and Cancel, the end still toasts. The Universe look hides it with the bar’s × or More’s Hide progress, Switch
2 and PS5 with theirs; Y (the Universe look) and X (Switch 2, PS5) do it straight away on a section that
gives them nothing else to do. pending counts the updates and proposals: the Universe look’s Runners badge,
Runners’ detail in Switch 2 and PS5. busyOn(id) and needed() (the missing runners the library
needs) serve the first-run page. A failed launch whose runner is missing and offered
(launchFailed) emits installProposed(gameId, id, name, version): every root asks “Install … to
play?” (question(id) the size and room), installFor(gameId, id) installs, and
readyToLaunch(gameId) launches the game again once it is in. A doctor row carries component
when an install fixes it, its fix naming Settings › Emulators & Proton: the Universe look’s A reads Install, Switch 2
and PS5 turn the row into an action (PS5 loads the listing with its Doctor, so the first press
installs); each asks, then installById(id). Once a day while the UI runs (the first check 90 s
after start), with components.auto_update on, no session running and no job of the user’s, the
form runs componentUpdate("") quietly — no bar — and toasts “Updated …” when something was.
Adding a game
Section titled “Adding a game”The runner page’s row is for someone who already knows the runner. Everyone else adds a game from
the Library: ui/CoverGrid.qml and switch2/pages/SoftwareGrid.qml take addTile, one more cell
after the last game (a “+” tile, the cursor on it as addSelected / atAddTile, never a model
index; currentGame is null there and A emits addRequested). With nothing in the library, Home
and the Library are the prompt: the Universe look’s hero band reads “Welcome”, a line on where games come
from, and Add a game and Set up; its rail tile (ui/LibraryTile.qml kind: "add", a plus over
“Add a game”, as the Library’s own add tile) adds one instead of opening the Library; the Switch 2
HOME row’s disc does the same and All Software says so under its tile. The Universe look’s Library is the
bar’s second tab (Home · Library · Media · Settings, libraryTab); Home’s rail tile stays a way in
(libraryRequested) and B returns to Home. B on the bar over any tab but Home goes Home too, rather than back
down into that tab; over Home it hands the page the focus back.
Its grid runs in Home’s order (RecentFirstGames) and fades the rows past either edge over 80 px
rather than cutting them at the hints; the focused row scrolls clear of the fade.
Both open api.screens.add — the Universe look as pages/AddGamePage.qml over the tab (openSub with
{ add: true }), the Switch 2 look as switch2/pages/AddGamePage.qml on its stack. load()
builds three cards: a “Pick a game file…” action, one action row per source of sources()
(store, loggedIn; its display says whether it is signed in) and “Import from Lutris”;
the Universe look joins them into one untitled card under the page’s title. The file flow is file first: setFile(path) keeps it and sorts runners() into runnerChoices /
runnerIds — the ones whose extensions take the file first (linux takes a bare binary, .sh,
.x86_64, .AppImage), found before missing, Proton before Wine, then by name — with runnerIndex on the
first; the page shows that list as a picker, pickRunner(i) takes the choice, pendingTitle()
proposes the title and addGame(title) calls add_game, toasting message. A signed-in store
row picks that store (api.screens.sources.pick) and leaves the hub for Settings › Downloads (the Universe look:
installRequested(source, "install") → theme.qml openSettings(section), whose deliverLanding
calls the Settings page’s land(name) once it is the active page; the Switch 2 and PS5 looks push
their Install page); one signed out or off (available false) opens the source’s own page instead
(pages/FormPage.qml with { source }), where its switch and Sign-in card are. The
Lutris row is two presses: previewLutris() runs import_lutris(false) off the UI thread
(busy) into lutris (the report) or lutrisError (Lutris absent: shown on the row, never
toasted — the client’s attempt(work) returns the error instead of emitting it), the row then
reads “N games to import” and the same press asks to confirm; importLutris() applies, emits
libraryChanged([]) — a full reload, the import updates games too — and toasts the count and hours. Nothing here touches the core: the
CLI’s universe add and universe migrate are the same calls.
First run
Section titled “First run”api.screens.onboarding is the setup shown once per machine: needed is true while the core’s
onboarded() is false and the library is empty — the flag is $XDG_STATE_HOME/universe/onboarded,
the GTK app’s too, so a machine set up in either is not asked again; a library with games sets it on
the first read, so an emptied library never brings it up later, and api.memory‘s onboarded of
earlier versions is carried over to it. Every look opens it from its root’s Component.onCompleted
as a dialog over what is on screen — the Universe look as pages/OnboardingPage.qml (openSetup(), openSub
with { setup: true }, which keeps the tabs visible under it), the Switch 2 look as
switch2/pages/OnboardingPage.qml on its stack (a page whose overlay is true leaves the layer
under it in view), the PS5 look as ps5/pages/OnboardingPage.qml on its stack, and on an empty
library its home row leads with Set Up and Add a Game — after the startup animation, when one plays
(start(), on api.boot.landed); Settings › About › “First-run setup” opens it
again, and so does the empty Home’s “Set up” — a second pill beside “Add a game” in the Universe look’s hero
band, a second disc beside the plus in the Switch 2 HOME row — shown while the library is empty,
whatever the flag says. load() asks the core everything in one call off the UI thread —
discover(), sources() (whose first call in a process asks every store over the network whether
its sign-in holds), settings(), gpu(), the global launch keys and the gog source’s settings —
loading true meanwhile, and builds steps ({id, title, subtitle}): found, stores (when an
enabled, available source is signed out, or — where config_writable — one that is off has its
launcher on this machine), install, data and preferences (all three only where
config_writable), and done; step, stepId, title, subtitle (the step’s, the done step’s following what came
in: “Still adding games” while an import runs, “Nothing added yet”, or the read-only config’s owner,
config_owner), next(), back(), finish() (sets the flag, emits finished; next() on the
last step finishes). Every step is one rows/groups list in the settings forms’ shape, so each
look draws it with its settings rows and its value editor; the form leaves quiet rows out unless
every row is one. found is a row per launcher found on the machine — an action row (via) where
Universe can take the games over: one verb, action “Add” with verb set (display “Add N games”,
detail where they go; the Universe look draws no arrow for a verb row), runImport(index) running
import_lutris(true), import_roms(true) for the emulators’ folders, or having the source the
via names adopt them (for heroic-gog it adds gog_dirs to the gog source’s scan_dirs, any
other source it turns on; then it starts a scan(via) job, whose count is jobResult(job)). The two
importers write the library one after the other — a second one waits as queued — while adoptions
run as jobs beside them, and nothing else waits: every other row and step stays usable. The row’s
state (queued, importing, imported, failed, waiting — a scan that brought nothing while
the source is signed out, run again once that sign-in succeeds) and count follow it, display
the short word (“Waiting…”, “Adding…”, “N games added”, “Nothing new”, “Not added”, “Waiting for a
sign-in”) and detail the reason, wraps so it reads whole. The art of the games an import or a
scan brought is fetched after they are in, as a media job over their ids (mediaRefreshMany); the
client does the same for every scan and install job that ends with games, and 5 s after a start
without a key script for the games no refresh has reached (fetchUnfetched, over media_unfetched). Under a read-only config, a
launcher whose adoption needs a write (its source off, Heroic’s folders missing from scan_dirs) is
a static row whose detail says what to add where (home-manager when config.toml is a link into
the Nix store, else config.toml). Other launchers are static rows (“No games”, quiet; “N games ·
not importable yet”, “· needs <program>”, “· no <via> source”). When two or more can still be added,
“Add everything” (everything) leads and runs them all. Then, under “Runners your games need”, one
row per missing runner the library needs that Universe can install (via: "component"; runImport
installs it through api.screens.components, the row reading “Installing…” meanwhile; an import or
an adoption that brought games asks the components again). stores lists each source’s Account row
and its sign-in rows (login_rows, worded from the source’s [login]: “Get a sign-in link” and
“Enter the code” for a code, “Get an API key” and “Enter the key” for a key, login the kind,
prompt the editor’s title; both quiet once the source is signed in), or for a source still off
a Use <name> switch (enabled, a bool row) whose sign-in rows replace it once on, through the
shared api.screens.login — ui/LoginCard.qml, switch2/ui/LoginCard.qml and
ps5/ui/SettingsLoginCard.qml are the QR, URL and status card FormPage shows too; while one is up
the setup keeps that store’s rows alone in the list, and shrinks the code (qrSize) before they
are cut. install lists the folder in use (install_dir, static, “In use”), the other launchers’
(install_dirs, an action row with via: "folder" whose runImport writes paths.games_root),
then “Another folder…” (paths.games_root, a path row): it moves nothing already installed.
data holds paths.prefixes_root and paths.saves_root (path rows: a prefix already elsewhere
stays, a game’s data page moves it) and saves.auto_backup (a bool row).
preferences holds the controller family (controller.family, an enum over
api.screens.controller.families, written with setFamily) and launch.hdr when it fits this GPU
(the upscaler upgrades are left to Settings). done holds a row per launcher still on its way or
that brought games, and per store signed in (state again), and, under a read-only config, why the
install folder and the preferences were skipped. idle is true when no row on the step does
anything: the looks then put the focus on Continue (Finish on done), on the step’s arrival and the
moment its last row stops doing anything (Add everything, an import that took the last row). added is true once games
came in or are on their way. Under the rows sit two buttons, Back (“Skip setup” on the first step,
“Close” once added) and Continue (“Finish” on the last), reached with Down past the last row,
Left/Right between them, A to press one — B and X do the same from anywhere in the dialog, the
hint bar showing both in the Universe look and PS5, their glyphs on the buttons in the Universe look and Switch 2; B on the first step asks “Skip setup?” first (Keep going, Skip),
unless added. The Universe look and Switch 2 count the steps (“2 / 5”) over the title, PS5 draws them as
dots. The Universe look’s Home rail lands on its first game when it fills while the page is up (the setup
importing behind the dialog, the add page), as a cold start does.
The GTK app’s first run (dialogs/onboarding.rs) has the same steps and the same flag: the found
page’s Continue, in a bar of its own under the content and the one suggested button of each page,
waits for the look to end; each launcher has an Add button and Add Everything presses them all, the
two importers taking turns; closing the dialog before any game came in and before the last page asks
“Skip Setup?” (Keep Going, Skip), and Add Games › First-Run Setup opens it again; the stores page
offers a source that is off but has its launcher here behind a “Use” switch; under a read-only
config a launcher says what to write instead of failing; the done page follows the imports still
running; its Game Data page sets the prefixes and save backups folders and the backup after each
session. Under the launchers, “Runners Your Games Need” lists the components the big screen’s
needed() would (a proton, wine or emulator build whose proposal is install), each with an
Install button that goes through components::act, asking first; the group reads the listing again
when a component’s job starts or ends, and after an import brought games.
What’s new
Section titled “What’s new”The core embeds the CHANGELOG.md it was built from (changelog::releases(), no network), and
api.screens.changelog serves it: releases, every release newest first, and pending, the ones a
start after an update shows. Each row is {version, dateText, sections: [{title, items}]}, an item
one line of Text.RichText (styled(): the Markdown’s code in a monospace span at the line’s size,
which Text.MarkdownText would shrink; bold; a link’s text). pending is the core’s whats_new(),
asked once as the screens are built: it records this version in $XDG_STATE_HOME/universe/last-version
on every start, whether desktop.whats_new is on or not, and hands back the releases since the
version recorded before only when it is on (on by default; its switch is in Settings › About and in
Game defaults › Updates, both desktop.whats_new: opensAfterUpdate reads and writes it, and follows
the other through the client’s configWritten). No file —
a first start, or the first after an upgrade from a version that kept none — shows nothing, and so
does a downgrade. dismiss() empties it.
Each look’s start() opens the page as its third branch, after the Themes landing and the first-run
setup, so never over the setup and never on a first run: the Universe look as a dialog over the page
underneath, ui/ChangelogDialog.qml through openChangelog(true) (show(fresh): a centred card on a scrim,
its hints in the bar through overlayHints, the screen under it left as it was; a click outside the card,
either button, is B), Switch 2 and PS5 their own pages/ChangelogPage.qml pushed with { fresh: true }.
A fresh page lists pending under “What’s new” and dismisses it when it closes; Settings › About’s
What’s new and Version rows open the same page on releases, under the same title.
Up and Down scroll it, B closes it, and the Universe look’s isScreenUp/isScreenDown and isFirst/isLast
page and jump.
Universe Desktop records and asks the same way from core_ready, after its first-run dialog and
never with it, nor in a scripted run (UNIVERSE_DESKTOP_SCRIPT, which leaves the version
unrecorded), and presents dialogs/whats_new.rs over the window when something is new; the main
menu’s What’s New (app.whats-new) presents it on the changelog from this version down (all of it on a
build the changelog does not name), and the About dialog’s What’s New holds the same (about_notes, AppStream markup:
each older release under a “Version X · date” line, the section headings translated).
The artwork pages and section
Section titled “The artwork pages and section”api.screens.artwork is one game’s artwork (the Universe look: pages/ArtworkPage.qml, the Artwork entry of a
game’s menu or a cell of the Settings section; the Switch 2 look: switch2/pages/ArtworkPage.qml
from Software Options, and ArtworkSlotPage.qml pushed over it, which shares the screen — only the
page below unloads it). load(id) reads media_status into slots — one row per slot with url
(what shows), defaultUrl and overrideUrl (the two layers, see docs/api.md), kind
(picked, default, missing), kindLabel (the Switch 2 pill: “Your pick”, the provider that
fetched the default, or “Missing”), originLabel and defaultOriginLabel, hasOverride,
hasDefault, aspect, use (where the themes show the slot, Switch 2’s caption) — and entry, the
catalogue game the art is matched to (“Name (year)”; matched when there is one), GOG GamesDB’s, or
SteamGridDB’s once the player’s key is set (sgdbKey; catalogue names the one in use), named
above the candidates only when entryDiffers (its name is not the game’s), so a wrong match is
seen and a right one says nothing. The Universe look’s page has two levels: the five slots as art cards (ui/ArtFrame.qml, the one
slot tile both the page and the Settings matrix draw — the box front tall on the left, square
and banner, then background and logo, in two rows beside it, sized to fill the width, each named
under it and nothing more), then a
slot’s browser — what shows now, the default under a pick dimmed beside it, and the candidates as
a grid, each with its provider or, SteamGridDB’s, its votes (a line only for another game’s entry or no candidates) —
opened by A, or straight away when the page is opened with a slot (the Settings section’s A;
B then leaves the page, the cards were never shown). loadCandidates(slot) fetches media_candidates off the UI
thread into candidates (url is the provider’s, thumb what the grid shows, provider and
providerLabel, votes — SteamGridDB’s only), more and candidatesBusy; moreCandidates()
takes the next page. Without the player’s SteamGridDB key every look ends the candidates with an
“Add a SteamGridDB key for more art” cell (kind: "key" among the Switch 2 and PS5 cells): its A
asks for the key (the Universe look’s keyboard sheet, the shell’s prompt in Artwork.js) and addKey(key)
writes keys.sgdb through setConfig and reloads the candidates. apply(slot, url) runs
media_set_url on a thread, useFile(slot, path) media_set_slot (the page’s menu, through
PathSheet / the shell’s browse); both emit mediaChanged for the game once the pick landed and
applied(slot). removeOverride(slot) runs media_unset (X in the Universe look, the “Default under it”
cell or the options menu on the Switch 2); refresh() fetches the missing art. All report
through message. The wrong-match flow is search(query) → hits (id as text — GamesDB’s
pass 2^53 —, provider, name, year, verified, current) → pin(id), which writes the hit’s
provider’s pin (metadata.gamesdb_id, or metadata.sgdb_id with a key) through media_pin and
reloads the candidates; every look searches the game’s own title first — the Universe look’s Y opens the hits in a sheet
whose Y takes another name, the Switch 2 look runs it through the shell’s pick with “Another
name…” as the last choice, then prompt (switch2/pages/Artwork.js). Local URLs carry the file’s mtime as a query
(models.file_url), so a pick that replaces a file at the same path repaints instead of showing
the image cache’s copy.
api.screens.artworkOverview is the Artwork section of the Settings tab (ArtworkOverview.qml, a
column of its own next to the sidebar): the library as a matrix, rows (id, title, slots —
the five slot rows above, in slot order) and columns (slot, label, aspect, use); a
missing slot and a pick are marked on the cell, nothing is counted but missingGames (the games
lacking some). refreshAll() fetches the missing art of every game: the pill above the titles
(Up from the first row), which the section’s fetchRequested(games) turns into a confirm naming
the count; while the job runs the pill reads “Stop · 3/12” and A is cancelRefresh(), which ends
it after the game in hand (“Stopped after 5 of 12 games”). load() reads
media_status for the whole library on a thread; a mediaChanged or libraryChanged reloads it
after a short debounce while the section is on screen.
The controller section
Section titled “The controller section”api.screens.controller is the Controller section of the Settings tab. The host starts
universe controller watch --json --wait as a child for its lifetime ($UNIVERSE_BIN, else
universe on PATH) and reads its event lines: device, gone, button, axis, battery, unknown,
macro, learned, learn_timeout, error, waiting, ready; it writes suspend, resume,
axes, reload, learn (a slot, or an axis role lx ly rx ry learned from the stick held
past 40 % for 150 ms, ABS_X- when thrown the other way; except lists axes that do not answer, the walk’s other stick) and cancel commands on its stdin. A device line
names the pad (vendor, product), its slots (each code and bound), its axes (role → evdev
axis, by the pad’s shape — a pad with RX/RY reads Z/RZ as triggers, one without as its right stick —
or as [controller.axes.<family>] learned it), sdl and sdl_axes (the same slots and roles as
SDL numbers the joystick: b3, h0.1, a5, ~ on a backwards axis) and its battery when it
reads one itself. The screen hands sdl/sdl_axes to the SDL mapper (GamepadThread.setMapping,
gamepad.mapping_fields): SDL runs without hidapi, on evdev like the watcher, and a lettered face
button maps to its letter — the A on the right of a Nintendo-style pad confirms — the rest by
position. The hint glyphs follow the same rule (PadNames.hintSlot). The screen exposes devices,
current, family (the current pad’s, else the last one seen or the one setFamily(id) chose, kept in api.memory as
controllerFamily, xbox until then: the button glyphs of every look follow it), families ({id, name}), connected, status (off, waiting, ready), passive, learning,
testing, the rows/groups of one card, titled with the pad’s name and nothing under it (a Controller picker row when two pads are connected,
a “Test the buttons” and a “Set up the buttons” row while the watcher is ready, then a “Test vibration” row (rumble) for a pad whose device says rumble, a “Nintendo games” enum row whose key is
controller.layout.<family> (choices as printed, A on the right, A at the bottom; choiceValues "", positional, xbox;
its detail naming the printed side from the device’s printed_layout; Switch 2 opens it in the full picker), then a row per button of the family — each
with its slot and family, so the row draws the button’s glyph, and its press and hold
macros, each carrying its label — the extras (back buttons, Fn) first, then the standard
buttons) and bind, unbind, learn, cancelLearn, setTesting, rumble (the watcher’s rumble for 500 ms on the current pad), startWalk, finishWalk, cancelWalk,
skipStep, backStep, declineWalk, suspend, resume, rescan (the watcher’s rescan, and rescanRequested, which
the host hands the gamepad thread: it lets SDL go and lists the pads again; the watcher’s rescanned
comes back as a macroNotice saying how many pads it found) and reconnect(id) (the core’s
controller_reconnect off the main thread, its outcome a macroNotice; false for a pad not listed).
The PS5 Control Center’s Controllers tile (a row per pad, “Reconnect” as its value, then Find Controllers and Controller
Settings, the controller landing) and the Switch 2 Controllers page (a row per pad, then Find Controllers) call the same two. Macros and families come from the core’s controller_state; a write goes
through set_controller_macro/remove_controller_macro and is followed by a reload to the watcher.
The walk (startWalk, from the “Set up the buttons” row) learns the pad’s buttons one after the
other — the standard slots, the family’s extras, then the four stick throws — one learn per step,
each answered by the watcher’s learned or skipped after WALK_STEP_MS (8 s, counted down in
walkStep.seconds; learn_timeout skips too). walking is true throughout and walkStep is
{slot, axis, art, label, prompt, index, count, seconds}; learning names the slot (or the stick)
the art pulses. The mapper is muted like the live view’s, so a press learns and navigates nothing:
the keyboard, a click or a tap on the actions under the step answer instead. Enter (finishWalk, also what
cancelLearn does while walking) stops it and keeps what it set up, as do leaving the section and losing the
pad or the watcher. Escape or Backspace (cancelWalk) stops it and puts back everything it changed: each
slot’s codes as controller_state had them when it began (through controllerSetButton), each stick in
[controller.axes] learned again or forgotten (through setConfig), and the family is not kept under
controllerWalks. A press that was another step’s button earlier in the walk moves it and says so.
Left (backStep, false on the first step; walkStep.back says when it is offered) takes the last step
back: a code goes to the slot it came from (through controllerSetButton) or off the slot it was
given to, a skip is unmarked, an axis is left for the next answer, and the step is asked again.
At the end a message tallies what was set up and what was skipped, and api.memory keeps the
family under controllerWalks (done or declined). A pad of a family neither walked nor given
learned buttons in [controller.buttons] is offered the walk once per session through walkOffered(family, name):
both shells ask, and “Not now” is declineWalk.
While another watcher holds the pads (a game launched from the CLI is running) the child reports
waiting: the screen turns passive, lists the pads from controller_pads under an info row
saying macros run in the game session, binds as usual (the holder reloads on the config’s mtime)
and refuses learn until ready. A watcher that exits reports off: the card says so and the
screen restarts it after restart_ms, doubling up to 30 s until it stays up. A shown pad that
reconnects under a new node becomes current again; a learn the watcher times out clears the
learning state with a message.
Another app taking the focus stops a learn, the walk (keeping what it set up, as Enter does) and
the live view (focusLost(), see “Focus”). The Advanced row’s cards are Timing (controller.hold_ms,
controller.volume_step) and HOME (“HOME opens Universe”, the controller.home_summons toggle).
The page suspends the watcher while the section has the focus, so a back button pressed while
looking at its row fires nothing, and nothing shows either: the SDL mapper is still turning the
pad into keys, and the rows are for binding. The live view (setTesting(true), from the test
row) is where presses are looked at: the pad takes the column, the mapper is muted, the watcher
streams the sticks and triggers (axes {on} → axis {id, axis, value}, lx ly rx ry as -1..1 and
lt rt as 0..1), PadArt’s readouts print each stick’s lean and each trigger’s pull in percent
beside it, and the HISTORY column (ui/PadHistory.js) logs the last 12 presses newest first, each
with the milliseconds since the one before (orange under 30 ms: the pad fired twice), a trigger or a
stick once past half with its pull or its direction. It ends on a Circle/B held for a second or Start and Select
together (both read from the watcher’s button events), on Escape, or by itself when the section
is left, the shown pad goes, another is picked, or the watcher stops or turns passive; Api mutes
api.pad for exactly as long as testing is true.
The art is ui/ControllerArt.qml around ui/PadArt.qml: ui/PadGeometry.js holds each family’s
body (an SVG path) and buttons on a 1000 × 700 sheet, one canvas draws the body and a PadButton
(a component of PadArt) sits on every button — white on a press, a stick leaning with its axes, a trigger filling from the
bottom with its pull (a press past the half only brightens its outline, so the gauge reads all the
way down; dashed when the slot has no code on this connection — PadArt also takes
focusedSlot and learningSlot, which the live view leaves empty and the walk fills; ControllerArt
takes the walk’s step and shows its prompt and countdown under the pad, with a Back and a Stop). While a button is being
learned, its row says so in place of its chips. The caption under the pad keeps its height from
the start, so the first press does
not resize the pad, and spells both ways out with the pad’s own glyphs. Three bodies serve the
families: Sony’s (DualSense Edge, DualSense, DualShock 4), Xbox’s (Elite, Xbox) and the generic one
(Switch Pro, 8BitDo Pro 3 — its L4/R4 as second bumpers inboard of L1/R1, PL/PR as back paddles —
and any family without a body of its own), each family adding its own extras.
With --fake a FakeWatcher stands in: one DualSense Edge with every button bound, or the pad
named by UNIVERSE_FAKE_PAD (a family id, or none for the empty state), with the slots listed in
UNIVERSE_FAKE_UNBOUND left without a button.
Running and testing the shipped host
Section titled “Running and testing the shipped host”man universe-ui lists the options, KeyScript in universe_ui/gamepad.py the --keys names (the mouse among them: Mouse:x,y, Click:x,y, Wheel:x,y,N, HWheel:x,y,N and Scroll:x,y,N (a touchpad’s pixels), a finger: Tap:x,y, LongTap:x,y,ms and Swipe:x1,y1,x2,y2, all in 1080p design units — 1728 wide in a 16:10 window —, and Type:text from the keyboard), just --list the dev recipes.
Universe Desktop
Section titled “Universe Desktop”universe-desktop (app ID io.github.ilyasturki.UniverseDesktop) links the Rust crate: GTK 4.22
and libadwaita 1.9 over the same core, in the same process. Its pages follow the Universe look’s, less what
belongs to the couch (the HOME dock, the hint bar, the on-screen keyboard, the pad driving the
screens), plus what the CLI gives a desktop: the hidden games, removal that trashes the Wine prefix,
rescans, the art fetched again, the store’s catalogue search and the GNOME Shell extension’s setup.
- The bridge (
backend.rs): the core runs on a tokio runtime of its own, which the main loop awaits throughrun(a future),call(a core method) andpinned(a core future that is notSend—launch, the source verbs,uninstall(a store’s own runs one),sources(),media_*,achievements— on a blocking thread). Nothing blocks the main loop,launchand its pre-launch hooks included. - Changes:
universe::changes::watchwith the journal sweep on, each event re-emitted on the application (changed). The library keeps oneGameObjectper game across refreshes, so a bound card follows its game. - Ownership:
adopt_scope()at startup, asuniverse-uidoes. Closing with a game running or a job that can stop asks first; then the game is stopped and itssession-endwaited for, an install or update pauses (the Store resumes it) and the window goes. SIGINT and SIGTERM take the same way out. - One job at a time (
jobs.rs): an install, an update, a scan, an add (one install into the library,source_adopt; like a scan it runs to its end), an art refresh or a component’s install or update (Application::start_component, aComponentJob; Stop cancels it throughcomponent_cancel). A store’s “update all” steps through its games, so each can be cancelled. The Store page shows the job; its end is a toast — on the preferences when they are open — and a notification for an install or update when the window is not focused. - Runners (Preferences › Runners, as Settings › Emulators & Proton in
universe-ui): the runners found, by the games on them, each with the build in use as its subtitle and what waits on it (an update, a newer build than the system’s, Needed) beside its game count, with the button that does it (components::quick: Update, or Install… asking first); then “Not Installed” (Universe can download it, a runner a game waits on first), “No Download” and “Tools” (gogdl, legendary, comet, butler, gamescope, MangoHud, gpu-screen-recorder). A runner’s page puts a Builds group under its Runner card (FormView::add_head): one row per component it installs — Proton’s families and umu-run, or the runner’s own. On Proton’s page the core’s runner form gives the Proton build key a Builds card of its own, every Proton found among its choices, and the component rows sit right under it. A component’s row (components.rs, the plumbing the first run can use too) shows the build that runs and its tag, and its menu holdsactions(): install, update, use a build, follow the newest, another version, roll back, remove, uninstall; what costs something asks first in anAdwAlertDialog, an install showing its notice and size. While its job runs the row shows the progress and Stop, and both pages read the listing again when a component’s job starts or ends (components::on_jobs; a store’s or a scan leaves them be): the rows are rebuilt in one go where they stood, no spinner, the scroll and the focused row kept (take_focus,refocus). A System Check that an install fixes gets an Install button that runs the same flow. - Settings forms (
form_view.rs, every page overCore::form): a change applies at once, to the page’s own scope. On a game’s or a runner’s page every row names where its value comes from in a caption (This Game, Runner, Global, Default, Found), and a list shows its value bare, its first entry naming what clearing the row comes to (Default · On). A row the page sets has a reset button (Back to Default · On) and, on a game’s page, one that applies the value to all the games it reaches (Apply to all Dolphin games,promote_field), both named on hover and to a screen reader; the toast says what came of it, as the looks’ does. A module’s and a source’s page ends in Game Defaults: the value every game takes unless it sets its own, where the all-games one lands and where its reset undoes it. The Advanced row names the cards its page holds. - Undo, not confirmation, for what can come back: hiding, favourites, removing a game, a
screenshot, a recording, a journal entry. A removal is held (
Application::defer) while its toast shows and done when the toast goes, or on quit; Undo, or Ctrl+Z for the newest toast, drops it. Uninstalling and removing with the Wine prefix ask instead. The uninstall dialog names the store that removes the files itself (uninstall_via), and removing with the prefix is offered only for one underpaths.prefixes_root: a purge leaves a store’s, Steam’scompatdata. - Stores: the Store page’s switcher lists the signed-in stores, each with its label and icon in
the library; it hides with one, and the search names the store it asks (“Search GOG”). Each row
shows its verb — Play for a game installed and in the library, Resume, Install, Update — with a
menu beside it (an installed game the library lacks: Add to Library, an add job, and Delete
Files…, which names the folder and asks first; one it holds: Uninstall…), and Refresh is the
header’s button. Its groups are On This PC (the folders looked in), Not Installed (in your
account) and In the Store (search results outside the listing); an installed row reads In your
library or Not in your library, the second with the folder it was found in. The first run adopts what
discover()finds through the store a launcher’svianames, turning it on when it is off; one that found nothing while signed out is listed on the stores step, and adopted again once its sign-in succeeds. - Search provider:
org.gnome.Shell.SearchProvider2at/io/github/ilyasturki/UniverseDesktop/SearchProvider, registered with the application on the bus and answered once the core has opened; a result opens the game’s page and starts it, “search in the app” carries the terms to the library’s search. The shell finds it throughgnome-shell/search-providers/io.github.ilyasturki.UniverseDesktop.search-provider.iniin a directory ofXDG_DATA_DIRSonly — a distro package or a Nix profile, not install.sh’s~/.local/share— and starts the app for it through the D-Bus service file (--gapplication-service, which stays up a minute between searches), whoseExecmust name the wrapper by its absolute path: the bus runs it in its own environment. The desktop entry isDBusActivatable, so the app grid starts it that way too. - Cards and rows: a game card shows Play and its ⋮ on hover and on keyboard focus (
:focus-withininstyle.css), its title on two lines with the whole of it on hover. A right click, or a long press on a touchscreen, opens the game’s menu (menus::game,actions::context_menu) where it landed: on a game card, a media card (the game it is of) and a Store row whose game is in the library. A media card’s text wraps inside its picture’s width, so a journal excerpt leaves the grid even. - Narrow windows: under 730 px the sidebar folds away; the library’s search names the view it
searches (“Search Nintendo Switch”), so the header says where you are with the sidebar hidden.
Under 600 px of content the cards shrink (
game_card::cover_size, two columns on a 360 px screen); under 550 px Media’s toggles turn to icons, named on hover. The running game sits in a bar under every page (playing_bar, the window’s ownNowPlaying) whenever the sidebar’s foot is out of sight: folded, hidden with F9, or under a page pushed over the home. - Game page (
pages/game.rs): the library’s row alone draws the hero at once (the byline, the facts line with the runner, then the installed version while the status iscurrentorupdateand Up to date while it iscurrent; the achievement count left to its row below), and under the title only an update mark (Update available forupdate, nothing otherwise and never the version). The game’s menus offer Update for a store update and no Mark as Current. The body under the hero (Your Play, About, the Gallery of the store’s pictures) waits in aloadingstack child until the details land (get, the screenshots’ count and whether the source’s manifest lists achievements, read without asking the stores), so it shows in one frame and nothing already drawn moves; the backdrop fades in on its own once decoded. The header names the game once its title scrolls away, and every page over it (Achievements, Journal, Sessions and Logs, Screenshots, Recordings, Saves and Storage) has the game under its title. The Journal lists the entries, newest first, then folds the played sessions without one into a single “N sessions without an entry” expander, each with Write; an entry shows only the pictures still on disk, from the text’s edge. A picture that is missing shows what it stands for — a pad for a game, a page for an entry, a camera for a shot — not the app’s mark. Sessions and Logs says in words how each session ended and what it left, a warning only on a crash or a kill. Achievements puts the unlocked and the locked under headings of their own, an All · Unlocked · Locked toggle showing one. - Game data: Saves and Storage (
pages/game_data.rs) is a page of its own behind a Your Play row, read once it opens behind aloadingchild, since sizes walk the folders and the saves ask ludusavi and the store; an action or a library change refills it in place. It holds the saves with Back Up, the backups in an expander (Restore each, asking first; Export to the home folder), the prefix with its size and a menu (Open Folder, Move Into Universe’s Prefixes, Wine Configuration, Winetricks, Run a Program, Stop the Prefix’s Programs, Reset), then the install folder, Universe’s files and the recordings, each with Open Folder. Storage, a dialog off the main menu (pages/storage.rs, aStorageViewpage), lists the roots with their free space, the games by size (a row opens the game’s page with its Saves and Storage over it) and the leftovers, each with Move to Trash behind a question. - The main menu (
menus.rs, built once and held by every page’s header; the Store’s adds Find Installed Games on top). Preferences keeps Launch, Runners, Stores, Modules and Controller (System too on a Deck). Launch (Game Defaults) ends its Updates card in a Game Updates row whoseprefs.game-updates(the dialog’s own action) pushesdialogs/game_updates.rs: the games not hidden whose status isupdate, by title, with Update (how == "apply"), I Have the Latest Version (markable, asking first with the installed version and the day of upstream’s newest build, thenmark_current) or Update it by hand; then theunknowngamesmarkabletakes in a collapsed Games Universe Can’t Check expander. It fills again on every library change. Stores and Modules list each one with its whole description, its Markdown drawn (rows::marked), or what keeps it from working (a setting it waits on by its label), Off beside the chevron when it is off: the switch is on its page, and coming back reads the list again in place, the focus kept. An unlisted extension says Unlisted there. Both end on “Get more…”: a subpage (addons.rs) of the index’s add-ons of that kind, each opening anAdwAlertDialog(an install’s names the kind, that it runs programs as you, and the index’s host; an installed one’s is its removal, with its update beside it), the list and the page read again once it lands.app.preferences-pagetakesaddons-moduleandaddons-sourceto open there. What was not a preference left it for a dialog of its own, each anAdwPreferencesDialogof one page so a toast lands on it: Library Artwork (dialogs/library_artwork.rs), System Check (dialogs/system_check.rs,universe doctor’s checks; on GNOME the Shell extension’s sits in a System Setup group of its own, and nowhere else) and Storage.app.preferences-pagewithstorage,artworkordoctoropens those dialogs. About lists System Check among its Details links, and its Troubleshooting holds the debugging information (debug_facts, then every failing check with its fix once the checks land). - Big Screen: Open Big Screen, in the main menu when
universe-uiis on PATH, runs it and leaves the window open; not while a game runs, nor twice. - Steam Deck: on a Deck (
deck::model()), Preferences › System sets whatsystem_controls()lists but the backlight, which the desktop sets, throughset_system; likeuniverse-ui, the app callsapply_system()at startup. - Controller: Preferences › Controller runs the watcher while its page shows. The window losing
the focus (
is-active) ends a learn, the walk and the button test, so a press made in another app teaches the page nothing; its Big Screen group holds “HOME Opens Universe” (controller.home_summons, see “Focus”). - State:
$XDG_STATE_HOME/universe/desktop.jsonholds the window size, the sidebar’s pick, the sort and whether hidden games show; the first run’s flag is the core’sonboarded(), the oneonboardedof earlier versions carried over to it. A recording’s frames come fromuniverse::frames, the Qt host’s cache. - Changelog: the main menu’s What’s New and About’s list every release from this one down, and unless Preferences › Game Defaults › Updates is off, the first start after an update opens what changed since the last (see “What’s new”).
What cost time:
- A builder’s
use_markup(false)on anAdwActionRoworAdwExpanderRowreaches the labels after itstitleandsubtitle, so a title with&or<fails to parse: rows are made throughrows::plain, which sets it first, and every toast goes throughWindow::toast, which turns it off. - A drawing whose height follows its width trips GTK’s measure check inside a scrolled preferences page: the pad art keeps one height.
AdwApplicationloadsshortcuts-dialog.uifrom the resource base path, not fromui/: the build script aliases it there, and libadwaita addsapp.shortcutsand Ctrl+? itself.- Ctrl+Z presses a toast’s Undo as its button does,
button-clickedthendismiss(): the other order would commit the removal it undoes.
just desktop runs it against .dev/. just desktop-shot DIR [steps…] runs it in a headless
weston and saves shots: size:WxH, wait:MS, action:NAME[::TARGET] (~ for a space, looked up
on the widgets on screen, so a page’s own group answers: win.open-game::<id> opens a game’s page,
app.preferences-page::stores a Preferences page, app.system-check a dialog) and shot:NAME.png, with
GDK_DISABLE=offload,dmabuf so a playing video is part of the window’s render node. A scripted run
is NON_UNIQUE, adopts no scope, sweeps no journal, checks no updates and saves no state; it cannot
open a popover or answer a dialog. man universe-desktop has the options and keys.