Skip to content

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).

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.

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.

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).

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.

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.

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.

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.covered holds 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. With controller.home_summons off (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.

These cost real time to discover; they are properties of Qt 6.11 / PySide6 6.11, not of Universe.

  • QtQml.Models.SortFilterProxyModel cannot carry the theme’s filters. It has no get(), no ExpressionFilter, and its FunctionFilter segfaults. Every proxy the theme needs is instead a QSortFilterProxyModel subclass. SortedGames, RecentGames, RecentFirstGames (the Universe look’s Library: every game in RecentGames’ order, the never played last), LimitedGames, SearchGames and LibraryGames are exported to QML as import Universe; CollectionGames is instantiated Python-side, one per collection. get(i) returns the Game; sourceRow(i) stands in for mapToSource(i), whose C++ name is virtual and breaks sorting if shadowed. RecentGames admits a game played or added (added_at) and orders by the later of the two (recentAt). Every proxy breaks a tie by sortTitle, A to Z whichever way it sorts. HeadedGames (a list model, not a proxy) puts head — api.screens.sources. arriving, the ArrivingGame of a store install under way, installing true and progress 0–1 — before source’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.installing and progress live on Game for that reason.
  • QML import paths. The host sets none: QML2_IMPORT_PATH comes from the flake — the dev shell’s hook (every just recipe), wrapQtAppsHook for the package, the pytest check — and so does QT_PLUGIN_PATH for the first two; a plugin must link the same qtbase as PySide6, or it is refused. .venv/bin/universe-ui from a plain shell finds no QML modules.
  • Software rendering has no shaders. The offscreen QPA loads the software scenegraph, where OpacityMask, FastBlur, ColorOverlay and ShaderEffect render nothing. The affected components test GraphicsInfo.api === GraphicsInfo.Software and 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 --keys scripting 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 is api.focus, not Qt (see “Focus”). Pad:A, PadHold:A and PadRelease:A post through the pad thread and meet that gate as the pad’s presses do; Press:slot and Unpress:slot reach 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), or store.rows[i] inside a loop over store.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 a property var of the page) and it is read once. readonly property var rows: store.rows is fine; so is a single store.rows.length or store.rows[3].
  • Behind a game or another app the scene holds still. Theme.covered (the core Theme, which the PS5 and Switch 2 ones read) is api.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 are asynchronous, so activePage is null for a frame or two after a switch; every binding on it null-checks, and a --keys script presses after a Wait.
  • A view’s cursor is a row, not a game. RecentGames pins the playing game first and the stats re-sort a game after its session; no view index follows, and a positional model.get(index) binding does not re-evaluate on a reorder. GameAnchor (import Universe) does: bind model and index to the view’s and client to api.universe, read game, follow moved(index); hold(id) picks the game to follow, and the anchor holds the one whose session just ended.
  • Collections are platforms. A collection’s shortName comes from the core’s platform string, which api.py maps (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.

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.

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 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.

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.

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.

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).

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, a question asks first (ConfirmDialog), run opens the value editor’s folder sheet on a file. Settings › Storage lists api.screens.storage in the settings cards, loaded when the section opens; a game’s row raises dataRequested(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 pushes switch2/pages/StoragePage.qml, as the console’s own. Both are SettingsRows over the rows, the questions through shell.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 pushes ps5/pages/StoragePage.qml, and shows the free space from loadFree(), read each time the hub shows (it no longer waits on the sources).

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.

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.

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.

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.

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.

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.

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.

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).

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.

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.

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 (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 through run (a future), call (a core method) and pinned (a core future that is not Send — launch, the source verbs, uninstall (a store’s own runs one), sources(), media_*, achievements — on a blocking thread). Nothing blocks the main loop, launch and its pre-launch hooks included.
  • Changes: universe::changes::watch with the journal sweep on, each event re-emitted on the application (changed). The library keeps one GameObject per game across refreshes, so a bound card follows its game.
  • Ownership: adopt_scope() at startup, as universe-ui does. Closing with a game running or a job that can stop asks first; then the game is stopped and its session-end waited 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, a ComponentJob; Stop cancels it through component_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 holds actions(): install, update, use a build, follow the newest, another version, roll back, remove, uninstall; what costs something asks first in an AdwAlertDialog, 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 over Core::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 under paths.prefixes_root: a purge leaves a store’s, Steam’s compatdata.
  • 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’s via names, 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.SearchProvider2 at /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 through gnome-shell/search-providers/io.github.ilyasturki.UniverseDesktop.search-provider.ini in a directory of XDG_DATA_DIRS only — 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), whose Exec must name the wrapper by its absolute path: the bus runs it in its own environment. The desktop entry is DBusActivatable, 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-within in style.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 own NowPlaying) 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 is current or update and Up to date while it is current; the achievement count left to its row below), and under the title only an update mark (Update available for update, 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 a loading stack 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 a loading child, 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, a StorageView page), 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 whose prefs.game-updates (the dialog’s own action) pushes dialogs/game_updates.rs: the games not hidden whose status is update, 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, then mark_current) or Update it by hand; then the unknown games markable takes 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 an AdwAlertDialog (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-page takes addons-module and addons-source to open there. What was not a preference left it for a dialog of its own, each an AdwPreferencesDialog of 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-page with storage, artwork or doctor opens 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-ui is 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 what system_controls() lists but the backlight, which the desktop sets, through set_system; like universe-ui, the app calls apply_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.json holds the window size, the sidebar’s pick, the sort and whether hidden games show; the first run’s flag is the core’s onboarded(), the one onboarded of earlier versions carried over to it. A recording’s frames come from universe::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 an AdwActionRow or AdwExpanderRow reaches the labels after its title and subtitle, so a title with & or < fails to parse: rows are made through rows::plain, which sets it first, and every toast goes through Window::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.
  • AdwApplication loads shortcuts-dialog.ui from the resource base path, not from ui/: the build script aliases it there, and libadwaita adds app.shortcuts and Ctrl+? itself.
  • Ctrl+Z presses a toast’s Undo as its button does, button-clicked then dismiss(): 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.