diff --git a/src-tauri/src/desktop/settings.rs b/src-tauri/src/desktop/settings.rs index 435a154b0e..21598694da 100644 --- a/src-tauri/src/desktop/settings.rs +++ b/src-tauri/src/desktop/settings.rs @@ -8,12 +8,14 @@ pub struct DesktopSettings { pub close_to_background_on_close: bool, pub show_system_tray_icon: bool, pub use_custom_title_bar: bool, + pub spellcheck: bool, } pub(crate) const DESKTOP_SETTINGS_PATH: &str = "desktop-preferences.json"; pub(crate) const CLOSE_TO_BACKGROUND_ON_CLOSE_KEY: &str = "closeToBackgroundOnClose"; pub(crate) const SHOW_SYSTEM_TRAY_ICON_KEY: &str = "showSystemTrayIcon"; pub(crate) const USE_CUSTOM_TITLE_BAR_KEY: &str = "useCustomTitleBar"; +pub(crate) const SPELLCHECK_KEY: &str = "spellcheck"; pub(crate) const LEGACY_KEEP_BACKGROUND_RUNNING_KEY: &str = "keepBackgroundRunning"; pub(crate) const fn use_custom_title_bar_default() -> bool { @@ -28,6 +30,7 @@ pub(crate) fn desktop_settings_from_values( close_to_background_on_close: Option, show_system_tray_icon: Option, use_custom_title_bar: Option, + spellcheck: Option, keep_background_running: Option, ) -> DesktopSettings { DesktopSettings { @@ -35,6 +38,7 @@ pub(crate) fn desktop_settings_from_values( || keep_background_running.unwrap_or(false), show_system_tray_icon: show_system_tray_icon.unwrap_or(true), use_custom_title_bar: use_custom_title_bar.unwrap_or(use_custom_title_bar_default()), + spellcheck: spellcheck.unwrap_or(true), } } @@ -50,6 +54,7 @@ mod tests { close_to_background_on_close: true, show_system_tray_icon: false, use_custom_title_bar: true, + spellcheck: false, }; assert_eq!( @@ -58,6 +63,7 @@ mod tests { "closeToBackgroundOnClose": true, "showSystemTrayIcon": false, "useCustomTitleBar": true, + "spellcheck": false, }) ); } @@ -69,12 +75,14 @@ mod tests { "closeToBackgroundOnClose": true, "showSystemTrayIcon": false, "useCustomTitleBar": true, + "spellcheck": false, })) .unwrap(), DesktopSettings { close_to_background_on_close: true, show_system_tray_icon: false, use_custom_title_bar: true, + spellcheck: false, } ); } @@ -87,17 +95,19 @@ mod tests { assert!(output.contains("closeToBackgroundOnClose: boolean")); assert!(output.contains("showSystemTrayIcon: boolean")); assert!(output.contains("useCustomTitleBar: boolean")); + assert!(output.contains("spellcheck: boolean")); assert!(!output.contains("keepBackgroundRunning: boolean")); } #[test] fn legacy_background_setting_keeps_close_behavior_enabled() { assert_eq!( - desktop_settings_from_values(Some(false), Some(false), Some(false), Some(true)), + desktop_settings_from_values(Some(false), Some(false), Some(false), None, Some(true)), DesktopSettings { close_to_background_on_close: true, show_system_tray_icon: false, use_custom_title_bar: false, + spellcheck: true, } ); } @@ -105,11 +115,12 @@ mod tests { #[test] fn explicit_close_setting_stays_disabled_when_legacy_background_is_off() { assert_eq!( - desktop_settings_from_values(Some(false), Some(true), Some(true), Some(false)), + desktop_settings_from_values(Some(false), Some(true), Some(true), None, Some(false)), DesktopSettings { close_to_background_on_close: false, show_system_tray_icon: true, use_custom_title_bar: true, + spellcheck: true, } ); } @@ -117,8 +128,15 @@ mod tests { #[test] fn missing_custom_title_bar_setting_uses_platform_default() { assert_eq!( - desktop_settings_from_values(Some(true), Some(true), None, None).use_custom_title_bar, + desktop_settings_from_values(Some(true), Some(true), None, None, None) + .use_custom_title_bar, cfg!(target_os = "windows") ); } + + #[test] + fn missing_spellcheck_setting_defaults_to_enabled() { + assert!(desktop_settings_from_values(None, None, None, None, None).spellcheck); + assert!(!desktop_settings_from_values(None, None, None, Some(false), None).spellcheck); + } } diff --git a/src-tauri/src/desktop/tray.rs b/src-tauri/src/desktop/tray.rs index e58e4f203e..0fbc4d245a 100644 --- a/src-tauri/src/desktop/tray.rs +++ b/src-tauri/src/desktop/tray.rs @@ -4,7 +4,8 @@ use crate::desktop::runtime_state::DesktopRuntimeState; use crate::desktop::settings::{ desktop_settings_from_values, tray_available_for_session, use_custom_title_bar_default, DesktopSettings, CLOSE_TO_BACKGROUND_ON_CLOSE_KEY, DESKTOP_SETTINGS_PATH, - LEGACY_KEEP_BACKGROUND_RUNNING_KEY, SHOW_SYSTEM_TRAY_ICON_KEY, USE_CUSTOM_TITLE_BAR_KEY, + LEGACY_KEEP_BACKGROUND_RUNNING_KEY, SHOW_SYSTEM_TRAY_ICON_KEY, SPELLCHECK_KEY, + USE_CUSTOM_TITLE_BAR_KEY, }; use serde_json::json; use tauri::{ @@ -26,6 +27,7 @@ pub struct DesktopSettingsState { close_to_background_on_close: AtomicBool, show_system_tray_icon: AtomicBool, use_custom_title_bar: AtomicBool, + spellcheck: AtomicBool, tray_available: AtomicBool, } @@ -35,6 +37,7 @@ impl Default for DesktopSettingsState { close_to_background_on_close: AtomicBool::new(true), show_system_tray_icon: AtomicBool::new(true), use_custom_title_bar: AtomicBool::new(use_custom_title_bar_default()), + spellcheck: AtomicBool::new(true), tray_available: AtomicBool::new(false), } } @@ -98,6 +101,7 @@ pub(crate) fn load_desktop_settings( USE_CUSTOM_TITLE_BAR_KEY.into(), json!(use_custom_title_bar_default()), ), + (SPELLCHECK_KEY.into(), json!(true)), ])) .build() .map_err(|error| tauri::Error::PluginInitialization("store".into(), error.to_string()))?; @@ -112,6 +116,7 @@ pub(crate) fn load_desktop_settings( store .get(USE_CUSTOM_TITLE_BAR_KEY) .and_then(|value| value.as_bool()), + store.get(SPELLCHECK_KEY).and_then(|value| value.as_bool()), store .get(LEGACY_KEEP_BACKGROUND_RUNNING_KEY) .and_then(|value| value.as_bool()), @@ -124,6 +129,7 @@ fn current_desktop_settings(app: &AppHandle) -> DesktopSet close_to_background_on_close: state.close_to_background_on_close.load(Ordering::Relaxed), show_system_tray_icon: state.show_system_tray_icon.load(Ordering::Relaxed), use_custom_title_bar: state.use_custom_title_bar.load(Ordering::Relaxed), + spellcheck: state.spellcheck.load(Ordering::Relaxed), } } @@ -171,6 +177,9 @@ fn apply_desktop_settings( state .use_custom_title_bar .store(settings.use_custom_title_bar, Ordering::Relaxed); + state + .spellcheck + .store(settings.spellcheck, Ordering::Relaxed); apply_main_window_title_bar_settings(app, settings)?; @@ -395,6 +404,7 @@ mod tests { close_to_background_on_close: true, show_system_tray_icon: true, use_custom_title_bar: false, + spellcheck: true, }; assert_eq!( @@ -409,6 +419,7 @@ mod tests { show_system_tray_icon: true, close_to_background_on_close: false, use_custom_title_bar: false, + spellcheck: true, }; assert_eq!( @@ -423,6 +434,7 @@ mod tests { close_to_background_on_close: true, show_system_tray_icon: true, use_custom_title_bar: false, + spellcheck: true, }; assert!(!tray_available_for_session(settings, false)); @@ -451,6 +463,7 @@ mod tests { close_to_background_on_close: true, show_system_tray_icon: true, use_custom_title_bar: false, + spellcheck: true, }; assert_eq!( @@ -462,11 +475,12 @@ mod tests { #[test] fn missing_store_values_default_to_enabled() { assert_eq!( - desktop_settings_from_values(None, None, None, None), + desktop_settings_from_values(None, None, None, None, None), DesktopSettings { close_to_background_on_close: true, show_system_tray_icon: true, use_custom_title_bar: use_custom_title_bar_default(), + spellcheck: true, } ); } @@ -474,11 +488,12 @@ mod tests { #[test] fn legacy_background_store_value_migrates_to_close_behavior() { assert_eq!( - desktop_settings_from_values(Some(false), Some(false), Some(false), Some(true)), + desktop_settings_from_values(Some(false), Some(false), Some(false), None, Some(true)), DesktopSettings { close_to_background_on_close: true, show_system_tray_icon: false, use_custom_title_bar: false, + spellcheck: true, } ); } @@ -486,11 +501,12 @@ mod tests { #[test] fn explicit_store_values_are_preserved_when_legacy_background_is_off() { assert_eq!( - desktop_settings_from_values(Some(false), Some(false), Some(true), Some(false)), + desktop_settings_from_values(Some(false), Some(false), Some(true), None, Some(false)), DesktopSettings { show_system_tray_icon: false, close_to_background_on_close: false, use_custom_title_bar: true, + spellcheck: true, } ); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 66dd1ed410..eed016b16f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -540,6 +540,7 @@ mod tests { close_to_background_on_close: true, show_system_tray_icon: true, use_custom_title_bar: false, + spellcheck: true, }; let _ = crate::desktop::runtime_state::DesktopRuntimeState { tray_available: true, diff --git a/src/app/components/app-shell/AppShell.tsx b/src/app/components/app-shell/AppShell.tsx index c69500ba4b..e19c02b185 100644 --- a/src/app/components/app-shell/AppShell.tsx +++ b/src/app/components/app-shell/AppShell.tsx @@ -6,6 +6,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { isTauri } from '@tauri-apps/api/core'; import { type as osType } from '@tauri-apps/plugin-os'; +import { DesktopSpellcheck } from '$components/tauri/DesktopSpellcheck'; import { TauriFrontendReady } from '$components/tauri/TauriFrontendReady'; import { TauriWindowFocus } from '$components/tauri/TauriWindowFocus'; import { DesktopTitleBar } from '$components/tauri/DesktopTitleBar'; @@ -89,6 +90,7 @@ function AppShellFrame({ children, portalContainer, onPortalContainerChange }: A <> +
({ + mockIsDesktopTauri: vi.fn<() => boolean>(), + mockUseDesktopSetting: vi.fn<() => readonly [boolean, (value: boolean) => void]>(), +})); + +vi.mock('$utils/platform', () => ({ + isDesktopTauri: mockIsDesktopTauri, +})); + +vi.mock('$state/hooks/desktopSettings', () => ({ + useDesktopSetting: mockUseDesktopSetting, +})); + +describe('DesktopSpellcheck', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseDesktopSetting.mockReturnValue([true, vi.fn<(value: boolean) => void>()]); + }); + + afterEach(() => { + document.body.removeAttribute('spellcheck'); + }); + + it('disables inherited spellcheck on the body when the setting is off', () => { + mockIsDesktopTauri.mockReturnValue(true); + mockUseDesktopSetting.mockReturnValue([false, vi.fn<(value: boolean) => void>()]); + + render(); + + expect(document.body.spellcheck).toBe(false); + }); + + it('keeps inherited spellcheck enabled on the body when the setting is on', () => { + mockIsDesktopTauri.mockReturnValue(true); + + render(); + + expect(document.body.spellcheck).toBe(true); + }); + + it('follows setting changes after mount', () => { + mockIsDesktopTauri.mockReturnValue(true); + mockUseDesktopSetting.mockReturnValue([true, vi.fn<(value: boolean) => void>()]); + + const { rerender } = render(); + expect(document.body.spellcheck).toBe(true); + + mockUseDesktopSetting.mockReturnValue([false, vi.fn<(value: boolean) => void>()]); + rerender(); + + expect(document.body.spellcheck).toBe(false); + }); + + it('does not touch the body outside desktop Tauri builds', () => { + mockIsDesktopTauri.mockReturnValue(false); + mockUseDesktopSetting.mockReturnValue([false, vi.fn<(value: boolean) => void>()]); + + render(); + + expect(document.body.hasAttribute('spellcheck')).toBe(false); + }); +}); diff --git a/src/app/components/tauri/DesktopSpellcheck.tsx b/src/app/components/tauri/DesktopSpellcheck.tsx new file mode 100644 index 0000000000..37aabec5f4 --- /dev/null +++ b/src/app/components/tauri/DesktopSpellcheck.tsx @@ -0,0 +1,16 @@ +import { useEffect } from 'react'; +import { isDesktopTauri } from '$utils/platform'; +import { useDesktopSetting } from '$state/hooks/desktopSettings'; + +// Editable fields inherit their spellcheck state from , so a single +// attribute governs the composer and every text input. +export function DesktopSpellcheck() { + const [spellcheck] = useDesktopSetting('spellcheck'); + + useEffect(() => { + if (!isDesktopTauri()) return; + document.body.spellcheck = spellcheck; + }, [spellcheck]); + + return null; +} diff --git a/src/app/features/settings/desktop/Desktop.test.tsx b/src/app/features/settings/desktop/Desktop.test.tsx index f1b1a036c1..4ea59286a7 100644 --- a/src/app/features/settings/desktop/Desktop.test.tsx +++ b/src/app/features/settings/desktop/Desktop.test.tsx @@ -13,20 +13,24 @@ const { mockUseDesktopRuntimeState, mockUseDesktopSettingsSyncing, mockSetUseCustomTitleBar, + mockSetSpellcheck, } = vi.hoisted(() => { const setUseCustomTitleBarMock = vi.fn<(value: boolean) => void>(); + const setSpellcheckMock = vi.fn<(value: boolean) => void>(); return { mockUseDesktopSetting: vi.fn< ( - key: 'closeToBackgroundOnClose' | 'showSystemTrayIcon' | 'useCustomTitleBar' + key: 'closeToBackgroundOnClose' | 'showSystemTrayIcon' | 'useCustomTitleBar' | 'spellcheck' ) => readonly [boolean, (value: boolean) => void] >((key) => { if (key === 'useCustomTitleBar') return [true, setUseCustomTitleBarMock] as const; + if (key === 'spellcheck') return [true, setSpellcheckMock] as const; if (key === 'closeToBackgroundOnClose') return [true, vi.fn<() => void>()] as const; return [true, vi.fn<() => void>()] as const; }), mockSetUseCustomTitleBar: setUseCustomTitleBarMock, + mockSetSpellcheck: setSpellcheckMock, mockUseDesktopSettingsReady: vi.fn<() => boolean>(() => true), mockUseDesktopSettingsSyncing: vi.fn<() => boolean>(() => false), mockUseDesktopRuntimeState: vi.fn<() => { trayAvailable: boolean }>(() => ({ @@ -115,7 +119,19 @@ describe('Desktop', () => { 'Show a system tray icon while Sable is running. Disable this if you want Sable to stay available without a tray icon.' ) ).toBeInTheDocument(); - expect(container.getElementsByClassName(SequenceCardStyle)).toHaveLength(4); + expect(screen.getByText('Spellcheck')).toBeInTheDocument(); + expect( + screen.getByText('Underline misspelled words and offer corrections in text fields.') + ).toBeInTheDocument(); + expect(container.getElementsByClassName(SequenceCardStyle)).toHaveLength(5); + }); + + it('updates the spellcheck setting from the Text input switch', () => { + renderDesktop(); + + fireEvent.click(screen.getByRole('switch', { name: 'spellcheck' })); + + expect(mockSetSpellcheck).toHaveBeenCalledWith(false); }); it('updates the custom title bar setting from the Window switch', () => { diff --git a/src/app/features/settings/desktop/Desktop.tsx b/src/app/features/settings/desktop/Desktop.tsx index 72ad97c2cf..8eeda1f12a 100644 --- a/src/app/features/settings/desktop/Desktop.tsx +++ b/src/app/features/settings/desktop/Desktop.tsx @@ -26,6 +26,7 @@ export function Desktop({ requestBack, requestClose }: DesktopProps) { ); const [showSystemTrayIcon, setShowSystemTrayIcon] = useDesktopSetting('showSystemTrayIcon'); const [useCustomTitleBar, setUseCustomTitleBar] = useDesktopSetting('useCustomTitleBar'); + const [spellcheck, setSpellcheck] = useDesktopSetting('spellcheck'); const [autoUpdateCheck, setAutoUpdateCheck] = useAtom(autoUpdateCheckAtom); if (!isTauri() || !ready) return null; @@ -84,6 +85,17 @@ export function Desktop({ requestBack, requestClose }: DesktopProps) { /> )} + + Text input + + Updates { }); it('migrates the legacy background-running flag into close behavior', () => { - expect(desktopSettingsFromStoreValues(false, false, true, undefined)).toEqual({ + expect(desktopSettingsFromStoreValues(false, false, true, undefined, undefined)).toEqual({ closeToBackgroundOnClose: true, showSystemTrayIcon: false, useCustomTitleBar: true, + spellcheck: true, }); }); it('preserves an explicit close-off setting when the legacy flag is off', () => { - expect(desktopSettingsFromStoreValues(false, true, false, undefined)).toEqual({ + expect(desktopSettingsFromStoreValues(false, true, false, undefined, undefined)).toEqual({ closeToBackgroundOnClose: false, showSystemTrayIcon: true, useCustomTitleBar: true, + spellcheck: true, }); }); it('preserves an explicit custom title bar value over platform defaults', () => { expect( - desktopSettingsFromStoreValues(undefined, undefined, undefined, false, 'windows') + desktopSettingsFromStoreValues(undefined, undefined, undefined, false, undefined, 'windows') ).toMatchObject({ useCustomTitleBar: false }); expect( - desktopSettingsFromStoreValues(undefined, undefined, undefined, true, 'macos') + desktopSettingsFromStoreValues(undefined, undefined, undefined, true, undefined, 'macos') ).toMatchObject({ useCustomTitleBar: true, }); }); + it('loads an explicit persisted spellcheck value', async () => { + mockEntries.mockResolvedValue([['spellcheck', false]]); + + await expect(getDesktopSetting('spellcheck')).resolves.toBe(false); + }); + it('writes through the desktop settings atom and syncs runtime state', async () => { const store = createStore(); const unsubscribe = store.sub(desktopSettingsReadyAtom, () => {}); @@ -142,6 +150,7 @@ describe('desktop settings state', () => { closeToBackgroundOnClose: true, showSystemTrayIcon: true, useCustomTitleBar: true, + spellcheck: true, }); expect(mockSet).not.toHaveBeenCalled(); @@ -150,6 +159,7 @@ describe('desktop settings state', () => { closeToBackgroundOnClose: true, showSystemTrayIcon: true, useCustomTitleBar: true, + spellcheck: true, }, }); expect(store.get(desktopRuntimeStateAtom)).toEqual({ trayAvailable: false }); @@ -176,6 +186,7 @@ describe('desktop settings state', () => { closeToBackgroundOnClose: true, showSystemTrayIcon: true, useCustomTitleBar: true, + spellcheck: true, }); await vi.waitFor(() => { @@ -196,19 +207,22 @@ describe('desktop settings state', () => { closeToBackgroundOnClose: false, showSystemTrayIcon: false, useCustomTitleBar: false, + spellcheck: false, }) ).resolves.toEqual({ trayAvailable: false }); - expect(mockSet).toHaveBeenCalledTimes(4); + expect(mockSet).toHaveBeenCalledTimes(5); expect(mockSet).toHaveBeenCalledWith('closeToBackgroundOnClose', false); expect(mockSet).toHaveBeenCalledWith('showSystemTrayIcon', false); expect(mockSet).toHaveBeenCalledWith('keepBackgroundRunning', false); expect(mockSet).toHaveBeenCalledWith('useCustomTitleBar', false); + expect(mockSet).toHaveBeenCalledWith('spellcheck', false); expect(mockSyncDesktopSettings).toHaveBeenCalledWith({ settings: { closeToBackgroundOnClose: false, showSystemTrayIcon: false, useCustomTitleBar: false, + spellcheck: false, }, }); }); @@ -231,6 +245,7 @@ describe('desktop settings state', () => { closeToBackgroundOnClose: true, showSystemTrayIcon: false, useCustomTitleBar: true, + spellcheck: true, }, }); }); @@ -253,6 +268,7 @@ describe('desktop settings state', () => { closeToBackgroundOnClose: false, showSystemTrayIcon: true, useCustomTitleBar: true, + spellcheck: true, }, }); }); @@ -275,6 +291,7 @@ describe('desktop settings state', () => { closeToBackgroundOnClose: true, showSystemTrayIcon: false, useCustomTitleBar: true, + spellcheck: true, }, }); }); diff --git a/src/app/state/desktopSettings.ts b/src/app/state/desktopSettings.ts index 39f6bed4c8..93db070b2f 100644 --- a/src/app/state/desktopSettings.ts +++ b/src/app/state/desktopSettings.ts @@ -29,6 +29,7 @@ export function desktopSettingsDefaultsForPlatform(platform: DesktopPlatform): D closeToBackgroundOnClose: true, showSystemTrayIcon: true, useCustomTitleBar: platform === 'windows', + spellcheck: true, }; } export type DesktopSettingKey = keyof DesktopSettings; @@ -75,6 +76,7 @@ export function desktopSettingsFromStoreValues( showSystemTrayIcon: boolean | undefined, legacyKeepBackgroundRunning: boolean | undefined, useCustomTitleBar: boolean | undefined, + spellcheck: boolean | undefined, platform = getDesktopTauriPlatform() ): DesktopSettings { const defaults = desktopSettingsDefaultsForPlatform(platform); @@ -85,6 +87,7 @@ export function desktopSettingsFromStoreValues( readBoolean(legacyKeepBackgroundRunning, false), showSystemTrayIcon: readBoolean(showSystemTrayIcon, defaults.showSystemTrayIcon), useCustomTitleBar: readBoolean(useCustomTitleBar, defaults.useCustomTitleBar), + spellcheck: readBoolean(spellcheck, defaults.spellcheck), }; } @@ -113,18 +116,21 @@ export async function getDesktopSettings(): Promise { showSystemTrayIcon, legacyKeepBackgroundRunning, useCustomTitleBar, + spellcheck, ] = await Promise.all([ desktopSettingsStore.get('closeToBackgroundOnClose'), desktopSettingsStore.get('showSystemTrayIcon'), desktopSettingsStore.get(LEGACY_KEEP_BACKGROUND_RUNNING_KEY), desktopSettingsStore.get('useCustomTitleBar'), + desktopSettingsStore.get('spellcheck'), ]); currentDesktopSettings = desktopSettingsFromStoreValues( closeToBackgroundOnClose, showSystemTrayIcon, legacyKeepBackgroundRunning, - useCustomTitleBar + useCustomTitleBar, + spellcheck ); return currentDesktopSettings; diff --git a/src/app/state/hooks/desktopSettings.test.tsx b/src/app/state/hooks/desktopSettings.test.tsx index 4517538400..2c8f26302a 100644 --- a/src/app/state/hooks/desktopSettings.test.tsx +++ b/src/app/state/hooks/desktopSettings.test.tsx @@ -94,6 +94,7 @@ describe('useDesktopSetting', () => { closeToBackgroundOnClose: true, showSystemTrayIcon: false, useCustomTitleBar: true, + spellcheck: true, }, }); @@ -128,6 +129,7 @@ describe('useDesktopSetting', () => { closeToBackgroundOnClose: false, showSystemTrayIcon: true, useCustomTitleBar: true, + spellcheck: true, }, });