Add configurable native glass window backgrounds
This commit is contained in:
1 parent
19d949fd13
commit
981991bac0
14 files changed
+687
-5
No files matched your search
@@ -62,7 +62,11 @@ like an explanation, expand the small **Why this result?** detail.
|
|||||||
|
|
||||||
Light and dark themes follow your system preference. Keyboard navigation,
|
Light and dark themes follow your system preference. Keyboard navigation,
|
||||||
short transitions and reduced-motion support keep the interface familiar
|
short transitions and reduced-motion support keep the interface familiar
|
||||||
and comfortable.
|
and comfortable. In **Settings → Appearance**, choose a solid, glass or
|
||||||
|
frosted-glass window background and adjust its tint. Reading surfaces stay
|
||||||
|
clear. Native glass currently requires **Windows 11 22H2 or later**. macOS,
|
||||||
|
Linux, unsupported Windows versions and high-contrast mode currently use a
|
||||||
|
solid background; your appearance preference is retained.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
Generated
+2
@@ -3823,6 +3823,8 @@ dependencies = [
|
|||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"window-vibrancy",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -30,3 +30,7 @@ searchi-media = { path = "../../../crates/searchi-media" }
|
|||||||
searchi-ai = { path = "../../../crates/searchi-ai" }
|
searchi-ai = { path = "../../../crates/searchi-ai" }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "bmp"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "bmp"] }
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
window-vibrancy = "=0.8.1"
|
||||||
|
windows-sys = { version = "0.61", features = ["Win32_UI_Accessibility", "Win32_System_SystemInformation", "Wdk_System_SystemServices", "Win32_Graphics_Dwm", "Win32_UI_WindowsAndMessaging"] }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
compile_error!("Release desktop builds require --features custom-protocol to embed the frontend");
|
compile_error!("Release desktop builds require --features custom-protocol to embed the frontend");
|
||||||
|
|
||||||
mod ai_commands;
|
mod ai_commands;
|
||||||
|
mod appearance;
|
||||||
mod data_directory;
|
mod data_directory;
|
||||||
mod model_download;
|
mod model_download;
|
||||||
mod semantic_commands;
|
mod semantic_commands;
|
||||||
@@ -32,6 +33,7 @@ use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
|
|||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
struct AppState {
|
struct AppState {
|
||||||
|
appearance: Arc<Mutex<appearance::AppearanceStatus>>,
|
||||||
ai: Arc<AiSession>,
|
ai: Arc<AiSession>,
|
||||||
database: PathBuf,
|
database: PathBuf,
|
||||||
lexical: Arc<Mutex<SearchIndex>>,
|
lexical: Arc<Mutex<SearchIndex>>,
|
||||||
@@ -85,6 +87,7 @@ fn measured_outcome(
|
|||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct DesktopStatus {
|
struct DesktopStatus {
|
||||||
|
appearance: appearance::AppearanceStatus,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
indexing: bool,
|
indexing: bool,
|
||||||
shortcut_status: String,
|
shortcut_status: String,
|
||||||
@@ -94,6 +97,11 @@ struct DesktopStatus {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn desktop_status(state: State<'_, AppState>) -> Result<DesktopStatus, String> {
|
fn desktop_status(state: State<'_, AppState>) -> Result<DesktopStatus, String> {
|
||||||
Ok(DesktopStatus {
|
Ok(DesktopStatus {
|
||||||
|
appearance: state
|
||||||
|
.appearance
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "Appearance is unavailable.")?
|
||||||
|
.clone(),
|
||||||
settings: state
|
settings: state
|
||||||
.settings
|
.settings
|
||||||
.lock()
|
.lock()
|
||||||
@@ -124,6 +132,92 @@ fn set_cpu_threads(threads: usize, state: State<'_, AppState>) -> Result<Setting
|
|||||||
Ok(store.value.clone())
|
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)]
|
#[derive(Default, Deserialize)]
|
||||||
struct Filters {
|
struct Filters {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -557,7 +651,10 @@ fn main() {
|
|||||||
Err(error) => format!("Global shortcut unavailable: {error}"),
|
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 {
|
app.manage(AppState {
|
||||||
|
appearance: Arc::new(Mutex::new(appearance)),
|
||||||
ai: Arc::new(AiSession::default()),
|
ai: Arc::new(AiSession::default()),
|
||||||
database,
|
database,
|
||||||
lexical,
|
lexical,
|
||||||
@@ -582,6 +679,8 @@ fn main() {
|
|||||||
prepare_media_preview,
|
prepare_media_preview,
|
||||||
desktop_status,
|
desktop_status,
|
||||||
set_cpu_threads,
|
set_cpu_threads,
|
||||||
|
set_appearance,
|
||||||
|
apply_appearance,
|
||||||
semantic_status,
|
semantic_status,
|
||||||
setup_model,
|
setup_model,
|
||||||
index_embeddings,
|
index_embeddings,
|
||||||
|
|||||||
@@ -7,6 +7,19 @@ use std::{
|
|||||||
path::{Path, PathBuf},
|
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 SETTINGS_VERSION: u32 = 2;
|
||||||
const MAX_SETTINGS_BYTES: u64 = 128 * 1024;
|
const MAX_SETTINGS_BYTES: u64 = 128 * 1024;
|
||||||
|
|
||||||
@@ -14,6 +27,10 @@ const MAX_SETTINGS_BYTES: u64 = 128 * 1024;
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub version: u32,
|
pub version: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub background_material: BackgroundMaterial,
|
||||||
|
#[serde(default = "default_background_opacity")]
|
||||||
|
pub background_opacity: u8,
|
||||||
pub roots: Vec<PathBuf>,
|
pub roots: Vec<PathBuf>,
|
||||||
pub cpu_threads: usize,
|
pub cpu_threads: usize,
|
||||||
/// Managed local Google EmbeddingGemma2 artifacts. Older settings default
|
/// Managed local Google EmbeddingGemma2 artifacts. Older settings default
|
||||||
@@ -26,6 +43,8 @@ impl Default for Settings {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
version: SETTINGS_VERSION,
|
version: SETTINGS_VERSION,
|
||||||
|
background_material: BackgroundMaterial::default(),
|
||||||
|
background_opacity: default_background_opacity(),
|
||||||
roots: Vec::new(),
|
roots: Vec::new(),
|
||||||
cpu_threads: 2,
|
cpu_threads: 2,
|
||||||
model_directory: None,
|
model_directory: None,
|
||||||
@@ -38,6 +57,9 @@ impl Settings {
|
|||||||
if self.version != SETTINGS_VERSION {
|
if self.version != SETTINGS_VERSION {
|
||||||
return Err(format!("Unsupported settings version {}", self.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) {
|
if !(1..=8).contains(&self.cpu_threads) {
|
||||||
return Err("CPU threads must be between 1 and 8.".into());
|
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);
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"app":{"windows":[{"title":"searchi","width":1080,"height":760,"minWidth":680,"minHeight":540,"transparent":true}]}}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/** Only a confirmed native compositor effect may expose the desktop behind the shell.
|
||||||
|
* Reading surfaces retain their opaque backgrounds independently of this material. */
|
||||||
|
export type BackgroundMaterial = "solid" | "glass" | "frosted";
|
||||||
|
export interface AppearanceStatus {
|
||||||
|
requested_material: BackgroundMaterial;
|
||||||
|
effective_material: BackgroundMaterial;
|
||||||
|
opacity: number;
|
||||||
|
native_effect: boolean;
|
||||||
|
fallback_reason: string | null;
|
||||||
|
}
|
||||||
|
export function appearanceView(
|
||||||
|
status: AppearanceStatus | undefined,
|
||||||
|
highContrast: boolean,
|
||||||
|
) {
|
||||||
|
const valid =
|
||||||
|
status &&
|
||||||
|
["glass", "frosted"].includes(status.effective_material) &&
|
||||||
|
status.native_effect;
|
||||||
|
const material: BackgroundMaterial =
|
||||||
|
!highContrast && valid ? status.effective_material : "solid";
|
||||||
|
const opacity = Number.isFinite(status?.opacity)
|
||||||
|
? Math.max(70, Math.min(100, status!.opacity))
|
||||||
|
: 90;
|
||||||
|
const message = highContrast
|
||||||
|
? "High contrast uses a solid background. Your appearance preference is retained."
|
||||||
|
: status?.fallback_reason
|
||||||
|
? `Solid background: ${status.fallback_reason}`
|
||||||
|
: material === "solid"
|
||||||
|
? "Solid background. Search results and passages stay clear."
|
||||||
|
: "The desktop shows subtly through the window. Search results and passages stay clear.";
|
||||||
|
return { material, opacity, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serializes compositor/settings mutations; a waiting update supersedes earlier
|
||||||
|
* pending work, retaining an unsaved preference when contrast also changes. */
|
||||||
|
export class AppearanceUpdates {
|
||||||
|
revision = 0;
|
||||||
|
private running = false;
|
||||||
|
private pending: {
|
||||||
|
revision: number;
|
||||||
|
highContrast: boolean;
|
||||||
|
preference?: { material: BackgroundMaterial; opacity: number };
|
||||||
|
} | null = null;
|
||||||
|
private write: (update: {
|
||||||
|
highContrast: boolean;
|
||||||
|
preference?: { material: BackgroundMaterial; opacity: number };
|
||||||
|
}) => Promise<AppearanceStatus>;
|
||||||
|
private publish: (status: AppearanceStatus) => void;
|
||||||
|
private fail: (error: unknown) => void;
|
||||||
|
private settled: () => void;
|
||||||
|
constructor(
|
||||||
|
write: AppearanceUpdates["write"],
|
||||||
|
publish: AppearanceUpdates["publish"],
|
||||||
|
fail: AppearanceUpdates["fail"],
|
||||||
|
settled: AppearanceUpdates["settled"],
|
||||||
|
) {
|
||||||
|
this.write = write;
|
||||||
|
this.publish = publish;
|
||||||
|
this.fail = fail;
|
||||||
|
this.settled = settled;
|
||||||
|
}
|
||||||
|
get busy() {
|
||||||
|
return this.running || this.pending !== null;
|
||||||
|
}
|
||||||
|
submit(
|
||||||
|
highContrast: boolean,
|
||||||
|
preference?: { material: BackgroundMaterial; opacity: number },
|
||||||
|
) {
|
||||||
|
this.pending = {
|
||||||
|
revision: ++this.revision,
|
||||||
|
highContrast,
|
||||||
|
preference: preference ?? this.pending?.preference,
|
||||||
|
};
|
||||||
|
if (!this.running) void this.drain();
|
||||||
|
}
|
||||||
|
private async drain() {
|
||||||
|
this.running = true;
|
||||||
|
while (this.pending) {
|
||||||
|
const update = this.pending;
|
||||||
|
this.pending = null;
|
||||||
|
try {
|
||||||
|
const status = await this.write(update);
|
||||||
|
if (update.revision === this.revision) this.publish(status);
|
||||||
|
} catch (error) {
|
||||||
|
if (update.revision === this.revision) this.fail(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.running = false;
|
||||||
|
this.settled();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
isCaptureSurface,
|
isCaptureSurface,
|
||||||
prepareMediaURL,
|
prepareMediaURL,
|
||||||
} from "./desktop-api";
|
} from "./desktop-api";
|
||||||
|
import { appearanceView, AppearanceUpdates } from "./appearance-state";
|
||||||
|
import type { AppearanceStatus, BackgroundMaterial } from "./appearance-state";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import {
|
import {
|
||||||
AutomaticIndexGate,
|
AutomaticIndexGate,
|
||||||
@@ -84,11 +86,14 @@ interface Settings {
|
|||||||
version: number;
|
version: number;
|
||||||
roots: string[];
|
roots: string[];
|
||||||
cpu_threads: number;
|
cpu_threads: number;
|
||||||
|
background_material?: BackgroundMaterial;
|
||||||
|
background_opacity?: number;
|
||||||
}
|
}
|
||||||
interface DesktopStatus {
|
interface DesktopStatus {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
indexing: boolean;
|
indexing: boolean;
|
||||||
shortcut_status: string;
|
shortcut_status: string;
|
||||||
|
appearance?: AppearanceStatus;
|
||||||
watching?: {
|
watching?: {
|
||||||
root: string;
|
root: string;
|
||||||
attached: boolean;
|
attached: boolean;
|
||||||
@@ -140,7 +145,7 @@ interface SearchRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
||||||
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span id="scope-label">All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Search scope<select id="filter-scope"><option value="all">All files</option><option value="code">Code</option></select></label><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Folders & resources</h2><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i === 1 ? "threads (default)" : i ? "threads" : "thread (low power)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback. Two or more threads let search run alongside indexing; one thread can delay semantic search during indexing.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details></div></header>
|
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span id="scope-label">All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Search scope<select id="filter-scope"><option value="all">All files</option><option value="code">Code</option></select></label><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Settings</h2><section class="appearance-settings" aria-labelledby="appearance-heading"><h3 id="appearance-heading">Appearance</h3><label for="background-material">Window background</label><select id="background-material"><option value="solid">Solid</option><option value="glass">Glass</option><option value="frosted">Frosted glass</option></select><div class="appearance-opacity"><label for="background-opacity">Background tint</label><output id="background-opacity-value" for="background-opacity">90%</output></div><input id="background-opacity" type="range" min="70" max="100" step="1" value="90" aria-describedby="appearance-status"><p id="appearance-status" class="hint" role="status">A subtle window background. Search results and passages stay clear.</p></section><h3>Folders & resources</h3><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i === 1 ? "threads (default)" : i ? "threads" : "thread (low power)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback. Two or more threads let search run alongside indexing; one thread can delay semantic search during indexing.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details></div></header>
|
||||||
<main>
|
<main>
|
||||||
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label>${icon("search")}<input id="query" type="search" placeholder="Search a filename, phrase, or what you remember…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd id="focus-shortcut">Ctrl K</kbd><button id="clear-query" class="icon-button" type="button" aria-label="Clear search" title="Clear search">${icon("dismiss")}</button></form>
|
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label>${icon("search")}<input id="query" type="search" placeholder="Search a filename, phrase, or what you remember…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd id="focus-shortcut">Ctrl K</kbd><button id="clear-query" class="icon-button" type="button" aria-label="Clear search" title="Clear search">${icon("dismiss")}</button></form>
|
||||||
<details id="semantic-panel"><summary>${icon("sparkles")}<span id="semantic-status" role="status" aria-live="polite">Preparing local semantic search…</span><span id="semantic-device" class="kind">Local model</span>${icon("chevron")}</summary><div class="semantic-content"><strong>Google EmbeddingGemma 2</strong><progress id="semantic-progress" aria-label="Local model setup progress"></progress><p id="semantic-detail" class="hint">The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.</p><div class="semantic-actions"><button id="retry-model" type="button" hidden>Retry model setup</button><button id="rebuild-semantic" type="button" hidden>Rebuild semantic index</button></div></div></details>
|
<details id="semantic-panel"><summary>${icon("sparkles")}<span id="semantic-status" role="status" aria-live="polite">Preparing local semantic search…</span><span id="semantic-device" class="kind">Local model</span>${icon("chevron")}</summary><div class="semantic-content"><strong>Google EmbeddingGemma 2</strong><progress id="semantic-progress" aria-label="Local model setup progress"></progress><p id="semantic-detail" class="hint">The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.</p><div class="semantic-actions"><button id="retry-model" type="button" hidden>Retry model setup</button><button id="rebuild-semantic" type="button" hidden>Rebuild semantic index</button></div></div></details>
|
||||||
@@ -465,8 +470,88 @@ async function refreshSemanticStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshDesktopStatus() {
|
const highContrast = window.matchMedia("(forced-colors: active)");
|
||||||
|
const appearanceUpdates = new AppearanceUpdates(
|
||||||
|
(update) =>
|
||||||
|
invoke<AppearanceStatus>(
|
||||||
|
update.preference ? "set_appearance" : "apply_appearance",
|
||||||
|
{ ...update.preference, highContrast: update.highContrast },
|
||||||
|
),
|
||||||
|
(status) => renderAppearance(status),
|
||||||
|
showError,
|
||||||
|
() => {
|
||||||
|
renderAppearance();
|
||||||
|
void refreshDesktopStatus(true).catch(showError);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let lastAppearance: AppearanceStatus | undefined;
|
||||||
|
function renderAppearance(
|
||||||
|
status?: AppearanceStatus,
|
||||||
|
settings?: Settings,
|
||||||
|
restoreControls = false,
|
||||||
|
) {
|
||||||
|
if (status) lastAppearance = status;
|
||||||
|
const material = element<HTMLSelectElement>("background-material");
|
||||||
|
const opacity = element<HTMLInputElement>("background-opacity");
|
||||||
|
if (
|
||||||
|
!appearanceUpdates.busy &&
|
||||||
|
settings &&
|
||||||
|
(restoreControls || document.activeElement !== opacity)
|
||||||
|
) {
|
||||||
|
material.value = settings.background_material ?? "frosted";
|
||||||
|
opacity.value = String(settings.background_opacity ?? 90);
|
||||||
|
}
|
||||||
|
const view = appearanceView(lastAppearance, highContrast.matches);
|
||||||
|
document.documentElement.dataset.backgroundMaterial = view.material;
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--window-opacity",
|
||||||
|
String(view.opacity / 100),
|
||||||
|
);
|
||||||
|
element("background-opacity-value").textContent = `${opacity.value}%`;
|
||||||
|
material.disabled = !isNativeDesktop() || appearanceUpdates.busy;
|
||||||
|
opacity.disabled =
|
||||||
|
!isNativeDesktop() ||
|
||||||
|
appearanceUpdates.busy ||
|
||||||
|
material.value === "solid" ||
|
||||||
|
highContrast.matches;
|
||||||
|
element("appearance-status").textContent = view.message;
|
||||||
|
}
|
||||||
|
function saveAppearance() {
|
||||||
|
if (!isNativeDesktop()) return;
|
||||||
|
appearanceUpdates.submit(highContrast.matches, {
|
||||||
|
material: element<HTMLSelectElement>("background-material")
|
||||||
|
.value as BackgroundMaterial,
|
||||||
|
opacity: Number(element<HTMLInputElement>("background-opacity").value),
|
||||||
|
});
|
||||||
|
renderAppearance();
|
||||||
|
}
|
||||||
|
element("background-material").addEventListener("change", () => {
|
||||||
|
void saveAppearance();
|
||||||
|
});
|
||||||
|
element("background-opacity").addEventListener("input", () => {
|
||||||
|
element("background-opacity-value").textContent =
|
||||||
|
`${element<HTMLInputElement>("background-opacity").value}%`;
|
||||||
|
});
|
||||||
|
element("background-opacity").addEventListener("change", () => {
|
||||||
|
void saveAppearance();
|
||||||
|
});
|
||||||
|
highContrast.addEventListener("change", () => {
|
||||||
|
renderAppearance(); // Fail closed immediately, before the native effect changes.
|
||||||
|
if (isNativeDesktop()) appearanceUpdates.submit(highContrast.matches);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshDesktopStatus(restoreAppearanceControls = false) {
|
||||||
|
const appearanceRevision = appearanceUpdates.revision;
|
||||||
const status = await invoke<DesktopStatus>("desktop_status");
|
const status = await invoke<DesktopStatus>("desktop_status");
|
||||||
|
if (
|
||||||
|
!appearanceUpdates.busy &&
|
||||||
|
appearanceRevision === appearanceUpdates.revision
|
||||||
|
)
|
||||||
|
renderAppearance(
|
||||||
|
status.appearance,
|
||||||
|
status.settings,
|
||||||
|
restoreAppearanceControls,
|
||||||
|
);
|
||||||
indexingActive = status.indexing;
|
indexingActive = status.indexing;
|
||||||
element("shortcut-status").textContent = status.shortcut_status;
|
element("shortcut-status").textContent = status.shortcut_status;
|
||||||
element("focus-shortcut").textContent = status.shortcut_status.includes(
|
element("focus-shortcut").textContent = status.shortcut_status.includes(
|
||||||
@@ -1433,6 +1518,10 @@ document.addEventListener("keydown", (event) => {
|
|||||||
});
|
});
|
||||||
async function initializeSurface() {
|
async function initializeSurface() {
|
||||||
await initializeDesktopSurface();
|
await initializeDesktopSurface();
|
||||||
|
renderAppearance();
|
||||||
|
if (isNativeDesktop()) {
|
||||||
|
appearanceUpdates.submit(highContrast.matches);
|
||||||
|
}
|
||||||
if (isDesktopSurface()) {
|
if (isDesktopSurface()) {
|
||||||
if (isCaptureSurface()) {
|
if (isCaptureSurface()) {
|
||||||
for (const id of [
|
for (const id of [
|
||||||
|
|||||||
@@ -949,3 +949,86 @@ aside pre.code-passage {
|
|||||||
aside pre.code-passage {
|
aside pre.code-passage {
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Native effects blur the desktop; CSS only supplies its readable shell tint.
|
||||||
|
Never use backdrop-filter as a substitute for an operating-system material. */
|
||||||
|
:root[data-background-material="glass"],
|
||||||
|
:root[data-background-material="frosted"] {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
:root[data-background-material="glass"] #app {
|
||||||
|
background: rgba(248, 250, 252, var(--window-opacity, 0.9));
|
||||||
|
}
|
||||||
|
:root[data-background-material="frosted"] #app {
|
||||||
|
background: rgba(
|
||||||
|
248,
|
||||||
|
250,
|
||||||
|
252,
|
||||||
|
calc(0.35 + var(--window-opacity, 0.9) * 0.65)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.appearance-settings {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.popover-panel h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.appearance-settings > label,
|
||||||
|
.appearance-opacity label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
#background-material {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 7px 0 15px;
|
||||||
|
}
|
||||||
|
.appearance-opacity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
#background-opacity-value {
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
#background-opacity {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 9px 0;
|
||||||
|
accent-color: var(--brand);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root[data-background-material="glass"] #app {
|
||||||
|
background: rgba(11, 15, 20, var(--window-opacity, 0.9));
|
||||||
|
}
|
||||||
|
:root[data-background-material="frosted"] #app {
|
||||||
|
background: rgba(
|
||||||
|
11,
|
||||||
|
15,
|
||||||
|
20,
|
||||||
|
calc(0.35 + var(--window-opacity, 0.9) * 0.65)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
:root,
|
||||||
|
:root[data-background-material] #app {
|
||||||
|
background: Canvas;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Strong shell copy remains legible even above an unusually bright/dark desktop.
|
||||||
|
Opaque reading panes retain the quieter secondary-text hierarchy. */
|
||||||
|
:root:not([data-background-material="solid"])[data-background-material] header,
|
||||||
|
:root:not([data-background-material="solid"])[data-background-material] footer,
|
||||||
|
:root:not([data-background-material="solid"])[data-background-material]
|
||||||
|
#semantic-panel
|
||||||
|
> summary {
|
||||||
|
--muted: var(--ink);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {appearanceView, AppearanceUpdates} from '../src/appearance-state.ts';
|
||||||
|
const glass = () => ({requested_material:'frosted', effective_material:'frosted', opacity:90, native_effect:true, fallback_reason:null});
|
||||||
|
test('transparency requires a confirmed native effect', () => {
|
||||||
|
assert.equal(appearanceView(undefined, false).material, 'solid');
|
||||||
|
assert.equal(appearanceView({...glass(), native_effect:false}, false).material, 'solid');
|
||||||
|
assert.equal(appearanceView(glass(), false).material, 'frosted');
|
||||||
|
assert.equal(appearanceView({...glass(), effective_material:'glass'}, false).material, 'glass');
|
||||||
|
});
|
||||||
|
test('high contrast stays opaque without losing the preference', () => {
|
||||||
|
const status = glass();
|
||||||
|
assert.equal(appearanceView(status, true).material, 'solid');
|
||||||
|
assert.match(appearanceView(status, true).message, /preference is retained/);
|
||||||
|
assert.equal(status.requested_material, 'frosted');
|
||||||
|
assert.equal(appearanceView(status, false).material, 'frosted');
|
||||||
|
});
|
||||||
|
test('fallback is honest and shell tint is bounded', () => {
|
||||||
|
const fallback = {...glass(), effective_material:'solid', native_effect:false, fallback_reason:'Native effects unavailable.'};
|
||||||
|
assert.match(appearanceView(fallback, false).message, /Solid background/);
|
||||||
|
assert.equal(appearanceView({...glass(), opacity:5}, false).opacity, 70);
|
||||||
|
assert.equal(appearanceView({...glass(), opacity:110}, false).opacity, 100);
|
||||||
|
assert.equal(appearanceView({...glass(), opacity:NaN}, false).opacity, 90);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rapid pending changes serialize and only publish the latest response', async () => {
|
||||||
|
const writes = [], published = [], errors = [];
|
||||||
|
let resolve;
|
||||||
|
let settled;
|
||||||
|
const done = new Promise(r => settled = r);
|
||||||
|
const queue = new AppearanceUpdates(update => {
|
||||||
|
writes.push(update);
|
||||||
|
if (writes.length === 1) return new Promise(r => resolve = r);
|
||||||
|
return Promise.resolve({...glass(), opacity:update.preference.opacity});
|
||||||
|
}, status => published.push(status), error => errors.push(error), settled);
|
||||||
|
queue.submit(false, {material:'glass', opacity:80});
|
||||||
|
queue.submit(false, {material:'frosted', opacity:95});
|
||||||
|
queue.submit(true); // Preserve the pending saved preference when contrast toggles.
|
||||||
|
assert.equal(writes.length, 1);
|
||||||
|
assert.equal(queue.busy, true);
|
||||||
|
resolve({...glass(), effective_material:'glass', opacity:80});
|
||||||
|
await done;
|
||||||
|
assert.equal(writes.length, 2);
|
||||||
|
assert.equal(writes[1].highContrast, true);
|
||||||
|
assert.deepEqual(writes[1].preference, {material:'frosted', opacity:95});
|
||||||
|
assert.equal(published.length, 1);
|
||||||
|
assert.equal(published[0].opacity, 95);
|
||||||
|
assert.equal(queue.busy, false);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
});
|
||||||
|
test('failed stale mutation cannot suppress a queued contrast restore', async () => {
|
||||||
|
let reject, settled;
|
||||||
|
const done = new Promise(r => settled = r);
|
||||||
|
const writes = [], errors = [], published = [];
|
||||||
|
const queue = new AppearanceUpdates(update => {
|
||||||
|
writes.push(update);
|
||||||
|
return writes.length === 1 ? new Promise((_,r) => reject=r) : Promise.resolve(glass());
|
||||||
|
}, status => published.push(status), error => errors.push(error), settled);
|
||||||
|
queue.submit(true);
|
||||||
|
queue.submit(false);
|
||||||
|
reject(new Error('owned stale failure'));
|
||||||
|
await done;
|
||||||
|
assert.equal(writes.length,2);
|
||||||
|
assert.equal(writes[1].highContrast,false);
|
||||||
|
assert.equal(published.length,1);
|
||||||
|
assert.deepEqual(errors,[]);
|
||||||
|
});
|
||||||
@@ -44,3 +44,19 @@ The brand board's example `searchi winter.pdf` is illustrative. Documentation
|
|||||||
must show the actual supported CLI syntax. Model downloads require networking;
|
must show the actual supported CLI syntax. Model downloads require networking;
|
||||||
file extraction, inference, and search remain local. Never turn visual marketing
|
file extraction, inference, and search remain local. Never turn visual marketing
|
||||||
copy into unsupported performance or production-readiness claims.
|
copy into unsupported performance or production-readiness claims.
|
||||||
|
|
||||||
|
## Window materials
|
||||||
|
|
||||||
|
Appearance settings offer Solid, Glass and Frosted glass, with a bounded
|
||||||
|
70–100% tint-strength control (90% by default). Frosted glass layers an extra
|
||||||
|
35% milk tint beneath that control; Glass has the lighter direct tint. Both
|
||||||
|
currently share Windows native acrylic blur. Native compositor effects expose and soften
|
||||||
|
the actual desktop; CSS transparency alone cannot blur other windows. If the
|
||||||
|
platform cannot apply its native effect, use an honest opaque fallback. Forced
|
||||||
|
colors always make the shell opaque while retaining the saved preference.
|
||||||
|
Search input, results, previews and settings keep their opaque reading surfaces;
|
||||||
|
never lower text opacity or introduce decorative wallpaper simulations.
|
||||||
|
Native acrylic currently requires Windows 11 22H2 or later with a compatible
|
||||||
|
compositor and system transparency settings. macOS, Linux and older Windows
|
||||||
|
currently use opaque fallback. Native material appearance and compositor
|
||||||
|
performance still need actual OS validation.
|
||||||
+8
-2
@@ -36,6 +36,10 @@ Status: implementation started; no production release yet. Windows VM tests use
|
|||||||
see [incremental semantic design](INCREMENTAL-SEMANTIC.md).
|
see [incremental semantic design](INCREMENTAL-SEMANTIC.md).
|
||||||
|
|
||||||
## 3. Desktop
|
## 3. Desktop
|
||||||
|
- [x] Persisted solid/glass/frosted appearance and bounded background tint;
|
||||||
|
opaque reading surfaces, serialized settings updates and high-contrast fallback.
|
||||||
|
- [ ] Verify native glass visually on Windows 11 22H2+; macOS/Linux currently
|
||||||
|
use an explicit opaque fallback. Native compositor performance remains open.
|
||||||
- [ ] Tauri shell, accessible keyboard search, progressive results and previews (shell built; UI interaction verification pending).
|
- [ ] Tauri shell, accessible keyboard search, progressive results and previews (shell built; UI interaction verification pending).
|
||||||
- [ ] Folder selection, exclusion controls, resource budgets and indexing status.
|
- [ ] Folder selection, exclusion controls, resource budgets and indexing status.
|
||||||
- [ ] Global shortcut, opening/revealing source files, PDF page navigation.
|
- [ ] Global shortcut, opening/revealing source files, PDF page navigation.
|
||||||
@@ -197,8 +201,10 @@ Audit and measured priorities: [EMBEDDINGGEMMA2-AUDIT.md](EMBEDDINGGEMMA2-AUDIT.
|
|||||||
media retry status across engine/desktop; genuine CPU promotion reuses
|
media retry status across engine/desktop; genuine CPU promotion reuses
|
||||||
identical document vectors with zero document re-encodes.
|
identical document vectors with zero document re-encodes.
|
||||||
See [early text setup](EARLY-TEXT-SETUP.md).
|
See [early text setup](EARLY-TEXT-SETUP.md).
|
||||||
- [ ] Verify native first use during genuine delayed media download,
|
- [x] Native Linux CPU first use during genuine delayed media download,
|
||||||
failure/retry and promotion; record actual first-input timing separately.
|
failure/retry and promotion; actual first-input timing recorded separately.
|
||||||
|
Focused activation retest preserves selection and query process, records
|
||||||
|
fresh timing/current generation after idle. Windows/macOS checks stay open.
|
||||||
- [ ] Buffered bounded media IPC and linear UTF-8 source-window offset processing.
|
- [ ] Buffered bounded media IPC and linear UTF-8 source-window offset processing.
|
||||||
- [ ] Evaluate 256d MRL and content-specific vision budgets on independent data.
|
- [ ] Evaluate 256d MRL and content-specific vision budgets on independent data.
|
||||||
- [ ] Validate GPU precision, synchronization-aware timings, parity and fallback
|
- [ ] Validate GPU precision, synchronization-aware timings, parity and fallback
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Configurable window backgrounds
|
||||||
|
|
||||||
|
Settings → Appearance offers Solid, Glass and Frosted glass. Preferences are
|
||||||
|
saved locally. The tint-strength control ranges from 70 to 100%; the default
|
||||||
|
is Frosted glass at 90%. Glass uses the direct tint; Frosted glass adds a 35%
|
||||||
|
milk layer beneath it. This changes background treatment, never text opacity.
|
||||||
|
|
||||||
|
Search input, result and preview panes and settings retain opaque reading
|
||||||
|
surfaces. Shell secondary text becomes stronger over translucent backgrounds.
|
||||||
|
High contrast immediately forces an opaque surface without changing the saved
|
||||||
|
preference. Appearance writes are serialized; obsolete replies cannot overwrite
|
||||||
|
the latest preference or accessibility state.
|
||||||
|
|
||||||
|
## Platform support
|
||||||
|
|
||||||
|
Windows 11 22H2 and newer use a transparent WebView and the established
|
||||||
|
window-vibrancy crate's native acrylic implementation. Glass and Frosted glass
|
||||||
|
share that native blur; their frontend milk tint differs. Desktop composition
|
||||||
|
and OS high-contrast checks precede application, and native HRESULT failure
|
||||||
|
returns an explicit solid fallback. Native window chrome remains enabled.
|
||||||
|
|
||||||
|
Older Windows, macOS and Linux currently use a solid fallback. This macOS build
|
||||||
|
does not enable private WebView transparency APIs. CSS backdrop-filter is not
|
||||||
|
used to pretend the desktop is blurred. The settings report the effective
|
||||||
|
material and explain the fallback.
|
||||||
|
|
||||||
|
## Recorded checks, 2026-10-13
|
||||||
|
|
||||||
|
- 57 frontend tests passed, including missing native capability, high contrast,
|
||||||
|
bounded tint, serialized pending preferences and stale failure handling.
|
||||||
|
- Frontend type checking, production build and formatting passed.
|
||||||
|
- 33 desktop Rust tests passed with one test thread; settings checks cover legacy
|
||||||
|
defaults, persisted preferences and invalid-value rejection preserving the
|
||||||
|
previous file and memory. An earlier parallel run hit a transient Windows
|
||||||
|
AccessDenied in an existing atomic-settings replacement test.
|
||||||
|
- Desktop Rust formatting and strict all-target Clippy passed.
|
||||||
|
|
||||||
|
Actual native material appearance and resize/compositor performance remain
|
||||||
|
unverified. Enabled browser inventory was empty; native Windows capture/input
|
||||||
|
access was unavailable. These checks establish implementation and persistence,
|
||||||
|
not visual quality on release hardware. macOS/Linux native materials remain
|
||||||
|
future work.
|
||||||
Reference in new issue
Block a user