Files
Windows-11-Clipboard-Histor…/src/SettingsApp.tsx
Gustavo Carvalho fdfd3c06ef fix: recreate shortcut without permissions (#209)
* feat: Add UI for manual desktop environment shortcut registration

* chore: remove automatic registration on startup
2026-03-08 10:12:54 -03:00

1079 lines
38 KiB
TypeScript

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 { emit } from '@tauri-apps/api/event'
import { clsx } from 'clsx'
import type { UserSettings, CustomKaomoji, BooleanSettingKey } from './types/clipboard'
import { FeaturesSection } from './components/FeaturesSection'
import { Switch } from './components/Switch'
import { useSystemThemePreference } from './utils/systemTheme'
import { useRenderingEnv } from './hooks/useRenderingEnv'
const MIN_HISTORY_SIZE = 1
const MAX_HISTORY_SIZE = 100_000
const DEFAULT_SETTINGS: UserSettings = {
theme_mode: 'system',
dark_background_opacity: 0.7,
light_background_opacity: 0.7,
enable_smart_actions: true,
enable_ui_polish: true,
enable_dynamic_tray_icon: true,
max_history_size: 50,
custom_kaomojis: [],
ui_scale: 1,
auto_delete_interval: 0,
auto_delete_unit: 'hours',
}
type ThemeMode = 'system' | 'dark' | 'light'
/**
* Maps theme mode setting to actual dark mode state.
* For 'system' mode, delegates to the shared useSystemThemePreference hook.
*/
function useThemeMode(themeMode: ThemeMode): boolean {
const systemPrefersDark = useSystemThemePreference()
if (themeMode === 'dark') return true
if (themeMode === 'light') return false
return systemPrefersDark
}
// --- Icons Components ---
const MonitorIcon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</svg>
)
const MoonIcon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
)
const SunIcon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
)
const ResetIcon = () => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
)
// --- Keyboard Shortcuts Section ---
interface KeyboardShortcutsSectionProps {
isDark: boolean
}
function KeyboardShortcutsSection({ isDark }: KeyboardShortcutsSectionProps) {
const [registering, setRegistering] = useState(false)
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const handleRegister = async () => {
setRegistering(true)
setStatus('idle')
setErrorMessage(null)
try {
await invoke<string>('register_de_shortcut')
setStatus('success')
} catch (e) {
console.error('Failed to register shortcuts:', e)
setStatus('error')
setErrorMessage(String(e))
} finally {
setRegistering(false)
}
}
return (
<section
className={clsx(
'rounded-xl border shadow-sm overflow-hidden',
isDark ? 'bg-win11-bg-secondary border-white/5' : 'bg-white border-gray-200/60'
)}
>
<div className="p-6 border-b border-inherit">
<div className="flex items-center gap-3 mb-1">
<div className={clsx('p-2 rounded-lg', isDark ? 'bg-white/5' : 'bg-gray-100')}>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h.01M12 12h.01M16 12h.01M7 16h10" />
</svg>
</div>
<h2 className="text-base font-semibold">Keyboard Shortcuts</h2>
</div>
<p className={clsx('text-xs', isDark ? 'text-gray-400' : 'text-gray-500')}>
Register the application shortcuts in your desktop environment
</p>
</div>
<div className="p-6 space-y-4">
{/* Shortcut list */}
<div
className={clsx(
'rounded-lg border divide-y text-sm font-mono',
isDark
? 'border-white/10 divide-white/10 bg-black/20'
: 'border-gray-200 divide-gray-100 bg-gray-50'
)}
>
{[
{ keys: 'Super + V', desc: 'Open Clipboard History' },
{ keys: 'Ctrl + Alt + V', desc: 'Alternative shortcut' },
{ keys: 'Super + .', desc: 'Open Emoji Picker' },
].map(({ keys, desc }) => (
<div key={keys} className="flex items-center justify-between px-4 py-2.5">
<span
className={clsx(
'px-2 py-0.5 rounded text-xs font-semibold',
isDark ? 'bg-white/10 text-gray-200' : 'bg-gray-200 text-gray-700'
)}
>
{keys}
</span>
<span
className={clsx('text-xs font-sans', isDark ? 'text-gray-400' : 'text-gray-500')}
>
{desc}
</span>
</div>
))}
</div>
<p className={clsx('text-xs leading-relaxed', isDark ? 'text-gray-500' : 'text-gray-400')}>
Shortcuts are only registered when you explicitly request it. If you removed a shortcut
from your system settings and it was re-added, use this button to re-register only the
ones you want.
</p>
{/* Status feedback */}
{status === 'success' && (
<div className="flex items-center gap-2 text-sm text-green-500">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
Shortcuts registered successfully!
</div>
)}
{status === 'error' && (
<div
className={clsx(
'text-xs rounded-lg p-3',
isDark ? 'bg-red-500/10 text-red-400' : 'bg-red-50 text-red-600'
)}
>
<p className="font-medium">Registration failed</p>
{errorMessage && <p className="mt-1 opacity-80">{errorMessage}</p>}
</div>
)}
{/* Action button */}
<button
id="register-shortkeys-btn"
onClick={handleRegister}
disabled={registering}
className={clsx(
'flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-semibold transition-all',
'bg-win11-bg-accent text-white hover:opacity-90 active:scale-95',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100'
)}
>
{registering ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Registering...
</>
) : (
<>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
Register Shortkeys in System
</>
)}
</button>
</div>
</section>
)
}
/**
* Settings App Component - Configuration UI for Win11 Clipboard History
*/
function SettingsApp() {
const [settings, setSettings] = useState<UserSettings>(DEFAULT_SETTINGS)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [saveMessage, setSaveMessage] = useState<string | null>(null)
// Custom Kaomoji State
const [newKaomoji, setNewKaomoji] = useState('')
// Rendering environment (NVIDIA / AppImage detection)
const renderingEnv = useRenderingEnv()
// 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<UserSettings>('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<UserSettings>('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)
}
}, [])
// Centralized settings update helper
const updateSettings = useCallback(
(partial: Partial<UserSettings>) => {
setSettings((prev) => {
const next = { ...prev, ...partial }
saveSettings(next)
return next
})
},
[saveSettings]
)
// Handle theme mode change
const handleThemeModeChange = (mode: ThemeMode) => {
updateSettings({ theme_mode: mode })
}
// Handle dark opacity change (visual only, no disk I/O)
const handleDarkOpacityChange = (value: number) => {
setSettings((prev) => ({ ...prev, dark_background_opacity: value }))
}
const handleAutoDeleteValueChange = (value: string) => {
// Only allow positive integers or 0
const num = Number.parseInt(value)
if (value === '' || (Number.isInteger(num) && num >= 0)) {
const interval = value === '' ? 0 : num
const newSettings = { ...settings, auto_delete_interval: interval }
setSettings(newSettings)
saveSettings(newSettings)
}
}
const handleAutoDeleteUnitChange = (unit: UserSettings['auto_delete_unit']) => {
const newSettings = { ...settings, auto_delete_unit: unit }
setSettings(newSettings)
saveSettings(newSettings)
}
// 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 Feature Toggles
const handleToggle = (key: BooleanSettingKey) => {
// Type safe toggle
updateSettings({ [key]: !settings[key] } as Partial<UserSettings>)
}
// Custom Kaomoji Handlers
const addCustomKaomoji = useCallback(() => {
const val = newKaomoji.trim()
if (!val) return
const newItem: CustomKaomoji = {
text: val,
category: 'Custom',
keywords: ['custom'],
}
updateSettings({ custom_kaomojis: [...settings.custom_kaomojis, newItem] })
setNewKaomoji('')
}, [newKaomoji, settings.custom_kaomojis, updateSettings])
const removeCustomKaomojiAt = useCallback(
(index: number) => {
const newList = settings.custom_kaomojis.filter((_, i) => i !== index)
updateSettings({ custom_kaomojis: newList })
},
[settings.custom_kaomojis, updateSettings]
)
// Handle window close
const handleClose = async () => {
try {
await getCurrentWindow().hide()
} catch (err) {
console.error('Failed to close window:', err)
}
}
if (isLoading) {
return (
<div
className={clsx(
'h-screen w-screen flex items-center justify-center select-none',
isDark
? 'bg-win11-bg-primary text-win11-text-primary'
: 'bg-win11Light-bg-primary text-win11Light-text-primary'
)}
>
<div className="flex flex-col items-center gap-3">
<div className="w-6 h-6 border-2 border-win11-bg-accent border-t-transparent rounded-full animate-spin" />
<span className="text-xs opacity-60 font-medium">Loading preferences...</span>
</div>
</div>
)
}
return (
<div
className={clsx(
'h-screen w-screen flex flex-col font-sans select-none',
isDark
? 'bg-win11-bg-primary text-win11-text-primary'
: 'bg-[#f0f3f9] text-win11Light-text-primary' // Slightly better light gray background
)}
>
{/* Header */}
<header
className={clsx(
'flex items-center justify-between px-8 py-6 flex-shrink-0',
'transition-colors duration-200'
)}
>
<div>
<h1 className="text-2xl font-bold tracking-tight">Personalization</h1>
<p className={clsx('text-sm mt-1', isDark ? 'text-gray-400' : 'text-gray-500')}>
Customize the look and feel of your clipboard history
</p>
</div>
{/* Status Indicator */}
<div className="h-8 flex items-center justify-end min-w-[100px]">
{(isSaving || saveMessage) && (
<div
className={clsx(
'flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium',
saveMessage?.includes('Error')
? 'bg-red-500/10 text-red-500'
: isDark
? 'bg-white/10 text-white'
: 'bg-black/5 text-black'
)}
>
{isSaving && (
<div className="w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
)}
{saveMessage || 'Saving...'}
</div>
)}
</div>
</header>
{/* Content */}
<main className="flex-1 overflow-y-auto px-8 pb-8 space-y-6 custom-scrollbar">
{/* Theme Selection Card */}
<section
className={clsx(
'rounded-xl p-6 border shadow-sm transition-all',
isDark ? 'bg-win11-bg-secondary border-white/5' : 'bg-white border-gray-200/60'
)}
>
<div className="flex items-center gap-3 mb-5">
<div className={clsx('p-2 rounded-lg', isDark ? 'bg-white/5' : 'bg-gray-100')}>
{settings.theme_mode === 'dark' ? (
<MoonIcon />
) : settings.theme_mode === 'light' ? (
<SunIcon />
) : (
<MonitorIcon />
)}
</div>
<h2 className="text-base font-semibold">Appearance</h2>
</div>
<div className="grid grid-cols-3 gap-4">
{(['system', 'light', 'dark'] as ThemeMode[]).map((mode) => (
<button
key={mode}
onClick={() => handleThemeModeChange(mode)}
className={clsx(
'group relative flex flex-col items-center gap-3 p-4 rounded-xl border-2 transition-all duration-200 outline-none focus:ring-2 focus:ring-win11-bg-accent/50',
settings.theme_mode === mode
? 'border-win11-bg-accent bg-win11-bg-accent/5'
: isDark
? 'border-transparent hover:bg-white/5 hover:border-white/10'
: 'border-transparent hover:bg-gray-50 hover:border-gray-200'
)}
>
{/* Visual Representation of Theme */}
<div
className={clsx(
'w-full aspect-[16/10] rounded-lg shadow-sm flex overflow-hidden border',
isDark ? 'border-white/10' : 'border-gray-200'
)}
>
{mode === 'system' && (
<>
<div className="flex-1 bg-[#f3f3f3]" />
<div className="flex-1 bg-[#202020]" />
</>
)}
{mode === 'light' && <div className="flex-1 bg-[#f3f3f3]" />}
{mode === 'dark' && <div className="flex-1 bg-[#202020]" />}
</div>
<div className="flex items-center gap-2">
<span
className={clsx(
'text-sm font-medium capitalize',
settings.theme_mode === mode
? 'text-win11-bg-accent'
: isDark
? 'text-gray-300'
: 'text-gray-700'
)}
>
{mode === 'system' ? 'System' : mode}
</span>
</div>
{/* Radio Circle Indicator */}
<div
className={clsx(
'absolute top-3 right-3 w-4 h-4 rounded-full border flex items-center justify-center transition-colors',
settings.theme_mode === mode
? 'border-win11-bg-accent bg-win11-bg-accent'
: isDark
? 'border-gray-600'
: 'border-gray-300'
)}
>
{settings.theme_mode === mode && (
<div className="w-1.5 h-1.5 rounded-full bg-white" />
)}
</div>
</button>
))}
</div>
<div
className={clsx('mt-6 pt-6 border-t', isDark ? 'border-white/5' : 'border-gray-100')}
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">Dynamic Tray Icon</div>
<div className={clsx('text-xs mt-0.5', isDark ? 'text-gray-400' : 'text-gray-500')}>
Adapt tray icon color to system theme.
</div>
</div>
<Switch
checked={settings.enable_dynamic_tray_icon}
onChange={() => handleToggle('enable_dynamic_tray_icon')}
isDark={isDark}
/>
</div>
</div>
</section>
{/* Auto Delete Section */}
<section
className={clsx(
'rounded-xl p-6 border shadow-sm transition-all',
isDark ? 'bg-win11-bg-secondary border-white/5' : 'bg-white border-gray-200/60'
)}
>
<div className="flex items-center gap-3 mb-5">
<div className={clsx('p-2 rounded-lg', isDark ? 'bg-white/5' : 'bg-gray-100')}>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</svg>
</div>
<div>
<h2 className="text-base font-semibold">Auto-delete History</h2>
<p className={clsx('text-xs mt-0.5', isDark ? 'text-gray-400' : 'text-gray-500')}>
Automatically clear old clipboard items (except pinned)
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 flex flex-col gap-2">
<label className="text-xs font-medium opacity-60 ml-1">Time value</label>
<div className="relative">
<input
type="number"
min="0"
value={settings.auto_delete_interval || ''}
placeholder="0 (Disabled)"
onChange={(e) => handleAutoDeleteValueChange(e.target.value)}
className={clsx(
'w-full px-4 py-2.5 rounded-lg border outline-none transition-all font-medium',
isDark
? 'bg-white/5 border-white/10 focus:border-win11-bg-accent text-white'
: 'bg-gray-50 border-gray-200 focus:border-win11-bg-accent text-gray-800'
)}
/>
</div>
</div>
<div className="flex-1 flex flex-col gap-2">
<label className="text-xs font-medium opacity-60 ml-1">Time unit</label>
<div className="flex gap-2">
{(['minutes', 'hours', 'days', 'weeks'] as const).map((unit) => (
<button
key={unit}
onClick={() => handleAutoDeleteUnitChange(unit)}
className={clsx(
'flex-1 py-2.5 rounded-lg border transition-all text-xs font-semibold capitalize',
settings.auto_delete_unit === unit
? 'bg-win11-bg-accent text-white border-win11-bg-accent'
: isDark
? 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10'
: 'bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100'
)}
>
{unit}
</button>
))}
</div>
</div>
</div>
<div className="mt-4 p-3 rounded-lg bg-win11-bg-accent/5 border border-win11-bg-accent/10">
<p className="text-[11px] leading-relaxed opacity-70">
{settings.auto_delete_interval === 0 ? (
<span className="font-medium">Auto-delete is currently disabled.</span>
) : (
<>
Clipboard history items will be deleted after{' '}
<span className="font-bold text-win11-bg-accent">
{settings.auto_delete_interval} {settings.auto_delete_unit}
</span>
. Pinned items are never deleted.
</>
)}
</p>
</div>
</section>
{/* Transparency Section */}
<section
className={clsx(
'rounded-xl border shadow-sm overflow-hidden',
isDark ? 'bg-win11-bg-secondary border-white/5' : 'bg-white border-gray-200/60',
renderingEnv.transparency_disabled && 'opacity-60'
)}
>
<div className="p-6 border-b border-inherit">
<h2 className="text-base font-semibold mb-1">Window Transparency</h2>
<p className={clsx('text-xs', isDark ? 'text-gray-400' : 'text-gray-500')}>
Control the backdrop opacity intensity
</p>
</div>
{renderingEnv.transparency_disabled && (
<div
className={clsx(
'mx-6 mt-6 p-3 rounded-lg flex items-start gap-3 text-sm',
isDark ? 'bg-yellow-500/10 text-yellow-300' : 'bg-yellow-50 text-yellow-800'
)}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 mt-0.5"
>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
<div>
<p className="font-medium text-xs">{renderingEnv.reason}</p>
<p
className={clsx(
'text-[11px] mt-1',
isDark ? 'text-yellow-400/70' : 'text-yellow-700'
)}
>
Transparency and rounded window corners have been automatically disabled to
prevent rendering artefacts.
</p>
</div>
</div>
)}
<div className="p-6 space-y-8">
{/* Dark Mode Slider */}
<div className="space-y-4">
<div className="flex justify-between items-center">
<label htmlFor="dark-opacity" className="text-sm font-medium">
Dark Mode Opacity
</label>
<div
className={clsx(
'px-2 py-1 rounded text-xs font-mono font-medium',
isDark ? 'bg-black/20' : 'bg-gray-100'
)}
>
{renderingEnv.transparency_disabled
? '100%'
: `${Math.round(settings.dark_background_opacity * 100)}%`}
</div>
</div>
<input
id="dark-opacity"
type="range"
min="0"
max="1"
step="0.01"
value={renderingEnv.transparency_disabled ? 1 : settings.dark_background_opacity}
onChange={(e) => handleDarkOpacityChange(Number.parseFloat(e.target.value))}
onMouseUp={commitOpacityChange}
onTouchEnd={commitOpacityChange}
disabled={renderingEnv.transparency_disabled}
className={clsx(
'w-full h-1.5 bg-gray-200 rounded-lg appearance-none dark:bg-gray-700 accent-win11-bg-accent',
renderingEnv.transparency_disabled ? 'cursor-not-allowed' : 'cursor-pointer'
)}
/>
</div>
{/* Light Mode Slider */}
<div className="space-y-4">
<div className="flex justify-between items-center">
<label htmlFor="light-opacity" className="text-sm font-medium">
Light Mode Opacity
</label>
<div
className={clsx(
'px-2 py-1 rounded text-xs font-mono font-medium',
isDark ? 'bg-black/20' : 'bg-gray-100'
)}
>
{renderingEnv.transparency_disabled
? '100%'
: `${Math.round(settings.light_background_opacity * 100)}%`}
</div>
</div>
<input
id="light-opacity"
type="range"
min="0"
max="1"
step="0.01"
value={renderingEnv.transparency_disabled ? 1 : settings.light_background_opacity}
onChange={(e) => handleLightOpacityChange(Number.parseFloat(e.target.value))}
onMouseUp={commitOpacityChange}
onTouchEnd={commitOpacityChange}
disabled={renderingEnv.transparency_disabled}
className={clsx(
'w-full h-1.5 bg-gray-200 rounded-lg appearance-none dark:bg-gray-700 accent-win11-bg-accent',
renderingEnv.transparency_disabled ? 'cursor-not-allowed' : 'cursor-pointer'
)}
/>
</div>
</div>
</section>
{/* UI Scale Section */}
<section
className={clsx(
'rounded-xl border shadow-sm overflow-hidden',
isDark ? 'bg-win11-bg-secondary border-white/5' : 'bg-white border-gray-200/60'
)}
>
<div className="p-6 border-b border-inherit">
<h2 className="text-base font-semibold mb-1">UI Scale</h2>
<p className={clsx('text-xs', isDark ? 'text-gray-400' : 'text-gray-500')}>
Adjust the clipboard window size for your display
</p>
</div>
<div className="p-6 space-y-4">
<div className="space-y-4">
<div className="flex justify-between items-center">
<label htmlFor="ui-scale" className="text-sm font-medium">
Clipboard Window Scale
</label>
<div
className={clsx(
'px-2 py-1 rounded text-xs font-mono font-medium',
isDark ? 'bg-black/20' : 'bg-gray-100'
)}
>
{Math.round(settings.ui_scale * 100)}%
</div>
</div>
<input
id="ui-scale"
type="range"
min="0.5"
max="2"
step="0.1"
value={settings.ui_scale}
onChange={(e) => {
const value = Number.parseFloat(e.target.value)
setSettings((prev) => ({ ...prev, ui_scale: 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"
/>
<p className={clsx('text-xs', isDark ? 'text-gray-500' : 'text-gray-400')}>
This setting only affects the clipboard popup, not this settings window
</p>
</div>
</div>
</section>
{/* History Settings Section */}
<section
className={clsx(
'rounded-xl border shadow-sm overflow-hidden',
isDark ? 'bg-win11-bg-secondary border-white/5' : 'bg-white border-gray-200/60'
)}
>
<div className="p-6 border-b border-inherit">
<h2 className="text-base font-semibold mb-1">History Settings</h2>
<p className={clsx('text-xs', isDark ? 'text-gray-400' : 'text-gray-500')}>
Configure clipboard history behavior
</p>
</div>
<div className="p-6 space-y-4">
<div className="flex justify-between items-center">
<div>
<label htmlFor="max-history" className="text-sm font-medium">
Maximum History Size
</label>
<p className={clsx('text-xs mt-0.5', isDark ? 'text-gray-400' : 'text-gray-500')}>
Number of clipboard items to keep ({MIN_HISTORY_SIZE} -{' '}
{MAX_HISTORY_SIZE.toLocaleString()})
</p>
</div>
<input
id="max-history"
type="number"
min={MIN_HISTORY_SIZE}
max={MAX_HISTORY_SIZE}
value={settings.max_history_size}
onChange={(e) => {
const raw = e.target.value
const parsed = Number.parseInt(raw, 10)
// If parsing fails (e.g. empty input), preserve the current setting
// instead of jumping to the maximum value.
const safe = Number.isNaN(parsed) ? settings.max_history_size : parsed
const value = Math.max(MIN_HISTORY_SIZE, Math.min(MAX_HISTORY_SIZE, safe))
updateSettings({ max_history_size: value })
}}
className={clsx(
'w-28 text-right font-mono border rounded-md transition-all focus:outline-none focus:ring-2 focus:ring-win11-bg-accent/50',
'input-number-compact no-number-spinner',
isDark
? 'bg-white/5 border-white/10 text-white'
: 'bg-gray-50 border-gray-200 text-gray-900'
)}
/>
</div>
</div>
</section>
{/* Custom Kaomoji Section */}
<section
className={clsx(
'rounded-xl border shadow-sm overflow-hidden',
isDark ? 'bg-win11-bg-secondary border-white/5' : 'bg-white border-gray-200/60'
)}
>
<div className="p-6 border-b border-inherit">
<h2 className="text-base font-semibold mb-1">Custom Kaomoji</h2>
<p className={clsx('text-xs', isDark ? 'text-gray-400' : 'text-gray-500')}>
Add your own personal kaomojis to the collection
</p>
</div>
<div className="p-6 space-y-6">
{/* Add New */}
<div className="flex gap-2">
<input
type="text"
value={newKaomoji}
onChange={(e) => setNewKaomoji(e.target.value)}
placeholder="( ˘ ³˘)♥"
className={clsx(
'flex-1 px-3 py-2 rounded-md border text-sm focus:outline-none focus:ring-2 focus:ring-win11-bg-accent/50 transition-all',
isDark
? 'bg-white/5 border-white/10 text-white placeholder-gray-500'
: 'bg-gray-50 border-gray-200 text-gray-900 placeholder-gray-400'
)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
addCustomKaomoji()
}
}}
/>
<button
onClick={addCustomKaomoji}
className="px-4 py-2 bg-win11-bg-accent text-white rounded-md text-sm font-medium hover:opacity-90 active:scale-95 transition-all"
>
Add
</button>
</div>
{/* List */}
{settings.custom_kaomojis.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-48 overflow-y-auto custom-scrollbar pr-1">
{settings.custom_kaomojis.map((item, idx) => (
<div
key={idx}
className={clsx(
'group flex items-center justify-between px-3 py-2 rounded-md border transition-colors',
isDark ? 'bg-white/5 border-white/10' : 'bg-gray-50 border-gray-200'
)}
>
<span className="font-mono text-sm truncate mr-2" title={item.text}>
{item.text}
</span>
<button
onClick={() => removeCustomKaomojiAt(idx)}
className="opacity-0 group-hover:opacity-100 p-1 text-red-500 hover:bg-red-500/10 rounded transition-all"
title="Delete"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
))}
</div>
) : (
<div
className={clsx(
'text-center py-4 text-sm italic opacity-60',
isDark ? 'text-gray-500' : 'text-gray-400'
)}
>
No custom kaomojis yet
</div>
)}
</div>
</section>
{/* Features Section */}
<FeaturesSection settings={settings} isDark={isDark} onToggle={handleToggle} />
{/* Keyboard Shortcuts Section */}
<KeyboardShortcutsSection isDark={isDark} />
{/* Reset Section */}
<div className="flex justify-end pt-2">
<button
onClick={async () => {
setSettings(DEFAULT_SETTINGS)
await saveSettings(DEFAULT_SETTINGS)
// Reset first run state to show setup wizard
await invoke('reset_first_run')
// Emit event to show wizard in main window
await emit('show-setup-wizard')
}}
className={clsx(
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all',
'hover:bg-red-50 hover:text-red-600',
isDark ? 'text-gray-400 hover:bg-red-500/10 hover:text-red-400' : 'text-gray-500'
)}
>
<ResetIcon />
Reset to defaults
</button>
</div>
</main>
{/* Footer */}
<footer
className={clsx(
'px-8 py-5 border-t flex justify-end',
isDark ? 'border-white/5 bg-win11-bg-secondary/50' : 'border-gray-200 bg-gray-50'
)}
>
<button
onClick={handleClose}
className="px-8 py-2.5 bg-win11-bg-accent hover:opacity-90 active:scale-95 text-white rounded-lg text-sm font-semibold shadow-sm transition-all"
>
Done
</button>
</footer>
</div>
)
}
export default SettingsApp