diff --git a/src-tauri/src/config/window_settings.rs b/src-tauri/src/config/window_settings.rs index adb57bbb8..403c1548d 100644 --- a/src-tauri/src/config/window_settings.rs +++ b/src-tauri/src/config/window_settings.rs @@ -9,9 +9,44 @@ use serde::{Deserialize, Serialize}; use std::path::Path; use std::sync::{Arc, Mutex}; +/// Titlebar rendering mode. Controls which decoration source is active and +/// how the custom titlebar component renders (or whether it renders at all). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TitlebarMode { + /// Default — full custom titlebar with drag, resize, dblclick, controls. + Qbz, + /// OS-native chrome (CSD on Wayland, KWin SSD on X11, NSWindow on macOS). + System, + /// KDE-only — KWin SSD via Xwayland + stripped custom strip below for search+nav. + Plasma, + /// No titlebar — for tiling WMs (i3/Hyprland/sway/niri). + Hidden, +} + +impl TitlebarMode { + /// Whether this mode requests OS decorations (decorations=true at window creation). + pub fn wants_decorations(self) -> bool { + matches!(self, TitlebarMode::System | TitlebarMode::Plasma) + } + + /// Whether this mode requires GDK_BACKEND=x11 to coerce SSD on Wayland. + pub fn wants_xwayland(self) -> bool { + matches!(self, TitlebarMode::Plasma) + } +} + +impl Default for TitlebarMode { + fn default() -> Self { + TitlebarMode::Qbz + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WindowSettings { - /// Use OS native window decorations instead of custom title bar + /// Use OS native window decorations (legacy — superseded by `titlebar_mode`). + /// Kept as a write-through shadow of `titlebar_mode in {System, Plasma}` for + /// any consumers that still read it; will be removed in a follow-up. pub use_system_titlebar: bool, /// Last saved window width (logical pixels, non-maximized) pub window_width: f64, @@ -23,6 +58,8 @@ pub struct WindowSettings { /// Implies building the window transparent on Linux so the corners /// can be seen through to the desktop. Requires restart to take effect. pub match_system_window_chrome: bool, + /// Authoritative titlebar mode (replaces `use_system_titlebar`). + pub titlebar_mode: TitlebarMode, } impl Default for WindowSettings { @@ -33,6 +70,7 @@ impl Default for WindowSettings { window_height: 800.0, is_maximized: false, match_system_window_chrome: false, + titlebar_mode: TitlebarMode::Qbz, } } } @@ -86,6 +124,26 @@ impl WindowSettingsStore { [], ); + // Add titlebar_mode column. Defaults to NULL so we can detect + // pre-existing rows and migrate from use_system_titlebar. + let _ = conn.execute( + "ALTER TABLE window_settings ADD COLUMN titlebar_mode TEXT", + [], + ); + + // One-time migration: if titlebar_mode is NULL but use_system_titlebar + // exists, derive mode. This runs at every open but only does work on + // rows where titlebar_mode is still NULL (idempotent). + let _ = conn.execute( + "UPDATE window_settings + SET titlebar_mode = CASE + WHEN use_system_titlebar = 1 THEN 'system' + ELSE 'qbz' + END + WHERE titlebar_mode IS NULL AND id = 1", + [], + ); + info!("[WindowSettings] Database initialized"); Ok(Self { conn }) @@ -102,11 +160,32 @@ impl WindowSettingsStore { Self::open_at(base_dir, "window_settings.db") } + /// Lightweight read-only open for startup (before Tauri state). + /// Opens existing DB without creating tables/running migrations. + pub fn new_readonly() -> Result { + let db_path = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz") + .join("window_settings.db"); + + if !db_path.exists() { + return Err("Window settings DB does not exist yet".to_string()); + } + + let conn = Connection::open_with_flags( + &db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|e| format!("Failed to open window settings database (readonly): {}", e))?; + + Ok(Self { conn }) + } + pub fn get_settings(&self) -> Result { self.conn .query_row( "SELECT use_system_titlebar, window_width, window_height, is_maximized, - match_system_window_chrome + match_system_window_chrome, titlebar_mode FROM window_settings WHERE id = 1", [], |row| { @@ -115,6 +194,13 @@ impl WindowSettingsStore { let window_height: f64 = row.get(2)?; let is_maximized: i32 = row.get(3)?; let match_chrome: i32 = row.get(4)?; + let mode_str: Option = row.get(5)?; + let titlebar_mode = match mode_str.as_deref() { + Some("system") => TitlebarMode::System, + Some("plasma") => TitlebarMode::Plasma, + Some("hidden") => TitlebarMode::Hidden, + _ => TitlebarMode::Qbz, + }; let defaults = WindowSettings::default(); let (w, h) = if is_valid_window_size(window_width, window_height) { (window_width, window_height) @@ -132,6 +218,7 @@ impl WindowSettingsStore { window_height: h, is_maximized: is_maximized != 0, match_system_window_chrome: match_chrome != 0, + titlebar_mode, }) }, ) @@ -158,6 +245,26 @@ impl WindowSettingsStore { Ok(()) } + pub fn set_titlebar_mode(&self, mode: TitlebarMode) -> Result<(), String> { + let mode_str = match mode { + TitlebarMode::Qbz => "qbz", + TitlebarMode::System => "system", + TitlebarMode::Plasma => "plasma", + TitlebarMode::Hidden => "hidden", + }; + let use_system = mode.wants_decorations() as i64; + self.conn + .execute( + "UPDATE window_settings + SET titlebar_mode = ?1, use_system_titlebar = ?2 + WHERE id = 1", + params![mode_str, use_system], + ) + .map_err(|e| format!("Failed to set titlebar_mode: {}", e))?; + info!("Window titlebar_mode set to {}", mode_str); + Ok(()) + } + /// Save the non-maximized window dimensions (called on resize while not maximized). pub fn set_window_size(&self, width: f64, height: f64) -> Result<(), String> { if !is_valid_window_size(width, height) { @@ -262,6 +369,23 @@ impl WindowSettingsState { .ok_or("Window settings store not initialized")?; store.set_match_system_window_chrome(value) } + + pub fn set_titlebar_mode(&self, mode: TitlebarMode) -> Result<(), String> { + let store = self.store.lock().map_err(|e| format!("lock poisoned: {}", e))?; + store + .as_ref() + .ok_or_else(|| "Window settings store not initialized".to_string())? + .set_titlebar_mode(mode) + } + + pub fn get_titlebar_mode(&self) -> Result { + let store = self.store.lock().map_err(|e| format!("lock poisoned: {}", e))?; + let settings = store + .as_ref() + .ok_or_else(|| "Window settings store not initialized".to_string())? + .get_settings()?; + Ok(settings.titlebar_mode) + } } /// Check that window dimensions are within a sane range.