feat(qt): two Wallpaper background modes, with the desktop's picture or your own

"Dynamic background" gains "Wallpaper" and "Wallpaper, blurred". The
picture is the desktop's wallpaper — Plasma's appletsrc (the containment
on screen 0, image plugin only; a wallpaper package resolves to its
largest bitmap, a slideshow folder to its first image), GNOME through
gsettings (the dark variant under a dark scheme), Windows' current
TranscodedWallpaper, macOS through System Events — or an image the user
picks (Settings > Appearance > Background image), which outranks it. The
resolved path and its mtime are memoised, so the frequent refreshes
(session entry, a mode switch, the window coming to the front) republish
nothing while the file holds.

"Wallpaper, blurred" sends the picture through the same atmosphere pass
Blurred art gives the cover, held still. "Wallpaper" shows the part of
the wallpaper that lies behind the window, lightly blurred under the
half-alpha chrome, at most a screen-sized texture: the window's place on
the screen comes from Qt where the platform tells it (X11, macOS,
Windows); Wayland hides window positions, so there the crop is centred
until the Plasma window-management protocol is wired. Neither mode waits
for a playing track. Six new strings in eight catalogs.

cc
This commit is contained in:
vicrodh committed 2026-09-14 01:54:57 -06:00
1 parent 7167866286
commit 47b5c14d32
18 files changed
+825 -3

No files matched your search

@@ -7695,3 +7695,27 @@ msgstr "Suche nach anderen Versionen…"
#: crates/qbz-qt/qml/views/AlbumView.qml
msgid "This release"
msgstr "Diese Veröffentlichung"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Wallpaper"
msgstr "Hintergrundbild"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Wallpaper, blurred"
msgstr "Hintergrundbild, unscharf"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Background image"
msgstr "Hintergrundmotiv"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "The system wallpaper. Choose an image of your own instead."
msgstr "Das Hintergrundbild des Systems. Alternativ ein eigenes Bild wählen."
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Choose image…"
msgstr "Bild wählen…"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "System wallpaper"
msgstr "System-Hintergrundbild"
@@ -3350,3 +3350,27 @@ msgstr "Looking for other versions…"
#: crates/qbz-qt/qml/views/AlbumView.qml
msgid "This release"
msgstr "This release"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Wallpaper"
msgstr "Wallpaper"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Wallpaper, blurred"
msgstr "Wallpaper, blurred"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Background image"
msgstr "Background image"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "The system wallpaper. Choose an image of your own instead."
msgstr "The system wallpaper. Choose an image of your own instead."
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Choose image…"
msgstr "Choose image…"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "System wallpaper"
msgstr "System wallpaper"
@@ -7701,3 +7701,27 @@ msgstr "Buscando otras versiones…"
#: crates/qbz-qt/qml/views/AlbumView.qml
msgid "This release"
msgstr "Este lanzamiento"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Wallpaper"
msgstr "Fondo de pantalla"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Wallpaper, blurred"
msgstr "Fondo de pantalla difuminado"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Background image"
msgstr "Imagen de fondo"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "The system wallpaper. Choose an image of your own instead."
msgstr "El fondo de pantalla del sistema. También puedes elegir una imagen propia."
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Choose image…"
msgstr "Elegir imagen…"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "System wallpaper"
msgstr "Fondo del sistema"
@@ -7693,3 +7693,27 @@ msgstr "Recherche d'autres versions…"
#: crates/qbz-qt/qml/views/AlbumView.qml
msgid "This release"
msgstr "Cette sortie"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Wallpaper"
msgstr "Fond d'écran"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Wallpaper, blurred"
msgstr "Fond d'écran flouté"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Background image"
msgstr "Image de fond"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "The system wallpaper. Choose an image of your own instead."
msgstr "Le fond d'écran du système. Vous pouvez aussi choisir votre propre image."
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Choose image…"
msgstr "Choisir une image…"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "System wallpaper"
msgstr "Fond d'écran du système"
@@ -7676,3 +7676,27 @@ msgstr "他のバージョンを探しています…"
#: crates/qbz-qt/qml/views/AlbumView.qml
msgid "This release"
msgstr "このリリース"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Wallpaper"
msgstr "壁紙"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Wallpaper, blurred"
msgstr "ぼかし壁紙"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Background image"
msgstr "背景画像"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "The system wallpaper. Choose an image of your own instead."
msgstr "システムの壁紙。代わりに自分の画像を選ぶこともできます。"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Choose image…"
msgstr "画像を選択…"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "System wallpaper"
msgstr "システムの壁紙"
@@ -7683,3 +7683,27 @@ msgstr "Andere versies zoeken…"
#: crates/qbz-qt/qml/views/AlbumView.qml
msgid "This release"
msgstr "Deze release"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Wallpaper"
msgstr "Bureaubladachtergrond"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Wallpaper, blurred"
msgstr "Vervaagde bureaubladachtergrond"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Background image"
msgstr "Achtergrondafbeelding"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "The system wallpaper. Choose an image of your own instead."
msgstr "De bureaubladachtergrond van het systeem. Je kunt ook een eigen afbeelding kiezen."
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Choose image…"
msgstr "Afbeelding kiezen…"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "System wallpaper"
msgstr "Systeemachtergrond"
@@ -7693,3 +7693,27 @@ msgstr "A procurar outras versões…"
#: crates/qbz-qt/qml/views/AlbumView.qml
msgid "This release"
msgstr "Este lançamento"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Wallpaper"
msgstr "Papel de parede"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Wallpaper, blurred"
msgstr "Papel de parede desfocado"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Background image"
msgstr "Imagem de fundo"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "The system wallpaper. Choose an image of your own instead."
msgstr "O papel de parede do sistema. Também pode escolher uma imagem sua."
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Choose image…"
msgstr "Escolher imagem…"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "System wallpaper"
msgstr "Papel de parede do sistema"
@@ -7714,3 +7714,27 @@ msgstr "Ищем другие версии…"
#: crates/qbz-qt/qml/views/AlbumView.qml
msgid "This release"
msgstr "Этот релиз"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Wallpaper"
msgstr "Обои"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Wallpaper, blurred"
msgstr "Размытые обои"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Background image"
msgstr "Фоновое изображение"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "The system wallpaper. Choose an image of your own instead."
msgstr "Обои системы. Можно выбрать собственное изображение."
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "Choose image…"
msgstr "Выбрать изображение…"
#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml
msgid "System wallpaper"
msgstr "Обои системы"
+1
View File
@@ -656,6 +656,7 @@ fn main() {
"qml/settings/OrbitSettings.qml",
"qml/settings/OrbitSettingsPanel.qml",
"qml/shell/AmbientField.qml",
"qml/shell/WallpaperField.qml",
"qml/shell/AppShell.qml",
"qml/shell/OrbitBanner.qml",
"qml/shell/ArtPreviewOverlay.qml",
@@ -148,6 +148,27 @@ Column {
onSelected: function (i) { QbzBridge.settingsSelect("app-background", i) }
}
}
// The picture behind the two Wallpaper modes: the desktop's, or one of
// the user's own (wallpaper_qt.rs).
SettingRow { kioskHost: root.kioskHost;
visible: (root.doc.appBackgroundIndex || 0) >= 3
label: QbzSession.tr("Background image", QbzSession.trRev)
description: (root.doc.appBackgroundImage || "") !== ""
? root.doc.appBackgroundImage
: QbzSession.tr("The system wallpaper. Choose an image of your own instead.", QbzSession.trRev)
Row {
spacing: 8
SettingsButton { kioskHost: root.kioskHost;
text: QbzSession.tr("Choose image…", QbzSession.trRev)
onClicked: QbzBridge.settingsPickBackgroundImage()
}
SettingsButton { kioskHost: root.kioskHost;
visible: (root.doc.appBackgroundImage || "") !== ""
text: QbzSession.tr("System wallpaper", QbzSession.trRev)
onClicked: QbzBridge.settingsClearBackgroundImage()
}
}
}
// Auto-theme rows (the "auto" theme only).
SettingRow { kioskHost: root.kioskHost;
visible: QbzShell.themeSlug === "auto"
+49
View File
@@ -121,6 +121,55 @@ Rectangle {
opacity: root.veilStrength
}
// Mode 3 — Wallpaper: the desktop wallpaper, the part of it that lies
// BEHIND this window (where the platform tells the window its place:
// X11, macOS, Windows — Wayland hides it, so a centred crop stands in),
// lightly blurred under the half-alpha chrome. Static: it costs a frame
// only when the window moves or resizes (shell/WallpaperField.qml).
// Mode 4 — Wallpaper, blurred: the wallpaper through the SAME atmosphere
// pass Blurred art gives the cover, held still (no transport to follow).
// Neither waits for a playing track (QbzTheme.ambientOn).
readonly property bool wallpaperModeOn: root.ambientOn && QbzShell.ambientMode === 3
readonly property bool wallpaperBlurOn: root.ambientOn && QbzShell.ambientMode === 4
WallpaperField {
anchors.fill: parent
visible: root.wallpaperModeOn
source: root.wallpaperModeOn ? QbzShell.wallpaperUrl : ""
hostWindow: root.hostWindow
blur: 0.35
dim: theme.isDark ? QbzShell.ambientDim : 0.0
}
Rectangle {
anchors.fill: parent
visible: root.wallpaperModeOn && !theme.isDark
color: theme.surfaceMain
opacity: root.veilStrength
}
ImmersiveAtmosphere {
anchors.fill: parent
visible: root.wallpaperBlurOn
source: root.wallpaperBlurOn ? QbzShell.wallpaperAtmosphereUrl : ""
animated: false
dim: theme.isDark ? QbzShell.ambientDim : 0.0
}
Rectangle {
anchors.fill: parent
visible: root.wallpaperBlurOn && !theme.isDark
color: theme.surfaceMain
opacity: root.veilStrength
}
// The wallpaper can change while QBZ runs: re-resolve when the window
// comes back to the front (wallpaper_qt memoises path + mtime, so a
// wallpaper that did not change republishes nothing).
Connections {
target: root.hostWindow
ignoreUnknownSignals: true
function onActiveChanged() {
if (root.hostWindow && root.hostWindow.active && QbzShell.ambientMode >= 3)
QbzShell.refreshWallpaper()
}
}
// The host ApplicationWindow (custom chrome: drag / maximize / resize).
property var hostWindow: null
@@ -0,0 +1,73 @@
// WallpaperField — the desktop wallpaper behind the window (2026-09-14): the
// part of it that lies UNDER this window, so moving the window slides the
// picture the way a translucent window would, lightly blurred beneath the
// half-alpha chrome. Nothing animates: it costs a frame only when the window
// moves or resizes, and a screen-sized texture at most (`sourceSize` caps the
// decode — a 3x-upscaled wallpaper never reaches the GPU whole).
//
// WHERE THE WINDOW IS. X11, macOS and Windows report the window's position;
// Wayland hides it from clients by design, so there the crop is centred on
// the screen until the Plasma window-management protocol is wired
// (wallpaper_qt.rs header). `hostWindow` is the ApplicationWindow.
import QtQuick
import QtQuick.Window
import QtQuick.Effects
Item {
id: root
/// file:// URL of the wallpaper (QbzShell.wallpaperUrl).
property string source: ""
property var hostWindow: null
/// MultiEffect blur amount, 0 = crisp.
property real blur: 0.0
/// Darkening on top (the dark-theme legibility veil).
property real dim: 0.0
clip: true
readonly property bool positionKnown: root.hostWindow !== null
&& Qt.platform.pluginName !== "wayland"
readonly property real screenW: Math.max(1, Screen.width)
readonly property real screenH: Math.max(1, Screen.height)
// The window's origin inside its screen; centred when unknown.
readonly property real offX: root.positionKnown
? root.hostWindow.x - Screen.virtualX
: Math.max(0, (root.screenW - root.width) / 2)
readonly property real offY: root.positionKnown
? root.hostWindow.y - Screen.virtualY
: Math.max(0, (root.screenH - root.height) / 2)
Image {
id: picture
source: root.source
x: -root.offX
y: -root.offY
width: root.screenW
height: root.screenH
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
sourceSize.width: Math.round(root.screenW * Screen.devicePixelRatio)
sourceSize.height: Math.round(root.screenH * Screen.devicePixelRatio)
smooth: true
}
MultiEffect {
visible: root.blur > 0 && picture.status === Image.Ready
source: picture
x: picture.x
y: picture.y
width: picture.width
height: picture.height
autoPaddingEnabled: false
blurEnabled: true
blur: root.blur
blurMax: 48
}
Rectangle {
anchors.fill: parent
color: "#000000"
opacity: root.dim
}
}
+4 -1
View File
@@ -161,7 +161,10 @@ QtObject {
// renderer-tier arm: the Qt field renders on the software path too
// (ShaderEffect where shaders exist, Canvas where they do not), so the
// feature is never taken away from a weak GPU here.
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
// The wallpaper modes (3, 4) do not depend on a playing track: the
// picture is always there.
readonly property bool ambientOn: QbzShell.ambientMode > 0
&& (QbzShell.ambientMode >= 3 || QbzPlayer.npHasTrack)
// --- ON AN ACCENT FILL: the glyph tint and its colour twin -----------
//
+12
View File
@@ -84,6 +84,10 @@ pub mod qbz_bridge {
/// Select rows: the picked OPTION INDEX within the row's list.
#[qinvokable]
fn settings_select(self: Pin<&mut QbzBridge>, key: QString, index: i32);
#[qinvokable]
fn settings_pick_background_image(self: Pin<&mut QbzBridge>);
#[qinvokable]
fn settings_clear_background_image(self: Pin<&mut QbzBridge>);
/// Slider rows (initial buffer size).
#[qinvokable]
fn settings_slider(self: Pin<&mut QbzBridge>, key: QString, value: i32);
@@ -281,6 +285,14 @@ impl qbz_bridge::QbzBridge {
crate::settings_bool(key.to_string(), value);
}
pub fn settings_pick_background_image(self: Pin<&mut Self>) {
crate::settings_qt::pick_background_image();
}
pub fn settings_clear_background_image(self: Pin<&mut Self>) {
crate::settings_qt::clear_background_image();
}
pub fn settings_select(self: Pin<&mut Self>, key: QString, index: i32) {
crate::settings_select(key.to_string(), index);
}
+3
View File
@@ -291,6 +291,7 @@ mod label_qt;
mod local_media_info_qt;
mod nav_qt;
mod page_restore_qt;
mod wallpaper_qt;
mod now_playing;
mod offline_fwd;
mod output_labels;
@@ -837,6 +838,8 @@ fn on_session_entered() {
};
nav_qt::record(&entry_view);
hydrate_view(&entry_view);
// The desktop wallpaper, when a background mode paints it (a no-op otherwise).
wallpaper_qt::refresh();
// Logged-off startup is the ONLY automatic redirect. An authenticated
// account keeps the established startup/restore flow even when physical
// connectivity is down. The selected tab is the first user-ordered Local
+38 -2
View File
@@ -778,6 +778,34 @@ pub fn ambient_bar_alpha() -> f32 {
/// Same one-document rule as `toggle_system_title_bar`: the current key is
/// read inside the write closure, so a torn read can no longer make the app
/// commit "ambient" over a user who had just turned it off (or the reverse).
/// Settings > Appearance > Background image > "Choose image…": the user's
/// own picture behind the wallpaper modes, outranking the desktop's.
pub fn pick_background_image() {
crate::spawn(async {
let Some(file) = rfd::AsyncFileDialog::new()
.set_title(qbz_i18n::t("Background image"))
.add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp", "avif"])
.pick_file()
.await
else {
return;
};
let path = file.path().to_string_lossy().to_string();
save_pref("app_background_image", serde_json::json!(path));
publish_snapshot().await;
crate::wallpaper_qt::refresh();
});
}
/// "System wallpaper": drop the user's own picture.
pub fn clear_background_image() {
save_pref("app_background_image", serde_json::json!(""));
crate::spawn(async {
publish_snapshot().await;
crate::wallpaper_qt::refresh();
});
}
pub fn toggle_ambient_background() -> i32 {
edit_prefs(|doc| {
let current = doc
@@ -1643,8 +1671,10 @@ const QCONNECT_CONFLICT_POLICY_LABELS: &[&str] = &[
"Continue local playback and replace the Qobuz Connect queue",
];
// Appearance option tables (AppearanceSettings.slint / ui_prefs.rs).
const APP_BACKGROUND_LABELS: &[&str] = &["Off", "Ambient", "Blurred art"];
const APP_BACKGROUND_VALUES: &[&str] = &["off", "ambient", "blurred"];
// 3 and 4 paint the desktop wallpaper (wallpaper_qt.rs): behind the window,
// or through the Blurred-art atmosphere pass.
const APP_BACKGROUND_LABELS: &[&str] = &["Off", "Ambient", "Blurred art", "Wallpaper", "Wallpaper, blurred"];
const APP_BACKGROUND_VALUES: &[&str] = &["off", "ambient", "blurred", "wallpaper", "wallpaper-blurred"];
const LANGUAGE_LABELS: &[&str] = &[
"Auto",
"English",
@@ -2155,6 +2185,9 @@ pub struct SettingsDoc {
pub app_background_modes: Vec<String>,
#[serde(rename = "appBackgroundIndex")]
pub app_background_index: i32,
/// The user's own background image ("" = the desktop wallpaper).
#[serde(rename = "appBackgroundImage")]
pub app_background_image: String,
#[serde(rename = "autoThemeSources")]
pub auto_theme_sources: Vec<String>,
#[serde(rename = "autoThemeSourceIndex")]
@@ -2714,6 +2747,7 @@ pub async fn publish_snapshot() {
.iter()
.position(|v| *v == pref_str("app_background", "off"))
.unwrap_or(0) as i32,
app_background_image: pref_str("app_background_image", ""),
auto_theme_sources: AUTO_THEME_SOURCE_LABELS
.iter()
.map(|l| qbz_i18n::t(l))
@@ -4225,6 +4259,8 @@ pub async fn settings_select(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &st
// ImmersiveAtmosphere for 2, exactly like AppShell.slint:213-231).
let ambient = index as i32;
crate::shell_bridge::ui(move |mut b| b.as_mut().set_ambient_mode(ambient));
// The wallpaper modes need their image (a no-op for the others).
crate::wallpaper_qt::refresh();
}
"language" => {
let Some(lang) = LANGUAGE_VALUES.get(index) else {
+16
View File
@@ -296,6 +296,11 @@ pub mod qbz_shell {
/// May the app-wide dynamic background be offered?
/// Same tier, same reason (`main.rs:8563`); gates the whole picker row.
#[qproperty(bool, app_background_available)]
/// The desktop wallpaper (or the user's own image) as a file:// URL,
/// and its atmosphere bitmap — the "wallpaper" background modes
/// (wallpaper_qt.rs). Empty until a mode that paints it resolves one.
#[qproperty(QString, wallpaper_url)]
#[qproperty(QString, wallpaper_atmosphere_url)]
/// Is the kiosk touch shell the one currently mounted?
///
/// The Qt counterpart of `ShellState.kiosk-profile`
@@ -533,6 +538,9 @@ pub mod qbz_shell {
/// Header history buttons.
#[qinvokable]
fn navigate_back(self: Pin<&mut QbzShell>);
/// Re-resolve the desktop wallpaper (window activation, a mode switch).
#[qinvokable]
fn refresh_wallpaper(self: Pin<&mut QbzShell>);
#[qinvokable]
fn navigate_forward(self: Pin<&mut QbzShell>);
@@ -896,6 +904,8 @@ pub struct QbzShellRust {
gpu_tier: bool,
shader_scenes_available: bool,
app_background_available: bool,
wallpaper_url: QString,
wallpaper_atmosphere_url: QString,
kiosk_profile: bool,
kiosk_fullscreen_boot: bool,
ambient_mode: i32,
@@ -1011,6 +1021,8 @@ impl Default for QbzShellRust {
gpu_tier: crate::renderer_qt::gpu_tier(),
shader_scenes_available: crate::renderer_qt::gpu_tier(),
app_background_available: crate::renderer_qt::gpu_tier(),
wallpaper_url: QString::default(),
wallpaper_atmosphere_url: QString::default(),
kiosk_profile: crate::kiosk_profile_qt::active(),
kiosk_fullscreen_boot: crate::kiosk_profile_qt::active()
&& crate::kiosk_profile_qt::fullscreen_at_boot(),
@@ -1433,6 +1445,10 @@ impl qbz_shell::QbzShell {
crate::nav_qt::back();
}
pub fn refresh_wallpaper(self: Pin<&mut Self>) {
crate::wallpaper_qt::refresh();
}
pub fn navigate_forward(self: Pin<&mut Self>) {
crate::nav_qt::forward();
}
+416
View File
@@ -0,0 +1,416 @@
//! The desktop wallpaper as the app-wide background (2026-09-14).
//!
//! Two `app_background` modes ride on it: "wallpaper" (3) shows the part of
//! the wallpaper that lies BEHIND the window, lightly blurred under the
//! translucent chrome (shell/WallpaperField.qml — the window's place on the
//! screen comes from Qt where the platform tells it: X11, macOS, Windows;
//! Wayland hides window positions, so a centred crop stands in until the
//! Plasma window-management protocol is wired); "wallpaper-blurred" (4)
//! passes the wallpaper through the same atmosphere pass Blurred art gives
//! the cover (atmosphere_qt::for_cover_blocking), so it reads like the album
//! blur with the desktop's colours.
//!
//! The image is the system wallpaper, resolved per desktop — KDE Plasma's
//! `plasma-org.kde.plasma.desktop-appletsrc` (the containment on screen 0
//! wins; a wallpaper PACKAGE resolves to its largest `contents/images/*`
//! bitmap, a slideshow folder to its first image), GNOME through `gsettings`
//! (the dark variant under a dark colour scheme), Windows' current
//! `TranscodedWallpaper`, macOS through System Events — or an image the user
//! chose (`app_background_image`), which outranks the desktop. `refresh()`
//! is cheap to call often: the resolved path and its mtime are memoised, and
//! nothing is republished while they hold.
use cxx_qt_lib::QString;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;
pub const MODE_WALLPAPER: i32 = 3;
static LAST: Mutex<Option<(PathBuf, Option<SystemTime>)>> = Mutex::new(None);
const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp", "bmp", "avif", "jxl"];
/// Does this `app_background` mode paint the wallpaper?
pub fn mode_uses_wallpaper(mode: i32) -> bool {
mode >= MODE_WALLPAPER
}
/// Resolve the wallpaper (the user's own image first) and publish its URL and
/// its atmosphere bitmap on the shell bridge. No-op while the mode does not
/// paint it, and quiet while the same file (same mtime) is already published.
pub fn refresh() {
if !mode_uses_wallpaper(crate::settings_qt::app_background_mode()) {
return;
}
crate::spawn(async {
let resolved = tokio::task::spawn_blocking(|| {
let custom = crate::settings_qt::pref_str("app_background_image", "");
let path = if custom.trim().is_empty() {
resolve_system()
} else {
Some(PathBuf::from(custom.trim()))
};
let path = path.filter(|p| p.is_file())?;
let mtime = std::fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok());
{
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
if last
.as_ref()
.is_some_and(|(p, t)| *p == path && *t == mtime)
{
return None;
}
*last = Some((path.clone(), mtime));
}
let atmosphere = crate::atmosphere_qt::for_cover_blocking(&path.to_string_lossy())
.unwrap_or_default();
Some((file_url(&path), atmosphere))
})
.await
.ok()
.flatten();
if let Some((url, atmosphere)) = resolved {
log::info!("[qbz-qt] wallpaper: {url}");
crate::shell_bridge::ui(move |mut b| {
b.as_mut().set_wallpaper_url(QString::from(url.as_str()));
b.as_mut()
.set_wallpaper_atmosphere_url(QString::from(atmosphere.as_str()));
});
}
});
}
/// The desktop's current wallpaper, if this desktop tells us.
pub fn resolve_system() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
return windows_wallpaper();
#[cfg(target_os = "macos")]
return macos_wallpaper();
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
return kde_wallpaper().or_else(gnome_wallpaper);
}
fn file_url(path: &Path) -> String {
let text = path.to_string_lossy();
if text.starts_with('/') {
format!("file://{text}")
} else {
// Windows: `C:\a\b.png` -> `file:///C:/a/b.png`.
format!("file:///{}", text.replace('\\', "/"))
}
}
/// `%20` and friends back to bytes (the desktops store wallpaper paths as
/// URLs). Anything malformed passes through untouched.
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = &s[i + 1..i + 3];
if let Ok(v) = u8::from_str_radix(hex, 16) {
out.push(v);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// A wallpaper setting's value (`file:///…` or a bare path) to a path.
fn path_from_setting(value: &str) -> PathBuf {
let v = value.trim().trim_matches('\'').trim_matches('"');
let v = v.strip_prefix("file://").unwrap_or(v);
PathBuf::from(percent_decode(v))
}
fn is_image(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| IMAGE_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str()))
}
/// A file stays a file; a directory is either a KDE wallpaper PACKAGE
/// (`contents/images/<WxH>.<ext>`, the largest wins) or a slideshow folder
/// (its first image, sorted).
fn resolve_image_source(path: PathBuf) -> Option<PathBuf> {
if path.is_file() {
return Some(path);
}
if !path.is_dir() {
return None;
}
let package = path.join("contents").join("images");
let dir = if package.is_dir() { package } else { path };
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
.ok()?
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file() && is_image(p))
.collect();
if files.is_empty() {
return None;
}
files.sort();
let pixels = |p: &Path| -> u64 {
p.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| {
let (w, h) = s.split_once('x')?;
Some(w.parse::<u64>().ok()? * h.parse::<u64>().ok()?)
})
.unwrap_or(0)
};
// The FIRST of equals wins (a slideshow folder has no sizes in its names),
// which `max_by_key` would not give: it returns the last maximum.
let mut best = files[0].clone();
let mut best_pixels = pixels(&best);
for file in &files[1..] {
let px = pixels(file);
if px > best_pixels {
best = file.clone();
best_pixels = px;
}
}
Some(best)
}
fn section_ids(section: &str) -> Vec<&str> {
section
.trim_matches(|c| c == '[' || c == ']')
.split("][")
.collect()
}
/// The image Plasma paints on screen 0 (else the lowest screen, else any):
/// `[Containments][N][Wallpaper][org.kde.image][General] Image=`, for the
/// containments whose wallpaper plugin is the image one.
fn kde_image_from_config(text: &str) -> Option<String> {
let mut section = String::new();
let mut screen_of: HashMap<String, i32> = HashMap::new();
let mut plugin_of: HashMap<String, String> = HashMap::new();
let mut image_of: HashMap<String, String> = HashMap::new();
for raw in text.lines() {
let line = raw.trim();
if line.starts_with('[') {
section = line.to_string();
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let ids = section_ids(&section);
if ids.len() == 2 && ids[0] == "Containments" {
match key {
"lastScreen" => {
if let Ok(n) = value.trim().parse::<i32>() {
screen_of.insert(ids[1].to_string(), n);
}
}
"wallpaperplugin" => {
plugin_of.insert(ids[1].to_string(), value.trim().to_string());
}
_ => {}
}
} else if ids.len() == 5
&& ids[0] == "Containments"
&& ids[2] == "Wallpaper"
&& ids[3] == "org.kde.image"
&& ids[4] == "General"
&& key == "Image"
&& !value.trim().is_empty()
{
image_of.insert(ids[1].to_string(), value.trim().to_string());
}
}
let mut best: Option<(i32, String)> = None;
for (id, image) in image_of {
if plugin_of.get(&id).is_some_and(|p| p != "org.kde.image") {
continue;
}
let screen = screen_of.get(&id).copied().unwrap_or(i32::MAX);
let rank = if screen < 0 { i32::MAX } else { screen };
if best.as_ref().is_none_or(|(r, _)| rank < *r) {
best = Some((rank, image));
}
}
best.map(|(_, image)| image)
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn kde_wallpaper() -> Option<PathBuf> {
let config = dirs::config_dir()?.join("plasma-org.kde.plasma.desktop-appletsrc");
let text = std::fs::read_to_string(config).ok()?;
let value = kde_image_from_config(&text)?;
resolve_image_source(path_from_setting(&value))
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn gsettings(schema: &str, key: &str) -> Option<String> {
let out = std::process::Command::new("gsettings")
.args(["get", schema, key])
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn gnome_wallpaper() -> Option<PathBuf> {
let dark = gsettings("org.gnome.desktop.interface", "color-scheme")
.is_some_and(|v| v.contains("prefer-dark"));
let key = if dark {
"picture-uri-dark"
} else {
"picture-uri"
};
let value = gsettings("org.gnome.desktop.background", key)
.filter(|v| !v.trim_matches('\'').is_empty())
.or_else(|| gsettings("org.gnome.desktop.background", "picture-uri"))?;
resolve_image_source(path_from_setting(&value))
}
#[cfg(target_os = "windows")]
fn windows_wallpaper() -> Option<PathBuf> {
let appdata = std::env::var_os("APPDATA")?;
let transcoded = PathBuf::from(appdata)
.join("Microsoft")
.join("Windows")
.join("Themes")
.join("TranscodedWallpaper");
if transcoded.is_file() {
return Some(transcoded);
}
let out = std::process::Command::new("reg")
.args(["query", r"HKCU\Control Panel\Desktop", "/v", "WallPaper"])
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let line = text.lines().find(|l| l.contains("WallPaper"))?;
let value = line.split("REG_SZ").nth(1)?.trim();
resolve_image_source(PathBuf::from(value))
}
#[cfg(target_os = "macos")]
fn macos_wallpaper() -> Option<PathBuf> {
let out = std::process::Command::new("osascript")
.args([
"-e",
"tell application \"System Events\" to get picture of current desktop",
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
if value.is_empty() {
return None;
}
resolve_image_source(PathBuf::from(value))
}
#[cfg(test)]
mod tests {
use super::*;
const APPLETSRC: &str = r#"
[Containments][1]
activityId=abc
lastScreen=1
plugin=org.kde.plasma.folder
wallpaperplugin=org.kde.image
[Containments][1][Wallpaper][org.kde.image][General]
Image=file:///home/me/Pictures/Second%20Screen.png
[Containments][2]
activityId=abc
lastScreen=0
plugin=org.kde.plasma.folder
wallpaperplugin=org.kde.image
[Containments][2][Wallpaper][org.kde.color][General]
Color=0,0,0
[Containments][2][Wallpaper][org.kde.image][General]
Image=file:///usr/share/wallpapers/MilkyWay/
[Containments][3]
lastScreen=0
wallpaperplugin=org.kde.color
[Containments][3][Wallpaper][org.kde.image][General]
Image=file:///home/me/stale.png
"#;
#[test]
fn plasma_picks_the_image_containment_on_screen_zero() {
assert_eq!(
kde_image_from_config(APPLETSRC).as_deref(),
Some("file:///usr/share/wallpapers/MilkyWay/")
);
// Without screen 0 the lowest screen wins; a colour containment never does.
let only_second = APPLETSRC.replace("lastScreen=0", "lastScreen=5");
assert_eq!(
kde_image_from_config(&only_second).as_deref(),
Some("file:///home/me/Pictures/Second%20Screen.png")
);
assert!(kde_image_from_config("[Containments][9]\nlastScreen=0\n").is_none());
}
#[test]
fn settings_values_become_decoded_paths() {
assert_eq!(
path_from_setting("file:///home/me/Pictures/Second%20Screen.png"),
PathBuf::from("/home/me/Pictures/Second Screen.png")
);
assert_eq!(
path_from_setting("'file:///usr/share/backgrounds/a.jpg'"),
PathBuf::from("/usr/share/backgrounds/a.jpg")
);
assert_eq!(
path_from_setting("/plain/path.png"),
PathBuf::from("/plain/path.png")
);
assert_eq!(percent_decode("100%"), "100%");
}
#[test]
fn a_package_resolves_to_its_largest_bitmap_and_a_folder_to_its_first_image() {
let temp = tempfile::tempdir().unwrap();
let package = temp.path().join("MilkyWay");
let images = package.join("contents").join("images");
std::fs::create_dir_all(&images).unwrap();
for name in ["1080x1920.png", "5120x2880.png", "notes.txt"] {
std::fs::write(images.join(name), b"x").unwrap();
}
assert_eq!(
resolve_image_source(package.clone()),
Some(images.join("5120x2880.png"))
);
let slides = temp.path().join("slides");
std::fs::create_dir_all(&slides).unwrap();
for name in ["b.jpg", "a.png", "readme.md"] {
std::fs::write(slides.join(name), b"x").unwrap();
}
assert_eq!(
resolve_image_source(slides.clone()),
Some(slides.join("a.png"))
);
let file = temp.path().join("one.webp");
std::fs::write(&file, b"x").unwrap();
assert_eq!(resolve_image_source(file.clone()), Some(file));
assert!(resolve_image_source(temp.path().join("missing")).is_none());
}
}