diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index cd0f053..26704ca 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,8 +1,8 @@ { "$schema": "https://schema.tauri.app/config/2", "identifier": "default", - "description": "Capability for the main window", - "windows": ["main"], + "description": "Capability for the main and settings windows", + "windows": ["main", "settings"], "permissions": [ "core:default", "core:window:default", diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 3f9c0b3..3e49ca6 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:default","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","core:window:allow-set-position","core:window:allow-set-size","core:window:allow-center","core:window:allow-is-visible","core:window:allow-start-dragging","core:window:allow-cursor-position","core:window:allow-outer-position","core:window:allow-inner-position","core:window:allow-outer-size","core:window:allow-inner-size","core:window:allow-current-monitor","core:window:allow-primary-monitor","core:window:allow-available-monitors","core:event:default","core:event:allow-emit","core:event:allow-listen","core:app:default","core:tray:default","core:tray:allow-set-icon","core:tray:allow-set-tooltip","core:resources:default","shell:default","shell:allow-open"],"platforms":["linux","windows","macOS"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Capability for the main and settings windows","local":true,"windows":["main","settings"],"permissions":["core:default","core:window:default","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","core:window:allow-set-position","core:window:allow-set-size","core:window:allow-center","core:window:allow-is-visible","core:window:allow-start-dragging","core:window:allow-cursor-position","core:window:allow-outer-position","core:window:allow-inner-position","core:window:allow-outer-size","core:window:allow-inner-size","core:window:allow-current-monitor","core:window:allow-primary-monitor","core:window:allow-available-monitors","core:event:default","core:event:allow-emit","core:event:allow-listen","core:app:default","core:tray:default","core:tray:allow-set-icon","core:tray:allow-set-tooltip","core:resources:default","shell:default","shell:allow-open"],"platforms":["linux","windows","macOS"]}} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d32cd83..4c89173 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ pub mod focus_manager; pub mod gif_manager; pub mod input_simulator; pub mod session; +pub mod user_settings; #[cfg(target_os = "linux")] pub mod linux_shortcut_manager; @@ -18,3 +19,4 @@ pub use emoji_manager::{EmojiManager, EmojiUsage}; pub use focus_manager::{restore_focused_window, save_focused_window}; pub use gif_manager::{paste_gif_to_clipboard, paste_gif_to_clipboard_with_uri}; pub use session::{get_session_type, is_wayland, is_x11, SessionType}; +pub use user_settings::{UserSettings, UserSettingsManager}; diff --git a/src-tauri/src/linux_shortcut_manager.rs b/src-tauri/src/linux_shortcut_manager.rs index efc0026..3ba905c 100644 --- a/src-tauri/src/linux_shortcut_manager.rs +++ b/src-tauri/src/linux_shortcut_manager.rs @@ -24,11 +24,29 @@ pub struct ShortcutConfig { pub cosmic_key: &'static str, } +fn get_command_path() -> &'static str { + // First, check if binary is in PATH (production install) + if Utils::command_exists("win11-clipboard-history") { + return "win11-clipboard-history"; + } + + // Try to find the current executable path (for development) + if let Ok(exe_path) = env::current_exe() { + let path_str = exe_path.to_string_lossy().to_string(); + // Leak the string to get a 'static lifetime + // This is acceptable since this is called once at startup + return Box::leak(path_str.into_boxed_str()); + } + + // Fallback to just the name + "win11-clipboard-history" +} + const SHORTCUTS: &[ShortcutConfig] = &[ ShortcutConfig { id: "win11-clipboard-history", name: "Clipboard History", - command: "win11-clipboard-history", + command: "win11-clipboard-history", // Will be replaced at runtime gnome_binding: "v", kde_binding: "Meta+V", xfce_binding: "v", @@ -38,7 +56,7 @@ const SHORTCUTS: &[ShortcutConfig] = &[ ShortcutConfig { id: "win11-clipboard-history-alt", name: "Clipboard History (Alt)", - command: "win11-clipboard-history", + command: "win11-clipboard-history", // Will be replaced at runtime gnome_binding: "v", kde_binding: "Ctrl+Alt+V", xfce_binding: "v", @@ -92,13 +110,17 @@ pub fn register_global_shortcut() { let handler = detect_handler(); println!("[ShortcutManager] Detected Environment: {}", handler.name()); + let command_path = get_command_path(); + println!("[ShortcutManager] Using command path: {}", command_path); + for shortcut in SHORTCUTS { - match handler.register(shortcut) { - Ok(_) => println!("[ShortcutManager] \u{2713} Registered '{}'", shortcut.name), - Err(e) => eprintln!( - "[ShortcutManager] \u{2717} Failed '{}': {}", - shortcut.name, e - ), + // Create a new config with the correct command path + let mut config = shortcut.clone(); + config.command = command_path; + + match handler.register(&config) { + Ok(_) => println!("[ShortcutManager] \u{2713} Registered '{}'", config.name), + Err(e) => eprintln!("[ShortcutManager] \u{2717} Failed '{}': {}", config.name, e), } } } @@ -107,16 +129,16 @@ pub fn unregister_global_shortcut() { let handler = detect_handler(); println!("[ShortcutManager] Environment: {}", handler.name()); + let command_path = get_command_path(); + for shortcut in SHORTCUTS { - match handler.unregister(shortcut) { - Ok(_) => println!( - "[ShortcutManager] \u{2713} Unregistered '{}'", - shortcut.name - ), - Err(e) => eprintln!( - "[ShortcutManager] \u{2717} Failed '{}': {}", - shortcut.name, e - ), + // Create a new config with the correct command path + let mut config = shortcut.clone(); + config.command = command_path; + + match handler.unregister(&config) { + Ok(_) => println!("[ShortcutManager] \u{2713} Unregistered '{}'", config.name), + Err(e) => eprintln!("[ShortcutManager] \u{2717} Failed '{}': {}", config.name, e), } } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 933ca99..8199334 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -18,6 +18,7 @@ use win11_clipboard_history_lib::emoji_manager::{EmojiManager, EmojiUsage}; use win11_clipboard_history_lib::focus_manager::{restore_focused_window, save_focused_window}; use win11_clipboard_history_lib::input_simulator::simulate_paste_keystroke; use win11_clipboard_history_lib::session::is_wayland; +use win11_clipboard_history_lib::user_settings::{UserSettings, UserSettingsManager}; /// Application state shared across all handlers pub struct AppState { @@ -59,6 +60,33 @@ fn set_mouse_state(state: State, inside: bool) { state.is_mouse_inside.store(inside, Ordering::Relaxed); } +// --- User Settings Commands --- + +#[tauri::command] +fn get_user_settings() -> Result { + let manager = UserSettingsManager::new(); + Ok(manager.load()) +} + +#[tauri::command] +fn set_user_settings(app: AppHandle, new_settings: UserSettings) -> Result<(), String> { + let manager = UserSettingsManager::new(); + manager.save(&new_settings)?; + + // Emit event to notify all windows that settings have changed + app.emit("app-settings-changed", &new_settings) + .map_err(|e| format!("Failed to emit settings changed event: {}", e))?; + + Ok(()) +} + +#[tauri::command] +fn is_settings_window_visible(app: AppHandle) -> bool { + app.get_webview_window("settings") + .map(|w| w.is_visible().unwrap_or(false)) + .unwrap_or(false) +} + #[tauri::command] async fn paste_item(app: AppHandle, state: State<'_, AppState>, id: String) -> Result<(), String> { // 1. Get Item (Scope lock tightly) @@ -326,6 +354,53 @@ impl WindowController { } } +// --- Settings Window Controller --- + +struct SettingsController; + +impl SettingsController { + /// Shows the settings window, recreating it if somehow destroyed + pub fn show(app: &AppHandle) { + use tauri::{WebviewUrl, WebviewWindowBuilder}; + + match app.get_webview_window("settings") { + Some(window) => { + let _ = window.show(); + let _ = window.set_focus(); + } + None => { + // Fallback: recreate the window if it was somehow destroyed + eprintln!( + "[SettingsController] Settings window missing, recreating as fallback..." + ); + + match WebviewWindowBuilder::new( + app, + "settings", + WebviewUrl::App("index.html".into()), + ) + .title("Settings - Clipboard History") + .inner_size(480.0, 520.0) + .resizable(false) + .decorations(true) + .transparent(false) + .visible(true) + .skip_taskbar(false) + .always_on_top(false) + .center() + .focused(true) + .build() + { + Ok(_) => { + println!("[SettingsController] Settings window recreated successfully") + } + Err(e) => eprintln!("[SettingsController] Failed to recreate window: {}", e), + } + } + } + } +} + // --- Window Event Helper --- fn handle_window_moved_for_wayland( @@ -396,11 +471,34 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); fn main() { let args: Vec = std::env::args().collect(); - if args.len() > 1 && (args[1] == "--version" || args[1] == "-v") { + + // Handle --version / -v + if args.iter().any(|arg| arg == "--version" || arg == "-v") { println!("win11-clipboard-history {}", VERSION); return; } + // Handle --help / -h + if args.iter().any(|arg| arg == "--help" || arg == "-h") { + println!("win11-clipboard-history {}", VERSION); + println!(); + println!("USAGE:"); + println!(" win11-clipboard-history [OPTIONS]"); + println!(); + println!("OPTIONS:"); + println!(" -h, --help Show this help message"); + println!(" -v, --version Show version information"); + println!(" --settings Open settings window on startup"); + println!(); + println!("SHORTCUTS:"); + println!(" Super+V Open clipboard history"); + println!(" Ctrl+Alt+V Alternative shortcut"); + return; + } + + // Check if --settings flag is present (for first instance startup) + let open_settings_on_start = args.iter().any(|arg| arg == "--settings"); + win11_clipboard_history_lib::session::init(); let is_mouse_inside = Arc::new(AtomicBool::new(false)); @@ -418,9 +516,17 @@ fn main() { .plugin(tauri_plugin_shell::init()) // Single Instance Plugin: When user triggers shortcut and app is already running, // the OS launches a new instance which signals the existing one to toggle - .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { - println!("[SingleInstance] Secondary instance detected, toggling window..."); - WindowController::toggle(app); + .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { + // Check if --settings flag is present + if argv.iter().any(|arg| arg == "--settings") { + println!( + "[SingleInstance] Secondary instance with --settings flag, opening settings..." + ); + SettingsController::show(app); + } else { + println!("[SingleInstance] Secondary instance detected, toggling window..."); + WindowController::toggle(app); + } })) .manage(AppState { clipboard_manager: clipboard_manager.clone(), @@ -431,9 +537,10 @@ fn main() { .setup(move |app| { let app_handle = app.handle().clone(); - let show = MenuItem::with_id(app, "show", "Show", true, None::<&str>)?; + let show = MenuItem::with_id(app, "show", "Show Clipboard", true, None::<&str>)?; + let settings = MenuItem::with_id(app, "settings", "Settings", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&show, &quit])?; + let menu = Menu::with_items(app, &[&show, &settings, &quit])?; let icon = Image::from_bytes(include_bytes!("../icons/icon.png")).unwrap(); @@ -443,6 +550,7 @@ fn main() { .on_menu_event(move |app, event| match event.id.as_ref() { "quit" => app.exit(0), "show" => WindowController::toggle(app), + "settings" => SettingsController::show(app), _ => {} }) .on_tray_icon_event(|tray, event| { @@ -456,9 +564,17 @@ fn main() { }) .build(app)?; + // Verify that settings window was created from config + if app.get_webview_window("settings").is_none() { + eprintln!("[Setup] FATAL: Settings window missing from config"); + } else { + println!("[Setup] Settings window created successfully from config"); + } + // Window Event Handlers (Focus & Move) let main_window = app.get_webview_window("main").unwrap(); let w_clone = main_window.clone(); + let app_handle_for_event = app_handle.clone(); main_window.on_window_event(move |event| match event { WindowEvent::Focused(false) => { @@ -467,6 +583,15 @@ fn main() { return; } + // Don't hide if settings window is visible (for live preview) + if let Some(settings_window) = + app_handle_for_event.get_webview_window("settings") + { + if settings_window.is_visible().unwrap_or(false) { + return; + } + } + if is_wayland() { state.config_manager.lock().sync_to_disk(); } @@ -492,6 +617,11 @@ fn main() { win11_clipboard_history_lib::linux_shortcut_manager::register_global_shortcut(); }); + // If --settings flag was passed on first startup, open the settings window + if open_settings_on_start { + SettingsController::show(&app_handle); + } + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -505,6 +635,9 @@ fn main() { paste_gif_from_url, finish_paste, set_mouse_state, + get_user_settings, + set_user_settings, + is_settings_window_visible, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/user_settings.rs b/src-tauri/src/user_settings.rs new file mode 100644 index 0000000..dd7a54e --- /dev/null +++ b/src-tauri/src/user_settings.rs @@ -0,0 +1,153 @@ +//! User Settings Module +//! Handles persistence of user preferences (theme mode, background opacity) in a separate JSON file. + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +const USER_SETTINGS_FILE: &str = "user_settings.json"; + +/// User-configurable settings for the application +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserSettings { + /// Theme mode: "system", "dark", or "light" + pub theme_mode: String, + /// Background opacity for dark mode (0.0 to 1.0) + /// Default matches the original glass-effect alpha of 0.05 + pub dark_background_opacity: f32, + /// Background opacity for light mode (0.0 to 1.0) + /// Default matches the original glass-effect-light alpha of 0.85 + pub light_background_opacity: f32, +} + +impl Default for UserSettings { + fn default() -> Self { + Self { + theme_mode: "system".to_string(), + dark_background_opacity: 0.70, + light_background_opacity: 0.70, + } + } +} + +impl UserSettings { + /// Validates and clamps opacity values to the valid range [0.0, 1.0] + pub fn validate(&mut self) { + self.dark_background_opacity = self.dark_background_opacity.clamp(0.0, 1.0); + self.light_background_opacity = self.light_background_opacity.clamp(0.0, 1.0); + + // Validate theme_mode + if !["system", "dark", "light"].contains(&self.theme_mode.as_str()) { + self.theme_mode = "system".to_string(); + } + } +} + +/// Manages loading and saving of user settings +pub struct UserSettingsManager { + config_dir: PathBuf, +} + +impl UserSettingsManager { + /// Creates a new UserSettingsManager + /// Uses the OS-appropriate config directory (e.g., ~/.config/win11-clipboard-history/) + pub fn new() -> Self { + let config_dir = dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("win11-clipboard-history"); + + Self { config_dir } + } + + /// Gets the path to the settings file + fn settings_path(&self) -> PathBuf { + self.config_dir.join(USER_SETTINGS_FILE) + } + + /// Loads user settings from the config file + /// Returns default settings if the file doesn't exist or is invalid + pub fn load(&self) -> UserSettings { + let path = self.settings_path(); + + if !path.exists() { + return UserSettings::default(); + } + + match fs::read_to_string(&path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(mut settings) => { + settings.validate(); + settings + } + Err(e) => { + eprintln!( + "[UserSettings] Failed to parse settings file: {}. Using defaults.", + e + ); + UserSettings::default() + } + }, + Err(e) => { + eprintln!( + "[UserSettings] Failed to read settings file: {}. Using defaults.", + e + ); + UserSettings::default() + } + } + } + + /// Saves user settings to the config file + pub fn save(&self, settings: &UserSettings) -> Result<(), String> { + // Ensure the config directory exists + if !self.config_dir.exists() { + fs::create_dir_all(&self.config_dir) + .map_err(|e| format!("Failed to create config directory: {}", e))?; + } + + // Validate settings before saving + let mut validated_settings = settings.clone(); + validated_settings.validate(); + + let content = serde_json::to_string_pretty(&validated_settings) + .map_err(|e| format!("Failed to serialize settings: {}", e))?; + + fs::write(self.settings_path(), content) + .map_err(|e| format!("Failed to write settings file: {}", e))?; + + Ok(()) + } +} + +impl Default for UserSettingsManager { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_settings() { + let settings = UserSettings::default(); + assert_eq!(settings.theme_mode, "system"); + assert!((settings.dark_background_opacity - 0.05).abs() < f32::EPSILON); + assert!((settings.light_background_opacity - 0.85).abs() < f32::EPSILON); + } + + #[test] + fn test_validate_clamps_values() { + let mut settings = UserSettings { + theme_mode: "invalid".to_string(), + dark_background_opacity: 1.5, + light_background_opacity: -0.5, + }; + settings.validate(); + + assert_eq!(settings.theme_mode, "system"); + assert!((settings.dark_background_opacity - 1.0).abs() < f32::EPSILON); + assert!(settings.light_background_opacity.abs() < f32::EPSILON); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bacda23..0286c06 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -28,6 +28,20 @@ "skipTaskbar": true, "alwaysOnTop": true, "focus": true + }, + { + "title": "Settings - Clipboard History", + "label": "settings", + "width": 480, + "height": 520, + "resizable": false, + "decorations": true, + "transparent": false, + "visible": false, + "skipTaskbar": false, + "alwaysOnTop": false, + "center": true, + "focus": true } ], "security": { diff --git a/src/App.tsx b/src/ClipboardApp.tsx similarity index 50% rename from src/App.tsx rename to src/ClipboardApp.tsx index ab57e72..34e5f92 100644 --- a/src/App.tsx +++ b/src/ClipboardApp.tsx @@ -1,8 +1,9 @@ import { useState, useCallback, useEffect } from 'react' import { clsx } from 'clsx' import { getCurrentWindow } from '@tauri-apps/api/window' +import { listen } from '@tauri-apps/api/event' +import { invoke } from '@tauri-apps/api/core' import { useClipboardHistory } from './hooks/useClipboardHistory' -import { useDarkMode } from './hooks/useDarkMode' import { HistoryItem } from './components/HistoryItem' import { TabBar } from './components/TabBar' import { Header } from './components/Header' @@ -11,18 +12,122 @@ import { DragHandle } from './components/DragHandle' import { EmojiPicker } from './components/EmojiPicker' import { GifPicker } from './components/GifPicker' import type { ActiveTab } from './types/clipboard' -import { invoke } from '@tauri-apps/api/core' + +/** User settings type matching the Rust struct */ +interface UserSettings { + theme_mode: 'system' | 'dark' | 'light' + dark_background_opacity: number + light_background_opacity: number +} + +const DEFAULT_SETTINGS: UserSettings = { + theme_mode: 'system', + dark_background_opacity: 0.7, + light_background_opacity: 0.7, +} /** - * Main App Component - Windows 11 Clipboard History Manager + * Determines if dark mode should be active based on theme mode setting */ -function App() { +function useThemeMode(themeMode: 'system' | 'dark' | 'light'): boolean { + const [systemPrefersDark, setSystemPrefersDark] = useState(() => { + if (globalThis.matchMedia) { + return globalThis.matchMedia('(prefers-color-scheme: dark)').matches + } + return true + }) + + useEffect(() => { + const mediaQuery = globalThis.matchMedia('(prefers-color-scheme: dark)') + const handleChange = (e: MediaQueryListEvent) => { + setSystemPrefersDark(e.matches) + } + mediaQuery.addEventListener('change', handleChange) + return () => mediaQuery.removeEventListener('change', handleChange) + }, []) + + // Determine actual dark mode based on theme setting + if (themeMode === 'dark') return true + if (themeMode === 'light') return false + return systemPrefersDark // 'system' mode +} + +/** + * Applies background opacity CSS variables based on user settings + * Opacity: 0.0 = fully transparent, 1.0 = fully opaque + */ +function applyBackgroundOpacity(settings: UserSettings) { + const root = document.documentElement + + // The gradient end is slightly less opaque than start for a subtle effect + // Using a small offset (0.03) to create a gentle gradient + const darkStart = settings.dark_background_opacity + const darkEnd = Math.max(0, darkStart - 0.03) + const lightStart = settings.light_background_opacity + const lightEnd = Math.max(0, lightStart - 0.05) + + root.style.setProperty('--win11-dark-bg-alpha-start', darkStart.toString()) + root.style.setProperty('--win11-dark-bg-alpha-end', darkEnd.toString()) + root.style.setProperty('--win11-light-bg-alpha-start', lightStart.toString()) + root.style.setProperty('--win11-light-bg-alpha-end', lightEnd.toString()) +} + +/** + * Updates the document's dark class based on theme + */ +function applyThemeClass(isDark: boolean) { + if (isDark) { + document.documentElement.classList.add('dark') + } else { + document.documentElement.classList.remove('dark') + } +} + +/** + * Main Clipboard App Component - Windows 11 Clipboard History Manager + */ +function ClipboardApp() { const [activeTab, setActiveTab] = useState('clipboard') - const isDark = useDarkMode() + const [settings, setSettings] = useState(DEFAULT_SETTINGS) + const [settingsLoaded, setSettingsLoaded] = useState(false) + + const isDark = useThemeMode(settings.theme_mode) const { history, isLoading, clearHistory, deleteItem, togglePin, pasteItem } = useClipboardHistory() + // Load initial settings and set up listener for changes + useEffect(() => { + // Load initial settings + invoke('get_user_settings') + .then((loadedSettings) => { + setSettings(loadedSettings) + applyBackgroundOpacity(loadedSettings) + setSettingsLoaded(true) + }) + .catch((err) => { + console.error('Failed to load user settings:', err) + applyBackgroundOpacity(DEFAULT_SETTINGS) + setSettingsLoaded(true) + }) + + // Listen for settings changes from the settings window + const unlistenPromise = listen('app-settings-changed', (event) => { + const newSettings = event.payload + setSettings(newSettings) + applyBackgroundOpacity(newSettings) + }) + + return () => { + unlistenPromise.then((unlisten) => unlisten()) + } + }, []) + + // Apply theme class when isDark changes + useEffect(() => { + applyThemeClass(isDark) + }, [isDark]) + // Handle ESC key to close/hide window useEffect(() => { const handleKeyDown = async (e: KeyboardEvent) => { @@ -101,6 +206,11 @@ function App() { } } + // Don't render until settings are loaded to prevent FOUC + if (!settingsLoaded) { + return null + } + return (
- {/* Header with title and actions */} - {/* Tab bar */} @@ -134,4 +242,4 @@ function App() { ) } -export default App +export default ClipboardApp diff --git a/src/SettingsApp.tsx b/src/SettingsApp.tsx new file mode 100644 index 0000000..adfbfbc --- /dev/null +++ b/src/SettingsApp.tsx @@ -0,0 +1,486 @@ +import { useState, useEffect, useCallback } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { getCurrentWindow, Window } from '@tauri-apps/api/window' +import { listen } from '@tauri-apps/api/event' +import { clsx } from 'clsx' + +/** User settings type matching the Rust struct */ +interface UserSettings { + theme_mode: 'system' | 'dark' | 'light' + dark_background_opacity: number + light_background_opacity: number +} + +const DEFAULT_SETTINGS: UserSettings = { + theme_mode: 'system', + dark_background_opacity: 0.7, + light_background_opacity: 0.7, +} + +type ThemeMode = 'system' | 'dark' | 'light' + +/** + * Determines if dark mode should be active based on theme mode setting + */ +function useThemeMode(themeMode: ThemeMode): boolean { + const [systemPrefersDark, setSystemPrefersDark] = useState(() => { + if (globalThis.matchMedia) { + return globalThis.matchMedia('(prefers-color-scheme: dark)').matches + } + return true + }) + + useEffect(() => { + const mediaQuery = globalThis.matchMedia('(prefers-color-scheme: dark)') + const handleChange = (e: MediaQueryListEvent) => { + setSystemPrefersDark(e.matches) + } + mediaQuery.addEventListener('change', handleChange) + return () => mediaQuery.removeEventListener('change', handleChange) + }, []) + + if (themeMode === 'dark') return true + if (themeMode === 'light') return false + return systemPrefersDark +} + +// --- Icons Components --- +const MonitorIcon = () => ( + + + + + +) + +const MoonIcon = () => ( + + + +) + +const SunIcon = () => ( + + + + + + + + + + + +) + +const ResetIcon = () => ( + + + + +) + +/** + * Settings App Component - Configuration UI for Win11 Clipboard History + */ +function SettingsApp() { + const [settings, setSettings] = useState(DEFAULT_SETTINGS) + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [saveMessage, setSaveMessage] = useState(null) + + // Apply theme to settings window itself + const isDark = useThemeMode(settings.theme_mode) + + useEffect(() => { + if (isDark) { + document.documentElement.classList.add('dark') + } else { + document.documentElement.classList.remove('dark') + } + }, [isDark]) + + // Load settings on mount and show main window for preview + useEffect(() => { + invoke('get_user_settings') + .then((loadedSettings) => { + setSettings(loadedSettings) + setIsLoading(false) + }) + .catch((err) => { + console.error('Failed to load settings:', err) + setIsLoading(false) + }) + + // Show the main clipboard window for live preview + const mainWindow = new Window('main') + mainWindow.show().catch(console.error) + + // Prevent window close, just hide it instead + const currentWindow = getCurrentWindow() + const unlistenClosePromise = currentWindow.onCloseRequested(async (event) => { + event.preventDefault() + await currentWindow.hide() + }) + + // Listen for settings changes (in case another settings window is open) + const unlistenSettingsPromise = listen('app-settings-changed', (event) => { + setSettings(event.payload) + }) + + // Hide main window when settings window closes + return () => { + mainWindow.hide().catch(console.error) + unlistenClosePromise.then((unlisten) => unlisten()) + unlistenSettingsPromise.then((unlisten) => unlisten()) + } + }, []) + + // Save settings with debounce-like behavior + const saveSettings = useCallback(async (newSettings: UserSettings) => { + setIsSaving(true) + setSaveMessage(null) + + try { + await invoke('set_user_settings', { newSettings }) + setSaveMessage('Saved') + setTimeout(() => setSaveMessage(null), 2000) + } catch (err) { + console.error('Failed to save settings:', err) + setSaveMessage('Error saving') + } finally { + setIsSaving(false) + } + }, []) + + // Handle theme mode change + const handleThemeModeChange = (mode: ThemeMode) => { + const newSettings = { ...settings, theme_mode: mode } + setSettings(newSettings) + saveSettings(newSettings) + } + + // Handle dark opacity change (visual only, no disk I/O) + const handleDarkOpacityChange = (value: number) => { + setSettings((prev) => ({ ...prev, dark_background_opacity: value })) + } + + // Handle light opacity change (visual only, no disk I/O) + const handleLightOpacityChange = (value: number) => { + setSettings((prev) => ({ ...prev, light_background_opacity: value })) + } + + // Commit opacity changes to disk (called on mouseUp/touchEnd) + const commitOpacityChange = () => { + saveSettings(settings) + } + + // Handle window close + const handleClose = async () => { + try { + await getCurrentWindow().hide() + } catch (err) { + console.error('Failed to close window:', err) + } + } + + if (isLoading) { + return ( +
+
+
+ Loading preferences... +
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

Personalization

+

+ Customize the look and feel of your clipboard history +

+
+ + {/* Status Indicator */} +
+ {(isSaving || saveMessage) && ( +
+ {isSaving && ( +
+ )} + {saveMessage || 'Saving...'} +
+ )} +
+
+ + {/* Content */} +
+ {/* Theme Selection Card */} +
+
+
+ {settings.theme_mode === 'dark' ? ( + + ) : settings.theme_mode === 'light' ? ( + + ) : ( + + )} +
+

Appearance

+
+ +
+ {(['system', 'light', 'dark'] as ThemeMode[]).map((mode) => ( + + ))} +
+
+ + {/* Transparency Section */} +
+
+

Window Transparency

+

+ Control the backdrop opacity intensity +

+
+ +
+ {/* Dark Mode Slider */} +
+
+ +
+ {Math.round(settings.dark_background_opacity * 100)}% +
+
+ handleDarkOpacityChange(Number.parseFloat(e.target.value))} + onMouseUp={commitOpacityChange} + onTouchEnd={commitOpacityChange} + className="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-win11-bg-accent" + /> +
+ + {/* Light Mode Slider */} +
+
+ +
+ {Math.round(settings.light_background_opacity * 100)}% +
+
+ handleLightOpacityChange(Number.parseFloat(e.target.value))} + onMouseUp={commitOpacityChange} + onTouchEnd={commitOpacityChange} + className="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-win11-bg-accent" + /> +
+
+
+ + {/* Reset Section */} +
+ +
+
+ + {/* Footer */} +
+ +
+
+ ) +} + +export default SettingsApp diff --git a/src/index.css b/src/index.css index f0e5a98..ac0caf7 100644 --- a/src/index.css +++ b/src/index.css @@ -18,6 +18,15 @@ text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + + /* CSS Custom Properties for dynamic background opacity */ + /* These values represent the actual alpha channel (0.0 = transparent, 1.0 = opaque) */ + --win11-dark-bg-alpha-start: 0.7; + --win11-dark-bg-alpha-end: 0.67; + + /* Light mode opacity values */ + --win11-light-bg-alpha-start: 0.7; + --win11-light-bg-alpha-end: 0.65; } /* Reset for Tauri */ @@ -103,8 +112,8 @@ body { .glass-effect { background: linear-gradient( 135deg, - rgba(255, 255, 255, 0.05) 0%, - rgba(255, 255, 255, 0.02) 100% + rgba(40, 40, 40, var(--win11-dark-bg-alpha-start, 0.7)) 0%, + rgba(40, 40, 40, var(--win11-dark-bg-alpha-end, 0.67)) 100% ); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25), @@ -114,8 +123,8 @@ body { .glass-effect-light { background: linear-gradient( 135deg, - rgba(255, 255, 255, 0.85) 0%, - rgba(255, 255, 255, 0.75) 100% + rgba(255, 255, 255, var(--win11-light-bg-alpha-start, 0.7)) 0%, + rgba(255, 255, 255, var(--win11-light-bg-alpha-end, 0.65)) 100% ); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15), diff --git a/src/main.tsx b/src/main.tsx index 611e848..26a2bf3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,28 @@ -import React from 'react' +import React, { useState } from 'react' import ReactDOM from 'react-dom/client' -import App from './App' +import { getCurrentWindow } from '@tauri-apps/api/window' +import ClipboardApp from './ClipboardApp' +import SettingsApp from './SettingsApp' import './index.css' +/** + * Root component that routes to either ClipboardApp or SettingsApp + * based on the current window's label + */ +export default function Root() { + const [windowLabel] = useState(() => getCurrentWindow().label) + + // Route to appropriate app based on window label + if (windowLabel === 'settings') { + return + } + + // Default to ClipboardApp for 'main' and any other window + return +} + ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - + )