← 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:
- Otto’s own UI: dock, app switcher, window decorations, menus. Read
from
src/config/mod.rsand consumed bysrc/theme/and the various view modules. - 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:
| Value | Meaning |
|---|---|
0 | no preference |
1 | prefer dark |
2 | prefer 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.Settings | org.freedesktop.appearance |
|---|---|
theme_scheme | color-scheme, and accent-color |
accent_color | accent-color |
icon_theme | icon-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:
| Namespace | Keys |
|---|---|
org.gnome.desktop.sound | theme-name, the one GTK and libcanberra already read |
org.gnome.desktop.interface | font-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.desktop | locales, rounded-corners, frosting, window-controls-side, maximize-button, tiling-decoration |
org.gnome.desktop.wm.preferences | button-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
contrastis not served. Theorg.freedesktop.appearancenamespace defines it; Otto has no such setting yet.