Desktop events in a shell

What a shell receives about the desktop around its windows: displays, theme, tray icons, notifications and application dialogs. Part of How a shell works.

Displays

The shell is one page covering the bounding box of every display, in logical pixels. Each display is a rectangle in it.

import { useDisplays } from "@domicile-desktop/react/displays";// DomicileDisplay: { name, x, y, width, height, scale, modeWidth, modeHeight, transform }const Bars = () =>  (useDisplays() ?? []).map((display) => ( // undefined until described    <Bar display={display} key={display.name} />  ));
  • On a tty the engine shows the page on every monitor, rotated and scaled per the config. Pointer events are always in page pixels. The shell does nothing for rotation or scale.
  • modeWidth×modeHeight (the panel's unrotated scanout pixels) and transform are informational. A 4K panel rotated 90° at scale 1.2 has a 3840×2160 mode and an 1800×3200 box.
  • Config: The config file, transform.

Theme

theme.mode in the config is "dark" or "light".

  • The shell reads domicile.theme.current; theme.on("change") reports changes.
  • The compositor also publishes it to the settings portal (org.freedesktop.appearance color-scheme, followed by GTK4, Qt6, Electron and Firefox) and applies it to browser windows.
  • theme.accent_color, contrast and reduced_motion go to the settings portal and to the shell, as domicile.appearance's accentColor, highContrast and reducedMotion, with a change event. applyAppearance in @domicile-desktop/component-library/appearance applies them to the preset: the accent token, the _contrastHigh condition and shortened animations.
  • theme.icon_theme goes to the settings portal, and draws the tray's and notifications' icons. readIconTheme and watchIconTheme in @domicile-desktop/sdk/icon-theme read it from there, watchIconTheme again on each SettingChanged.
  • prefers-color-scheme in the shell's page follows it.
  • domicile.theme.set("light") changes it until the desktop exits. It does not write the config. A later config edit overrides it.

Windows change theme after the shell has captured its old frame, so a shell can animate the switch:

  1. theme.on("change") fires.
  2. The shell captures its old frame (for example, starts a view transition) and calls domicile.theme.captured(theme). With no animation, it calls it at once.
  3. Once every shell has called it, the compositor repaints the windows and sets theme.windows and fires windowschange.
  4. If no shell calls it, windows switch after about a second.

In a view transition, call theme.captured once transition.ready resolves, and pause the animation until windowschange. Calling it from the update callback is too early:

  • The engine runs the update callback before it has drawn the capture.
  • Until ready, the screen shows the live page, and an <app> in it turns at once.

ThemeProvider in @domicile-desktop/component-library runs the view transition. Implement its ThemeSource.turnWindows to call domicile.theme.captured(theme) and resolve on windowschange (see manganese's theme/host-theme.ts).

System tray

Apps' tray icons (StatusNotifierItem, hosted by the compositor on the session bus) are domicile.tray.items, the full list; tray.on("change") reports changes.

const Tray = () => {  const { tray } = useDomicile();  // { id, title, icon, bus, menu, itemIsMenu }  const items = useWatch(tray, "change", () => tray.items) ?? [];  return items.map(({ id, icon, title }) => (    <button      key={id}      onClick={() => tray.activate(id, "primary")} // or "secondary", "context"    >      <img alt={title} src={icon} />    </button>  ));};
  • The app decides what a click does.
  • icon is a data: URL or "". menu is the item's com.canonical.dbusmenu object path on bus, or "". Read it with watchMenu from @domicile-desktop/sdk/dbusmenu (SYSTEM-TRAY.md).
  • itemIsMenu is the item's ItemIsMenu: open its menu on the primary button instead of sending "primary".
  • Manganese's: tray/Tray.tsx.

Notifications

App notifications, and sites' Web Notifications, are domicile.notifications.list: every uncleared notification, oldest first. notifications.on("change") reports changes.

const Notifications = () => {  const { notifications } = useDomicile();  // { id, appName, summary, body, icon, urgency, actions, clickable, timeoutMs, time }  const list = useWatch(notifications, "change", () => notifications.list) ?? [];  return list.map(({ id, summary }) => (    <div key={id}>      {/* a click (if clickable), or an action's key */}      <button onClick={() => notifications.invoke(id, "default")}>{summary}</button>      {/* clear; the app is told */}      <button onClick={() => notifications.dismiss([id])}>×</button>    </div>  ));};// notifications.reply(id, "On my way"): the text of an "inline-reply" action's field
  • The shell decides which are new. The first list is history.
  • The shell decides how long a toast stays up. A notification stays in the list until cleared.
  • An action with key "inline-reply" (INLINE_REPLY_NOTIFICATION_ACTION) is a reply, not a plain action: pressing it never sends the key. Its label labels the reply.
  • Design: NOTIFICATIONS.md. Manganese's: notifications/.

Application dialogs

Applications ask for dialogs through xdg-desktop-portal, and the compositor is its backend. The shell draws each one. See Dialogs and portals.

A shell must answer each request. One that ignores them leaves the app waiting for an answer: a file picker that never opens, a call that never shares. Either draw them or refuse them:

import { PortalDialogs } from "@domicile-desktop/component-library/PortalDialogs";import { useDomicile } from "@domicile-desktop/react/domicile-provider";// Put it anywhere in your shell.const Dialogs = () => <PortalDialogs domicile={useDomicile()} />;

The full props, for a shell with monitors and keybindings of its own:

import { PortalDialogs } from "@domicile-desktop/component-library/PortalDialogs";<PortalDialogs  domicile={domicile}  screen={focusedScreen}  screenOf={(appId) => screenShowing(appId)}  shellChords={ownedChords(keybindings)}/>;
  • PortalDialogs draws every kind it knows and refuses the rest: Access (a yes/no question), Account (share the user's name and picture), AppChooser (an app list for "Open with" and OpenURI), FileChooser (the component library's FilePicker), RemoteDesktop (which devices and the clipboard an application may control), InputCapture, GlobalShortcuts (review the chords an app asks for), Wallpaper (a picture to preview), DynamicLauncher (an install confirm with an editable name), Usb (a device grant), ScreenCast (a picker of the windows, browser windows or screens to share, each previewed on the desk, or a region to draw; pass shownWindows here too), Print (a printer and its options, or "No printers are set up."), Screenshot (pick the desk, a monitor, a window or a dragged area of a frozen frame, and save it or copy it with Ctrl+C; pass shownWindows so a window includes your frame around it) and PickColor (pick a pixel of it, with a magnifier). Answer Access, Account, Wallpaper and Usb with PortalAnswer.Access() to allow, and DynamicLauncher with PortalAnswer.DynamicLauncher(name). Pass closedBrowsers for browser windows you keep running but show as closed, such as a background inbox's; the window pickers leave them out.
  • The app list, the file picker, the account picture and the wallpaper preview read the system through domicile.files (desktop entries, mimeapps.list, directories, pictures).
  • A dialog goes on the screen screenOf names for the window that asked, or on screen when the application named no window.
  • An Inhibit request is an application holding off logout, user switching or suspend (body.what), listed until it lets go. It asks nothing, so a shell only shows it ("Editor is preventing logout"). The compositor refuses answers to it.
  • It also shows each running screen cast, remote desktop or input capture session, with a Stop button. Pass omitScreenCasts when the shell shows screen casts itself, as manganese's Sharing bar item does.
  • Dialogs and the indicator name an application by its desktop entry, with its icon, else by its app id; no app id is "An application". useApps in @domicile-desktop/component-library/useApps does the same for a shell's own UI, and sourceName in ./source-name names a screen cast's sources.
  • PortalDialogs also fires the chords apps hold. shellChords are the shell's own, which a review flags as taken.
  • Without React, domicile.portals has the requests, parsed (requests), and sends answers (answer). capturing and stopCapturing do the same for running sessions. The shell's own screen recording also takes pauseRecording, resumeRecording and discardRecording; see Dialogs and portals. fireGlobalShortcuts in @domicile-desktop/sdk/global-shortcuts fires the chords.
  • A shell must answer every request. One left unanswered keeps its application waiting until it gives up.
  • The compositor refuses answers while the desktop is locked.
  • portals.wallpaper is the pictures applications set for the background and the lock screen, as paths; usePortalWallpaper in @domicile-desktop/component-library/usePortalWallpaper is the hook. usePictureUrl in @domicile-desktop/component-library/usePictureUrl turns a path into a URL for an <img>.
This page on GitHub