Add configurable native glass window backgrounds

This commit is contained in:
karl committed 2026-10-13 10:12:25 +02:00
1 parent 19d949fd13
commit 981991bac0
14 files changed
+687 -5

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
//! Native materials are applied on the UI thread. Unsupported systems stay
//! opaque; stored preferences are retained for the user's next supported session.
use crate::settings::BackgroundMaterial;
use serde::Serialize;
use tauri::WebviewWindow;
#[derive(Clone, Debug, Serialize)]
pub struct AppearanceStatus {
pub requested_material: BackgroundMaterial,
pub effective_material: BackgroundMaterial,
pub opacity: u8,
pub native_effect: bool,
pub fallback_reason: Option<String>,
}
pub fn apply(
window: &WebviewWindow,
material: BackgroundMaterial,
opacity: u8,
high_contrast: bool,
) -> AppearanceStatus {
let mut status = AppearanceStatus {
requested_material: material,
effective_material: BackgroundMaterial::Solid,
opacity,
native_effect: false,
fallback_reason: None,
};
if high_contrast || material == BackgroundMaterial::Solid || opacity == 100 {
#[cfg(windows)]
{
let _ = window_vibrancy::clear_acrylic(window);
}
if high_contrast {
status.fallback_reason = Some("High contrast uses an opaque background.".into());
}
return status;
}
#[cfg(windows)]
{
if !native_backdrop_available() {
status.fallback_reason = Some("Glass requires Windows 11 22H2 or newer with desktop composition enabled and high contrast off. Using a solid background.".into());
return status;
}
let _ = window_vibrancy::clear_acrylic(window);
// Keep the webview clear; the frontend draws a theme-aware readable tint.
let result = window
.set_background_color(Some(tauri::window::Color(0, 0, 0, 0)))
.map_err(|error| error.to_string())
.and_then(|()| {
match material {
BackgroundMaterial::Glass => {
window_vibrancy::apply_acrylic(window, Some((0, 0, 0, 0)))
}
BackgroundMaterial::Frosted => {
window_vibrancy::apply_acrylic(window, Some((0, 0, 0, 0)))
}
BackgroundMaterial::Solid => unreachable!(),
}
.map_err(|error| error.to_string())
});
match result {
Ok(()) => {
status.effective_material = material;
status.native_effect = true;
}
Err(_) => {
status.fallback_reason =
Some("Native transparency is unavailable. Using a solid background.".into());
}
}
}
#[cfg(not(windows))]
{
let _ = window;
status.fallback_reason = Some(
if cfg!(target_os = "macos") {
"Native glass is unavailable in this macOS build. Using a solid background."
} else {
"Native glass is unavailable on this desktop. Using a solid background."
}
.into(),
);
}
status
}
#[cfg(windows)]
fn native_backdrop_available() -> bool {
use windows_sys::Win32::UI::Accessibility::{HCF_HIGHCONTRASTON, HIGHCONTRASTW};
use windows_sys::Win32::{
Graphics::Dwm::DwmIsCompositionEnabled,
UI::WindowsAndMessaging::{SystemParametersInfoW, SPI_GETHIGHCONTRAST},
};
let mut composition = 0;
let mut contrast = HIGHCONTRASTW {
cbSize: std::mem::size_of::<HIGHCONTRASTW>() as u32,
dwFlags: 0,
lpszDefaultScheme: std::ptr::null_mut(),
};
// Failure to query accessibility/composition is treated conservatively.
unsafe {
if DwmIsCompositionEnabled(&mut composition) < 0
|| composition == 0
|| SystemParametersInfoW(
SPI_GETHIGHCONTRAST,
contrast.cbSize,
&mut contrast as *mut _ as *mut _,
0,
) == 0
|| contrast.dwFlags & HCF_HIGHCONTRASTON != 0
{
return false;
}
}
// The established crate checks the HRESULT of the public backdrop API on
// Windows 11 22H2+. Its older undocumented path cannot confirm success.
let mut version = windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW {
dwOSVersionInfoSize: std::mem::size_of::<
windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW,
>() as u32,
..unsafe { std::mem::zeroed() }
};
unsafe {
windows_sys::Wdk::System::SystemServices::RtlGetVersion(&mut version) >= 0
&& version.dwBuildNumber >= 22621
}
}
+99
View File
@@ -4,6 +4,7 @@
compile_error!("Release desktop builds require --features custom-protocol to embed the frontend");
mod ai_commands;
mod appearance;
mod data_directory;
mod model_download;
mod semantic_commands;
@@ -32,6 +33,7 @@ use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
use tauri_plugin_opener::OpenerExt;
struct AppState {
appearance: Arc<Mutex<appearance::AppearanceStatus>>,
ai: Arc<AiSession>,
database: PathBuf,
lexical: Arc<Mutex<SearchIndex>>,
@@ -85,6 +87,7 @@ fn measured_outcome(
#[derive(Serialize)]
struct DesktopStatus {
appearance: appearance::AppearanceStatus,
settings: Settings,
indexing: bool,
shortcut_status: String,
@@ -94,6 +97,11 @@ struct DesktopStatus {
#[tauri::command]
fn desktop_status(state: State<'_, AppState>) -> Result<DesktopStatus, String> {
Ok(DesktopStatus {
appearance: state
.appearance
.lock()
.map_err(|_| "Appearance is unavailable.")?
.clone(),
settings: state
.settings
.lock()
@@ -124,6 +132,92 @@ fn set_cpu_threads(threads: usize, state: State<'_, AppState>) -> Result<Setting
Ok(store.value.clone())
}
#[tauri::command]
async fn set_appearance(
material: settings::BackgroundMaterial,
opacity: u8,
high_contrast: bool,
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<appearance::AppearanceStatus, String> {
{
let mut store = state
.settings
.lock()
.map_err(|_| "Settings are unavailable.")?;
let mut next = store.value.clone();
next.background_material = material;
next.background_opacity = opacity;
store.save(next)?;
}
apply_saved_appearance(
app,
state.appearance.clone(),
material,
opacity,
high_contrast,
)
.await
}
#[tauri::command]
async fn apply_appearance(
high_contrast: bool,
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<appearance::AppearanceStatus, String> {
let (material, opacity) = {
let store = state
.settings
.lock()
.map_err(|_| "Settings are unavailable.")?;
(
store.value.background_material,
store.value.background_opacity,
)
};
apply_saved_appearance(
app,
state.appearance.clone(),
material,
opacity,
high_contrast,
)
.await
}
async fn apply_saved_appearance(
app: tauri::AppHandle,
current: Arc<Mutex<appearance::AppearanceStatus>>,
material: settings::BackgroundMaterial,
opacity: u8,
high_contrast: bool,
) -> Result<appearance::AppearanceStatus, String> {
let window = app
.get_webview_window("main")
.ok_or("Main window is unavailable.")?;
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
app.run_on_main_thread(move || {
let status = appearance::apply(&window, material, opacity, high_contrast);
let result = current
.lock()
.map_err(|_| "Appearance is unavailable.".to_string())
.map(|mut current| {
*current = status.clone();
status
});
let _ = sender.send(result);
})
.map_err(|error| error.to_string())?;
tauri::async_runtime::spawn_blocking(move || {
receiver
.recv_timeout(std::time::Duration::from_secs(5))
.map_err(|_| "Appearance update was cancelled.".to_string())?
})
.await
.map_err(|error| error.to_string())?
}
#[derive(Default, Deserialize)]
struct Filters {
#[serde(default)]
@@ -557,7 +651,10 @@ fn main() {
Err(error) => format!("Global shortcut unavailable: {error}"),
}
};
let window = app.get_webview_window("main").ok_or_else(|| std::io::Error::other("Main window is unavailable."))?;
let appearance = appearance::apply(&window, settings.value.background_material, settings.value.background_opacity, false);
app.manage(AppState {
appearance: Arc::new(Mutex::new(appearance)),
ai: Arc::new(AiSession::default()),
database,
lexical,
@@ -582,6 +679,8 @@ fn main() {
prepare_media_preview,
desktop_status,
set_cpu_threads,
set_appearance,
apply_appearance,
semantic_status,
setup_model,
index_embeddings,
+50
View File
@@ -7,6 +7,19 @@ use std::{
path::{Path, PathBuf},
};
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BackgroundMaterial {
Solid,
Glass,
#[default]
Frosted,
}
fn default_background_opacity() -> u8 {
90
}
const SETTINGS_VERSION: u32 = 2;
const MAX_SETTINGS_BYTES: u64 = 128 * 1024;
@@ -14,6 +27,10 @@ const MAX_SETTINGS_BYTES: u64 = 128 * 1024;
#[serde(deny_unknown_fields)]
pub struct Settings {
pub version: u32,
#[serde(default)]
pub background_material: BackgroundMaterial,
#[serde(default = "default_background_opacity")]
pub background_opacity: u8,
pub roots: Vec<PathBuf>,
pub cpu_threads: usize,
/// Managed local Google EmbeddingGemma2 artifacts. Older settings default
@@ -26,6 +43,8 @@ impl Default for Settings {
fn default() -> Self {
Self {
version: SETTINGS_VERSION,
background_material: BackgroundMaterial::default(),
background_opacity: default_background_opacity(),
roots: Vec::new(),
cpu_threads: 2,
model_directory: None,
@@ -38,6 +57,9 @@ impl Settings {
if self.version != SETTINGS_VERSION {
return Err(format!("Unsupported settings version {}", self.version));
}
if !(70..=100).contains(&self.background_opacity) {
return Err("Background opacity must be between 70 and 100 percent.".into());
}
if !(1..=8).contains(&self.cpu_threads) {
return Err("CPU threads must be between 1 and 8.".into());
}
@@ -229,3 +251,31 @@ mod budget_migration_tests {
assert_eq!(SettingsStore::load(path).unwrap().value.cpu_threads, 8);
}
}
#[cfg(test)]
mod appearance_tests {
use super::*;
#[test]
fn legacy_defaults_and_saved_appearance_round_trip() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.json");
fs::write(&path, r#"{"version":2,"roots":[],"cpu_threads":2}"#).unwrap();
let mut store = SettingsStore::load(path.clone()).unwrap();
assert_eq!(store.value.background_material, BackgroundMaterial::Frosted);
assert_eq!(store.value.background_opacity, 90);
let mut next = store.value.clone();
next.background_material = BackgroundMaterial::Glass;
next.background_opacity = 70;
store.save(next.clone()).unwrap();
assert_eq!(SettingsStore::load(path.clone()).unwrap().value, next);
let bytes = fs::read(&path).unwrap();
for opacity in [0, 69, 101, 255] {
let mut invalid = next.clone();
invalid.background_opacity = opacity;
assert!(store.save(invalid).is_err());
assert_eq!(store.value, next);
assert_eq!(fs::read(&path).unwrap(), bytes);
}
assert!(serde_json::from_str::<BackgroundMaterial>("\"other\"").is_err());
}
}