← Otto Developer GuideColor Scheme

How Otto decides whether the desktop is light or dark, and how applications find out.

The setting

One option in otto_config.toml:

theme_scheme = "Light"   # or "Dark"

It controls two separate things:

  1. Otto’s own UI: dock, app switcher, window decorations, menus. Read from src/config/mod.rs and consumed by src/theme/ and the various view modules.
  2. Client applications, over D-Bus, through the XDG Settings portal.

Why applications need a portal for this

An application cannot read otto_config.toml: it may be sandboxed, and it should not have to know which compositor it is running under. The desktop-wide answer is the Settings portal (org.freedesktop.portal.Settings), where a toolkit asks for the color-scheme key in the org.freedesktop.appearance namespace and gets back a number:

ValueMeaning
0no preference
1prefer dark
2prefer light

GTK4 apps query this automatically. So does Firefox, and Chromium via its portal integration. GTK 3, libadwaita outside a sandbox and Qt 6 (through Qt’s gtk3 platform theme, which Otto names in QT_QPA_PLATFORMTHEME) read GSettings’ org.gnome.desktop.interface color-scheme instead, so a session of Otto’s own writes prefer-dark or default there at startup and on every change (src/gnome_interface.rs, see Text rendering).

The chain

App  →  org.freedesktop.portal.Settings          (xdg-desktop-portal)
     →  org.freedesktop.impl.portal.Settings     (xdg-desktop-portal-otto)
     →  org.otto.Settings                        (the compositor)
     →  theme_scheme in the config

GTK 3, libadwaita, Qt  →  GSettings color-scheme  ←  gnome_interface::sync_color_scheme

Compositor service: src/settings_service.rs registers org.otto.Settings at /org/otto/Settings, exposing GetColorScheme() and GetIconTheme() alongside the general settings API. It is started during the compositor’s D-Bus service initialization.

Portal backend: components/xdg-desktop-portal-otto/src/portal/settings.rs implements org.freedesktop.impl.portal.Settings, handling ReadAll() and Read() per spec, including namespace glob filtering. It reaches the compositor through the D-Bus proxy in components/xdg-desktop-portal-otto/src/otto_client/settings.rs.

The backend is registered as org.freedesktop.impl.portal.desktop.otto and declared in otto.portal alongside its other interfaces.

GetColorScheme and GetIconTheme are a frozen contract. The portal backend depends on them and they must keep working even as the wider settings API grows. See settings-dbus-api.md.

Verifying

# Ask the portal what the desktop's preference is
gdbus call --session \
  --dest org.freedesktop.portal.Desktop \
  --object-path /org/freedesktop/portal/desktop \
  --method org.freedesktop.portal.Settings.Read \
  org.freedesktop.appearance color-scheme

# Ask the compositor directly, bypassing the portal
busctl --user call org.otto.Settings /org/otto/Settings \
  org.otto.Settings GetColorScheme

If the two disagree, the portal backend is stale — a backend from an earlier login can still own the bus name. See the note in screenshare.md.

Changes reach running applications

The portal backend subscribes to org.otto.Settings’ Changed signal and re-emits the settings it serves as the portal’s own SettingChanged (portal::spawn_change_relay). Otto’s identifiers are not the portal’s keys, so only the ones with a counterpart are forwarded. The scheme moves two keys: the accent is usually a palette name, and the sRGB triple that name stands for is a different colour under the other scheme.

org.otto.Settingsorg.freedesktop.appearance
theme_schemecolor-scheme, and accent-color
accent_coloraccent-color
icon_themeicon-theme

otto-kit apps pick these up through the watchers in color_scheme.rs, accent.rs and icon_theme.rs, started by AppRunner.

The other four namespaces

Appearance is the namespace this page is about, but the backend serves five, because most of what Otto has to say has no freedesktop key:

NamespaceKeys
org.gnome.desktop.soundtheme-name, the one GTK and libcanberra already read
org.gnome.desktop.interfacefont-name, font-antialiasing, font-hinting, font-rgba-order, font-rendering: Otto’s font and text rendering, which GTK 4 reads (see Text rendering)
org.otto.desktoplocales, rounded-corners, frosting, window-controls-side, maximize-button, tiling-decoration
org.gnome.desktop.wm.preferencesbutton-layout (close,minimize: on the left, :minimize,close on the right, with maximize when the zoom button is shown): where a toolkit drawing its own titlebar puts the window buttons. GTK 4 and sandboxed apps follow it live; GTK 3 outside a sandbox, Chrome among them, reads dconf instead, which Otto does not write

The org.otto.desktop keys are Otto’s own, and they are there because they have to reach Otto’s own windows live. They are seeded from the environment, and a running process cannot be told about a changed environment. Each is served as the schema’s own variant, so a key the compositor does not know about is absent rather than guessed at. The relay forwards their settings too.

When the portal is not there

The portal backend is optional: a session without xdg-desktop-portal-otto running answers nothing. With no other source, the top bar and every otto-kit app would render light on a dark desktop.

So the compositor also publishes the configured scheme in the environment, the way it publishes corner rounding and the window-controls side:

OTTO_COLOR_SCHEME=dark    # or light

otto::export_color_scheme() (src/lib.rs) is called from main before anything is spawned, and the assignment is pushed into the systemd and D-Bus activation environments alongside WAYLAND_DISPLAY (src/state/mod.rs), so a bus-activated helper gets it too, although it is not a child of the compositor and inherits nothing from it.

color_scheme::current_color_scheme() prefers the portal’s answer and only falls back to the environment when the portal has reported nothing, so a value inherited at startup can never clobber a later, more authoritative reply.

The environment half is startup-only: a process reads OTTO_COLOR_SCHEME once, and nothing can tell a running process the variable changed. theme_scheme itself is a live setting, so a switch reaches running applications through the portal relay described above and through nothing else.

Accent colour

accent-color is served as (ddd): sRGB in 0.0..=1.0, no alpha, as the spec requires. The compositor stores the accent by name (see theming); GetAccentColor on org.otto.Settings does the palette lookup and the conversion, so the portal passes the triple straight through.

Inside the compositor the accent takes a shorter path. Otto draws its window decorations with otto-kit’s WindowDecoration, which tints the traffic-light controls from accent::current_accent(), the global the portal watcher fills in a client. Otto cannot use that watcher: it is what answers the portal call, so it would be querying itself. theme::publish_accent() writes the resolved colour straight into the same store with accent::set_accent, at startup (Otto::init) and again whenever accent_color or theme_scheme is applied live, and theme::accent_color() reads it back out. One store on both sides, so a compositor-drawn titlebar and an otto-kit client’s own titlebar cannot disagree.

Because the accent is read inside render functions rather than held in view state, applying it re-renders rather than updates: rerender_accent_colored_views rebuilds the workspace selector, the window selectors, and every window’s decoration layer.

Gaps

Spec references