From 96ec5caa5fe5e0ea36236ccc672a3f9547e8feab Mon Sep 17 00:00:00 2001 From: Hanashi Date: Sat, 29 Aug 2026 23:28:15 -0400 Subject: [PATCH] app settings --- .../src/enums/tauri/ux/tauri_event_name.rs | 14 ++++- .../commands/service/app_preferences/mod.rs | 4 ++ .../notify_app_preferences_changed.rs | 9 +++ .../update_app_preference_command.rs | 6 +- .../update_prompt_preference_command.rs | 58 +++++++++++++++++ .../update_sound_preference_command.rs | 6 +- .../app_preferences_changed_event.rs | 14 +++++ .../src/events/functional_events/mod.rs | 1 + crates/artcraftx/src/lib.rs | 2 + .../state/app_preferences/app_preferences.rs | 14 ++++- .../settings/app_prompt_preferences.rs | 20 ++++++ .../src/state/app_preferences/settings/mod.rs | 1 + .../apps/artcraft/app/src/pages/MainApp.tsx | 4 ++ .../promptbox/src/lib/MentionTextarea.tsx | 6 +- .../promptbox/src/lib/PromptBoxAudio.tsx | 4 +- .../promptbox/src/lib/PromptBoxImage.tsx | 4 +- .../promptbox/src/lib/PromptBoxVideo.tsx | 4 +- .../promptbox/src/lib/promptStore.ts | 43 +------------ .../src/lib/panes/MiscSettingsPane.tsx | 25 +++++++- .../libs/soundboard/src/lib/SoundManager.ts | 13 ++-- frontend/libs/tauri-api/package.json | 3 +- frontend/libs/tauri-api/src/index.ts | 2 + .../src/lib/settings/GetAppPreferences.ts | 7 +++ .../lib/settings/UpdatePromptPreference.ts | 27 ++++++++ .../src/lib/settings/appPreferencesStore.ts | 63 +++++++++++++++++++ frontend/libs/tauri-events/src/index.ts | 2 + .../functional/AppPreferencesChangedEvent.ts | 38 +++++++++++ .../src/lib/sync/useAppPreferencesSync.ts | 33 ++++++++++ frontend/package-lock.json | 3 +- 29 files changed, 363 insertions(+), 67 deletions(-) create mode 100644 crates/artcraftx/src/commands/service/app_preferences/notify_app_preferences_changed.rs create mode 100644 crates/artcraftx/src/commands/service/app_preferences/update_prompt_preference_command.rs create mode 100644 crates/artcraftx/src/events/functional_events/app_preferences_changed_event.rs create mode 100644 crates/artcraftx/src/state/app_preferences/settings/app_prompt_preferences.rs create mode 100644 frontend/libs/tauri-api/src/lib/settings/UpdatePromptPreference.ts create mode 100644 frontend/libs/tauri-api/src/lib/settings/appPreferencesStore.ts create mode 100644 frontend/libs/tauri-events/src/lib/events/functional/AppPreferencesChangedEvent.ts create mode 100644 frontend/libs/tauri-events/src/lib/sync/useAppPreferencesSync.ts diff --git a/crates/api_clients/artcraft_client/src/enums/tauri/ux/tauri_event_name.rs b/crates/api_clients/artcraft_client/src/enums/tauri/ux/tauri_event_name.rs index 03959b00..201dccb1 100644 --- a/crates/api_clients/artcraft_client/src/enums/tauri/ux/tauri_event_name.rs +++ b/crates/api_clients/artcraft_client/src/enums/tauri/ux/tauri_event_name.rs @@ -42,6 +42,11 @@ pub enum TauriEventName { #[serde(rename = "credits_balance_changed_event")] CreditsBalanceChangedEvent, + /// Informational event: + /// The app preferences were saved; re-read them. + #[serde(rename = "app_preferences_changed_event")] + AppPreferencesChangedEvent, + /// Informational event: /// Subscription was changed (but we don't know the details - upgrade, cancel, etc.) #[serde(rename = "subscription_plan_changed_event")] @@ -106,6 +111,7 @@ impl TauriEventName { Self::GenerationCompleteEvent => "generation-complete-event", Self::GenerationFailedEvent => "generation-failed-event", Self::CreditsBalanceChangedEvent => "credits_balance_changed_event", + Self::AppPreferencesChangedEvent => "app_preferences_changed_event", Self::SubscriptionPlanChangedEvent => "subscription_plan_changed_event", Self::TextToImageGenerationCompleteEvent => "text_to_image_generation_complete_event", Self::ObjectGenerationCompleteEvent => "object_generation_complete_event", @@ -126,6 +132,7 @@ impl TauriEventName { "generation-complete-event" => Ok(Self::GenerationCompleteEvent), "generation-failed-event" => Ok(Self::GenerationFailedEvent), "credits_balance_changed_event" => Ok(Self::CreditsBalanceChangedEvent), + "app_preferences_changed_event" => Ok(Self::AppPreferencesChangedEvent), "subscription_plan_changed_event" => Ok(Self::SubscriptionPlanChangedEvent), "text_to_image_generation_complete_event" => Ok(Self::TextToImageGenerationCompleteEvent), "object_generation_complete_event" => Ok(Self::ObjectGenerationCompleteEvent), @@ -149,6 +156,7 @@ impl TauriEventName { Self::GenerationCompleteEvent, Self::GenerationFailedEvent, Self::CreditsBalanceChangedEvent, + Self::AppPreferencesChangedEvent, Self::SubscriptionPlanChangedEvent, Self::TextToImageGenerationCompleteEvent, Self::ObjectGenerationCompleteEvent, @@ -178,6 +186,7 @@ mod tests { assert_serialization(TauriEventName::GenerationCompleteEvent, "generation-complete-event"); assert_serialization(TauriEventName::GenerationFailedEvent, "generation-failed-event"); assert_serialization(TauriEventName::CreditsBalanceChangedEvent, "credits_balance_changed_event"); + assert_serialization(TauriEventName::AppPreferencesChangedEvent, "app_preferences_changed_event"); assert_serialization(TauriEventName::SubscriptionPlanChangedEvent, "subscription_plan_changed_event"); assert_serialization(TauriEventName::TextToImageGenerationCompleteEvent, "text_to_image_generation_complete_event"); assert_serialization(TauriEventName::ObjectGenerationCompleteEvent, "object_generation_complete_event"); @@ -197,6 +206,7 @@ mod tests { assert_eq!(TauriEventName::GenerationCompleteEvent.to_str(), "generation-complete-event"); assert_eq!(TauriEventName::GenerationFailedEvent.to_str(), "generation-failed-event"); assert_eq!(TauriEventName::CreditsBalanceChangedEvent.to_str(), "credits_balance_changed_event"); + assert_eq!(TauriEventName::AppPreferencesChangedEvent.to_str(), "app_preferences_changed_event"); assert_eq!(TauriEventName::SubscriptionPlanChangedEvent.to_str(), "subscription_plan_changed_event"); assert_eq!(TauriEventName::TextToImageGenerationCompleteEvent.to_str(), "text_to_image_generation_complete_event"); assert_eq!(TauriEventName::ObjectGenerationCompleteEvent.to_str(), "object_generation_complete_event"); @@ -215,6 +225,7 @@ mod tests { assert_eq!(TauriEventName::from_str("generation-complete-event").unwrap(), TauriEventName::GenerationCompleteEvent); assert_eq!(TauriEventName::from_str("generation-failed-event").unwrap(), TauriEventName::GenerationFailedEvent); assert_eq!(TauriEventName::from_str("credits_balance_changed_event").unwrap(), TauriEventName::CreditsBalanceChangedEvent); + assert_eq!(TauriEventName::from_str("app_preferences_changed_event").unwrap(), TauriEventName::AppPreferencesChangedEvent); assert_eq!(TauriEventName::from_str("subscription_plan_changed_event").unwrap(), TauriEventName::SubscriptionPlanChangedEvent); assert_eq!(TauriEventName::from_str("text_to_image_generation_complete_event").unwrap(), TauriEventName::TextToImageGenerationCompleteEvent); assert_eq!(TauriEventName::from_str("object_generation_complete_event").unwrap(), TauriEventName::ObjectGenerationCompleteEvent); @@ -230,12 +241,13 @@ mod tests { #[test] fn all_variants() { let mut variants = TauriEventName::all_variants(); - assert_eq!(variants.len(), 15); + assert_eq!(variants.len(), 16); assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationEnqueueSuccessEvent)); assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationEnqueueFailureEvent)); assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationCompleteEvent)); assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationFailedEvent)); assert_eq!(variants.pop_first(), Some(TauriEventName::CreditsBalanceChangedEvent)); + assert_eq!(variants.pop_first(), Some(TauriEventName::AppPreferencesChangedEvent)); assert_eq!(variants.pop_first(), Some(TauriEventName::SubscriptionPlanChangedEvent)); assert_eq!(variants.pop_first(), Some(TauriEventName::TextToImageGenerationCompleteEvent)); assert_eq!(variants.pop_first(), Some(TauriEventName::ObjectGenerationCompleteEvent)); diff --git a/crates/artcraftx/src/commands/service/app_preferences/mod.rs b/crates/artcraftx/src/commands/service/app_preferences/mod.rs index 4c65fd34..c436ae83 100644 --- a/crates/artcraftx/src/commands/service/app_preferences/mod.rs +++ b/crates/artcraftx/src/commands/service/app_preferences/mod.rs @@ -1,4 +1,8 @@ pub mod get_app_preferences_command; pub mod load_custom_sound_command; pub mod update_app_preference_command; +pub mod update_prompt_preference_command; pub mod update_sound_preference_command; + +// Private +mod notify_app_preferences_changed; diff --git a/crates/artcraftx/src/commands/service/app_preferences/notify_app_preferences_changed.rs b/crates/artcraftx/src/commands/service/app_preferences/notify_app_preferences_changed.rs new file mode 100644 index 00000000..4bf5da21 --- /dev/null +++ b/crates/artcraftx/src/commands/service/app_preferences/notify_app_preferences_changed.rs @@ -0,0 +1,9 @@ +use crate::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::events::functional_events::app_preferences_changed_event::AppPreferencesChangedEvent; +use tauri::AppHandle; + +/// Tell the frontend the preferences changed so it drops its cached copy. +/// Every command that writes preferences calls this after a successful save. +pub fn notify_app_preferences_changed(app: &AppHandle) { + AppPreferencesChangedEvent {}.send_infallible(app); +} diff --git a/crates/artcraftx/src/commands/service/app_preferences/update_app_preference_command.rs b/crates/artcraftx/src/commands/service/app_preferences/update_app_preference_command.rs index 3b02d47a..333b002e 100644 --- a/crates/artcraftx/src/commands/service/app_preferences/update_app_preference_command.rs +++ b/crates/artcraftx/src/commands/service/app_preferences/update_app_preference_command.rs @@ -1,3 +1,4 @@ +use crate::commands::service::app_preferences::notify_app_preferences_changed::notify_app_preferences_changed; use crate::state::app_preferences::app_preferences::AppPreferences; use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager; use crate::state::app_preferences::settings::preferred_download_directory::PreferredDownloadDirectory; @@ -6,7 +7,7 @@ use anyhow::anyhow; use errors::AnyhowResult; use log::{error, info}; use serde_derive::{Deserialize, Serialize}; -use tauri::State; +use tauri::{AppHandle, State}; /// One preference change, typed per preference. The frontend sends /// `{ "preference": "", "value": }`. @@ -29,6 +30,7 @@ pub struct UpdateAppPreferencesResponse { #[tauri::command] pub async fn update_app_preferences_command( request: UpdateAppPreferencesRequest, + app: AppHandle, app_prefs: State<'_, AppPreferencesManager>, ) -> Result { info!("update_app_preferences_command called: {:?}", request); @@ -38,6 +40,8 @@ pub async fn update_app_preferences_command( format!("Error updating app preferences: {:?}", err) })?; + notify_app_preferences_changed(&app); + Ok(UpdateAppPreferencesResponse { success: true }) } diff --git a/crates/artcraftx/src/commands/service/app_preferences/update_prompt_preference_command.rs b/crates/artcraftx/src/commands/service/app_preferences/update_prompt_preference_command.rs new file mode 100644 index 00000000..a91bacf1 --- /dev/null +++ b/crates/artcraftx/src/commands/service/app_preferences/update_prompt_preference_command.rs @@ -0,0 +1,58 @@ +use crate::commands::service::app_preferences::notify_app_preferences_changed::notify_app_preferences_changed; +use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager; +use crate::state::app_preferences::settings::app_prompt_preferences::AppPromptPreferences; +use log::{error, info}; +use serde_derive::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; + +/// One prompt-box preference change. The frontend sends +/// `{ "preference": "", "value": }`. +#[derive(Deserialize, Debug)] +#[serde(tag = "preference", content = "value", rename_all = "snake_case")] +pub enum UpdatePromptPreferenceRequest { + EnterToGenerate(bool), +} + +/// The full prompt preferences after the change. +#[derive(Serialize)] +pub struct UpdatePromptPreferenceResponse { + pub prompt: AppPromptPreferences, +} + +#[tauri::command] +pub async fn update_prompt_preference_command( + request: UpdatePromptPreferenceRequest, + app: AppHandle, + app_prefs: State<'_, AppPreferencesManager>, +) -> Result { + info!("update_prompt_preference_command called: {:?}", request); + + let prefs = app_prefs + .update(|prefs| match request { + UpdatePromptPreferenceRequest::EnterToGenerate(enabled) => prefs.prompt.enter_to_generate = enabled, + }) + .map_err(|err| { + error!("Error updating prompt preference: {:?}", err); + format!("Error updating prompt preference: {:?}", err) + })?; + + notify_app_preferences_changed(&app); + + Ok(UpdatePromptPreferenceResponse { prompt: prefs.prompt }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Pin the wire format the frontend sends (`UpdatePromptPreference.ts`). + #[test] + fn decodes_enter_to_generate() { + assert!(matches!( + serde_json::from_str::(r#"{"preference":"enter_to_generate","value":false}"#).unwrap(), + UpdatePromptPreferenceRequest::EnterToGenerate(false), + )); + assert!(serde_json::from_str::(r#"{"preference":"enter_to_generate","value":"yes"}"#).is_err()); + assert!(serde_json::from_str::(r#"{"preference":"tab_to_generate","value":true}"#).is_err()); + } +} diff --git a/crates/artcraftx/src/commands/service/app_preferences/update_sound_preference_command.rs b/crates/artcraftx/src/commands/service/app_preferences/update_sound_preference_command.rs index b87899ec..799816ea 100644 --- a/crates/artcraftx/src/commands/service/app_preferences/update_sound_preference_command.rs +++ b/crates/artcraftx/src/commands/service/app_preferences/update_sound_preference_command.rs @@ -1,9 +1,10 @@ +use crate::commands::service::app_preferences::notify_app_preferences_changed::notify_app_preferences_changed; use crate::state::app_preferences::app_preferences_manager::AppPreferencesManager; use crate::state::app_preferences::settings::app_sound_file::{optional_sound, AppSoundFile}; use crate::state::app_preferences::settings::app_sound_preferences::{AppSoundEvent, AppSoundPreferences}; use log::{error, info, warn}; use serde_derive::{Deserialize, Serialize}; -use tauri::State; +use tauri::{AppHandle, State}; /// Change the sound for one event. /// @@ -35,6 +36,7 @@ pub struct UpdateSoundPreferenceResponse { #[tauri::command] pub async fn update_sound_preference_command( request: UpdateSoundPreferenceRequest, + app: AppHandle, app_prefs: State<'_, AppPreferencesManager>, ) -> Result { info!("update_sound_preference_command called: {:?}", request); @@ -56,6 +58,8 @@ pub async fn update_sound_preference_command( format!("Error updating sound preference: {:?}", err) })?; + notify_app_preferences_changed(&app); + Ok(UpdateSoundPreferenceResponse { sounds: prefs.sounds }) } diff --git a/crates/artcraftx/src/events/functional_events/app_preferences_changed_event.rs b/crates/artcraftx/src/events/functional_events/app_preferences_changed_event.rs new file mode 100644 index 00000000..026796c3 --- /dev/null +++ b/crates/artcraftx/src/events/functional_events/app_preferences_changed_event.rs @@ -0,0 +1,14 @@ +use crate::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent}; +use artcraft_client::enums::tauri::ux::tauri_event_name::TauriEventName; +use serde_derive::Serialize; + +/// The app preferences were saved (by any command). Carries no payload; the +/// frontend should re-read them via `get_app_preferences_command`. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct AppPreferencesChangedEvent {} + +impl BasicSendableEvent for AppPreferencesChangedEvent { + const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::AppPreferencesChangedEvent; + const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Success; +} diff --git a/crates/artcraftx/src/events/functional_events/mod.rs b/crates/artcraftx/src/events/functional_events/mod.rs index 01404e3a..4382c5cb 100644 --- a/crates/artcraftx/src/events/functional_events/mod.rs +++ b/crates/artcraftx/src/events/functional_events/mod.rs @@ -1,3 +1,4 @@ +pub mod app_preferences_changed_event; pub mod credits_balance_changed_event; pub mod gaussian_generation_complete_event; pub mod object_generation_complete_event; diff --git a/crates/artcraftx/src/lib.rs b/crates/artcraftx/src/lib.rs index ebc4280d..14cc123a 100644 --- a/crates/artcraftx/src/lib.rs +++ b/crates/artcraftx/src/lib.rs @@ -25,6 +25,7 @@ use crate::commands::credentials::refresh_grok_statsig_command::refresh_grok_sta use crate::commands::service::app_preferences::get_app_preferences_command::get_app_preferences_command; use crate::commands::service::app_preferences::load_custom_sound_command::load_custom_sound_command; use crate::commands::service::app_preferences::update_app_preference_command::update_app_preferences_command; +use crate::commands::service::app_preferences::update_prompt_preference_command::update_prompt_preference_command; use crate::commands::service::app_preferences::update_sound_preference_command::update_sound_preference_command; use crate::commands::cost_estimate::estimate_audio_cost_command::estimate_audio_cost_command; use crate::commands::cost_estimate::estimate_image_cost_command::estimate_image_cost_command; @@ -228,6 +229,7 @@ pub fn run() { platform_info_command, tasks_nuke_all_command, update_app_preferences_command, + update_prompt_preference_command, update_sound_preference_command, ]); diff --git a/crates/artcraftx/src/state/app_preferences/app_preferences.rs b/crates/artcraftx/src/state/app_preferences/app_preferences.rs index a5fbfb88..84c5df47 100644 --- a/crates/artcraftx/src/state/app_preferences/app_preferences.rs +++ b/crates/artcraftx/src/state/app_preferences/app_preferences.rs @@ -1,5 +1,6 @@ use crate::state::app_preferences::legacy_json_preferences::LegacyJsonPreferences; use crate::state::app_preferences::settings::app_download_preferences::AppDownloadPreferences; +use crate::state::app_preferences::settings::app_prompt_preferences::AppPromptPreferences; use crate::state::app_preferences::settings::app_sound_preferences::AppSoundPreferences; use crate::state::data_dir::app_data_root::AppDataRoot; use errors::AnyhowResult; @@ -10,7 +11,8 @@ use std::path::Path; /// File format version (not semver). /// - 1..=3: the legacy JSON layout (see `legacy_json_preferences`). /// - 4: TOML, grouped into `[sounds]` / `[downloads]` tables. -const CURRENT_VERSION: u32 = 4; +/// - 5: added the `[prompt]` table. +const CURRENT_VERSION: u32 = 5; /// User-adjustable app preferences, persisted as `settings/app_preferences.toml`. /// @@ -26,6 +28,9 @@ const CURRENT_VERSION: u32 = 4; /// [downloads] /// preferred_download_filename = "artcraft_convention" /// +/// [prompt] +/// enter_to_generate = true +/// /// [downloads.preferred_download_directory] /// system = "downloads" /// ``` @@ -38,6 +43,7 @@ pub struct AppPreferences { pub version: u32, pub sounds: AppSoundPreferences, pub downloads: AppDownloadPreferences, + pub prompt: AppPromptPreferences, } impl Default for AppPreferences { @@ -46,6 +52,7 @@ impl Default for AppPreferences { version: CURRENT_VERSION, sounds: AppSoundPreferences::default(), downloads: AppDownloadPreferences::default(), + prompt: AppPromptPreferences::default(), } } } @@ -123,11 +130,12 @@ mod tests { #[test] fn defaults_serialize_to_nested_toml() { let toml = toml::to_string_pretty(&AppPreferences::default()).unwrap(); - assert!(toml.starts_with("version = 4\n"), "{toml}"); + assert!(toml.starts_with("version = 5\n"), "{toml}"); assert!(toml.contains("[sounds]\nplay_sounds = true\n"), "{toml}"); assert!(toml.contains("enqueue_success = \"done\""), "{toml}"); assert!(toml.contains("[downloads]\n"), "{toml}"); assert!(toml.contains("[downloads.preferred_download_directory]\nsystem = \"downloads\""), "{toml}"); + assert!(toml.contains("[prompt]\nenter_to_generate = true"), "{toml}"); } #[test] @@ -141,6 +149,7 @@ mod tests { prefs.sounds.generation_success = Some(AppSoundFile::CustomWav("/tmp/ding.wav".into())); prefs.downloads.preferred_download_directory = PreferredDownloadDirectory::Custom("/tmp/out".into()); prefs.downloads.preferred_download_filename = PreferredDownloadFilename::Custom("{model}_{date}".into()); + prefs.prompt.enter_to_generate = false; prefs.save_to_file(&path).unwrap(); let loaded = AppPreferences::load_from_file(&path).unwrap(); @@ -153,6 +162,7 @@ mod tests { assert!(!prefs.sounds.play_sounds); assert_eq!(prefs.sounds.enqueue_success, Some(AppSoundFile::Done)); assert_eq!(prefs.downloads, AppPreferences::default().downloads); + assert!(prefs.prompt.enter_to_generate, "prompt table absent -> default"); } #[test] diff --git a/crates/artcraftx/src/state/app_preferences/settings/app_prompt_preferences.rs b/crates/artcraftx/src/state/app_preferences/settings/app_prompt_preferences.rs new file mode 100644 index 00000000..aeb1050e --- /dev/null +++ b/crates/artcraftx/src/state/app_preferences/settings/app_prompt_preferences.rs @@ -0,0 +1,20 @@ +use serde_derive::{Deserialize, Serialize}; + +/// How the prompt boxes behave (image, video, mesh, splat, ...). +/// +/// Missing fields in an older preferences file fall back to the defaults. +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AppPromptPreferences { + /// When on, Enter submits the prompt and Shift+Enter inserts a newline. + /// When off, both insert a newline and only the button submits. + pub enter_to_generate: bool, +} + +impl Default for AppPromptPreferences { + fn default() -> Self { + Self { + enter_to_generate: true, + } + } +} diff --git a/crates/artcraftx/src/state/app_preferences/settings/mod.rs b/crates/artcraftx/src/state/app_preferences/settings/mod.rs index aba1fbe4..db195c98 100644 --- a/crates/artcraftx/src/state/app_preferences/settings/mod.rs +++ b/crates/artcraftx/src/state/app_preferences/settings/mod.rs @@ -1,4 +1,5 @@ pub mod app_download_preferences; +pub mod app_prompt_preferences; pub mod app_sound_file; pub mod app_sound_preferences; pub mod preferred_download_directory; diff --git a/frontend/apps/artcraft/app/src/pages/MainApp.tsx b/frontend/apps/artcraft/app/src/pages/MainApp.tsx index a735042e..76d7f7a3 100644 --- a/frontend/apps/artcraft/app/src/pages/MainApp.tsx +++ b/frontend/apps/artcraft/app/src/pages/MainApp.tsx @@ -20,6 +20,7 @@ import { ActionReminderModal, } from "@storyteller/ui-action-reminder-modal"; import { + useAppPreferencesSync, useFlashFileDownloadErrorEvent, useFlashUserInputErrorEvent, useGenerationCompleteEvent, @@ -54,6 +55,9 @@ export const MainApp = () => { // Tauri event listeners. Must always be mounted so generation/upload // completions are surfaced no matter which tab the user is on. + // Keep the app-preferences cache (Enter-to-generate, sounds, ...) in sync. + useAppPreferencesSync(); + useGenerationEnqueueSuccessEvent(); useGenerationEnqueueFailureEvent(); useGenerationCompleteEvent(); diff --git a/frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx b/frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx index f2dbc092..02c4fce3 100644 --- a/frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx +++ b/frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx @@ -11,7 +11,7 @@ import { import { createPortal } from "react-dom"; import { twMerge } from "tailwind-merge"; import { Music, User, Video } from "lucide-react"; -import { useEnterToGenerateStore } from "./promptStore"; +import { useEnterToGenerate } from "@storyteller/tauri-api"; import { MentionChipMenu } from "./MentionChipMenu"; import { DeckPreviewModal } from "./deck/DeckCard"; @@ -537,8 +537,8 @@ export const MentionTextarea = forwardRef( // right after a mention label, which detectMention would read as a fresh // "@..." trigger and reopen the dropdown over the already-confirmed chip. const suppressMentionDetect = useRef(false); - const enterToGenerateStore = useEnterToGenerateStore((s) => s.enabled); - const enterToGenerate = enterToGenerateProp ?? enterToGenerateStore; + const enterToGeneratePreference = useEnterToGenerate(); + const enterToGenerate = enterToGenerateProp ?? enterToGeneratePreference; const [mentionState, setMentionState] = useState({ isOpen: false, diff --git a/frontend/libs/components/promptbox/src/lib/PromptBoxAudio.tsx b/frontend/libs/components/promptbox/src/lib/PromptBoxAudio.tsx index 86cb90f8..1a61545c 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBoxAudio.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBoxAudio.tsx @@ -1,3 +1,4 @@ +import { useEnterToGenerate } from "@storyteller/tauri-api"; import { useMemo, useRef, useState, ReactNode } from "react"; import { toast } from "@storyteller/ui-toaster"; import { @@ -23,7 +24,6 @@ import { } from "@storyteller/model-list"; import { usePromptAudioStore, - useEnterToGenerateStore, type RefAudio, } from "./promptStore"; import { useAutoGrowEditorHeight } from "./useAutoGrowEditorHeight"; @@ -127,7 +127,7 @@ export const PromptBoxAudio = ({ const [isEnqueueing, setIsEnqueueing] = useState(false); const [isFocused, setIsFocused] = useState(false); - const enterToGenerate = useEnterToGenerateStore((s) => s.enabled); + const enterToGenerate = useEnterToGenerate(); // Audio library picker (the "From library" button in the reference row). const [isAudioLibraryOpen, setIsAudioLibraryOpen] = useState(false); diff --git a/frontend/libs/components/promptbox/src/lib/PromptBoxImage.tsx b/frontend/libs/components/promptbox/src/lib/PromptBoxImage.tsx index dd8eb464..7e21f3fe 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBoxImage.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBoxImage.tsx @@ -1,3 +1,4 @@ +import { useEnterToGenerate } from "@storyteller/tauri-api"; import { useState, useRef, useEffect, useMemo, ReactNode } from "react"; import { useSignals } from "@preact/signals-react/runtime"; import { UploaderState } from "@storyteller/common"; @@ -13,7 +14,6 @@ import type { UploadMediaFn } from "@storyteller/api"; import { usePromptImageStore, RefImage, - useEnterToGenerateStore, } from "./promptStore"; import { useAutoGrowEditorHeight } from "./useAutoGrowEditorHeight"; import { @@ -131,7 +131,7 @@ export const PromptBoxImage = ({ const referenceImages = usePromptImageStore((s) => s.referenceImages); const setReferenceImages = usePromptImageStore((s) => s.setReferenceImages); - const enterToGenerate = useEnterToGenerateStore((s) => s.enabled); + const enterToGenerate = useEnterToGenerate(); const maxImagePromptCount = Math.max( 1, diff --git a/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx b/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx index 766824e0..22911392 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx @@ -1,3 +1,4 @@ +import { useEnterToGenerate } from "@storyteller/tauri-api"; import { useState, useRef, useEffect, useMemo, useCallback } from "react"; import { useSignals } from "@preact/signals-react/runtime"; import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover"; @@ -27,7 +28,6 @@ import { VideoInputMode, useCharactersStore, StoredCharacter, - useEnterToGenerateStore, } from "./promptStore"; import { gtagEvent } from "@storyteller/google-analytics"; import type { UploadImageFn } from "./ImagePromptRow"; @@ -186,7 +186,7 @@ export const PromptBoxVideo = ({ const setInputMode = usePromptVideoStore((s) => s.setInputMode); const generationCount = usePromptVideoStore((s) => s.generationCount); const setGenerationCount = usePromptVideoStore((s) => s.setGenerationCount); - const enterToGenerate = useEnterToGenerateStore((s) => s.enabled); + const enterToGenerate = useEnterToGenerate(); const [isEnqueueing, setIsEnqueueing] = useState(false); const [isFocused, setIsFocused] = useState(false); const [isExpanded, setIsExpanded] = useState(false); diff --git a/frontend/libs/components/promptbox/src/lib/promptStore.ts b/frontend/libs/components/promptbox/src/lib/promptStore.ts index 44ad7d36..daf5aee2 100644 --- a/frontend/libs/components/promptbox/src/lib/promptStore.ts +++ b/frontend/libs/components/promptbox/src/lib/promptStore.ts @@ -293,47 +293,8 @@ export const usePromptEditStore = create()((set) => ({ setResolution: (resolution) => set({ resolution }), })); -// ----- Enter-to-Generate Preference Store ----- -// Controls how the Enter key behaves inside prompt boxes. -// false (default): Both Enter and Shift+Enter insert a newline; only the button submits. -// true: Enter submits; Shift+Enter inserts a newline. -// Persisted to localStorage so the choice survives reloads. -const ENTER_TO_GENERATE_STORAGE_KEY = "artcraft_enter_to_generate"; - -const readEnterToGenerate = (): boolean => { - if (typeof window === "undefined") return false; - try { - return window.localStorage.getItem(ENTER_TO_GENERATE_STORAGE_KEY) === "true"; - } catch { - return false; - } -}; - -const writeEnterToGenerate = (enabled: boolean) => { - if (typeof window === "undefined") return; - try { - if (enabled) { - window.localStorage.setItem(ENTER_TO_GENERATE_STORAGE_KEY, "true"); - } else { - window.localStorage.removeItem(ENTER_TO_GENERATE_STORAGE_KEY); - } - } catch { - // ignore storage failures - } -}; - -interface EnterToGenerateStore { - enabled: boolean; - setEnabled: (enabled: boolean) => void; -} - -export const useEnterToGenerateStore = create()((set) => ({ - enabled: readEnterToGenerate(), - setEnabled: (enabled) => { - writeEnterToGenerate(enabled); - set({ enabled }); - }, -})); +// NB: The Enter-to-generate preference lives in the backend app preferences; +// read it with `useEnterToGenerate()` from @storyteller/tauri-api. // ----- Characters Store ----- export interface StoredCharacter { diff --git a/frontend/libs/components/settings-modal/src/lib/panes/MiscSettingsPane.tsx b/frontend/libs/components/settings-modal/src/lib/panes/MiscSettingsPane.tsx index 285268d5..cda572ae 100644 --- a/frontend/libs/components/settings-modal/src/lib/panes/MiscSettingsPane.tsx +++ b/frontend/libs/components/settings-modal/src/lib/panes/MiscSettingsPane.tsx @@ -1,12 +1,31 @@ -import { useEnterToGenerateStore } from "@storyteller/ui-promptbox"; +import { + PromptPreferenceName, + UpdatePromptPreference, + useAppPreferencesStore, + useEnterToGenerate, +} from "@storyteller/tauri-api"; import { Switch } from "@storyteller/ui-switch"; import { SettingsRow } from "./SettingsRow"; interface MiscSettingsPaneProps {} export const MiscSettingsPane = (args: MiscSettingsPaneProps) => { - const enterToGenerate = useEnterToGenerateStore((s) => s.enabled); - const setEnterToGenerate = useEnterToGenerateStore((s) => s.setEnabled); + const enterToGenerate = useEnterToGenerate(); + const refreshPreferences = useAppPreferencesStore((s) => s.refresh); + + const setEnterToGenerate = async (enabled: boolean) => { + try { + await UpdatePromptPreference({ + preference: PromptPreferenceName.EnterToGenerate, + value: enabled, + }); + } catch (err) { + console.error("Could not save Enter-to-generate preference:", err); + } + // The backend also broadcasts app_preferences_changed_event; refreshing + // here just makes the switch settle without waiting on it. + await refreshPreferences(); + }; return (
diff --git a/frontend/libs/soundboard/src/lib/SoundManager.ts b/frontend/libs/soundboard/src/lib/SoundManager.ts index c944e3b2..7250acea 100644 --- a/frontend/libs/soundboard/src/lib/SoundManager.ts +++ b/frontend/libs/soundboard/src/lib/SoundManager.ts @@ -1,9 +1,10 @@ import { AppSoundEvent, AppSoundFile, - GetAppPreferences, SILENT_SOUND, + getCachedAppPreferences, isCustomWavSound, + useAppPreferencesStore, } from "@storyteller/tauri-api"; import { CustomSoundPlayer } from "./CustomSoundPlayer"; import { SoundEffect } from "./SoundEffect"; @@ -98,13 +99,11 @@ export class SoundManager { static async playGenerationFailure() { await this.playEvent("generation_failure"); } private static async playEvent(event: AppSoundEvent) { - let sounds; - try { - sounds = (await GetAppPreferences()).preferences?.sounds; - } catch (err) { - console.warn("Could not read sound preferences; not playing a sound:", err); - return; + // Read the synced cache; load it once if this fires before the app root did. + if (getCachedAppPreferences() === undefined) { + await useAppPreferencesStore.getState().refresh(); } + const sounds = getCachedAppPreferences()?.sounds; if (!sounds?.play_sounds) return; await this.playSound(sounds[event], event); } diff --git a/frontend/libs/tauri-api/package.json b/frontend/libs/tauri-api/package.json index b0dae566..455365f1 100644 --- a/frontend/libs/tauri-api/package.json +++ b/frontend/libs/tauri-api/package.json @@ -15,6 +15,7 @@ } }, "dependencies": { - "@tauri-apps/api": "^2.11.0" + "@tauri-apps/api": "^2.11.0", + "zustand": "^5.0.5" } } diff --git a/frontend/libs/tauri-api/src/index.ts b/frontend/libs/tauri-api/src/index.ts index c0920068..b236c508 100644 --- a/frontend/libs/tauri-api/src/index.ts +++ b/frontend/libs/tauri-api/src/index.ts @@ -32,6 +32,8 @@ export * from "./lib/providers/Provider.js"; export * from "./lib/settings/GetAppPreferences.js"; export * from "./lib/settings/UpdateAppPreference.js"; export * from "./lib/settings/UpdateSoundPreference.js"; +export * from "./lib/settings/UpdatePromptPreference.js"; +export * from "./lib/settings/appPreferencesStore.js"; export * from "./lib/settings/LoadCustomSound.js"; export * from "./lib/sora/CheckSoraSession.js"; export * from "./lib/sora/LogoutSoraSession.js"; diff --git a/frontend/libs/tauri-api/src/lib/settings/GetAppPreferences.ts b/frontend/libs/tauri-api/src/lib/settings/GetAppPreferences.ts index e194807e..9e2879da 100644 --- a/frontend/libs/tauri-api/src/lib/settings/GetAppPreferences.ts +++ b/frontend/libs/tauri-api/src/lib/settings/GetAppPreferences.ts @@ -8,6 +8,13 @@ export interface GetAppPreferencesResult { export interface AppPreferencesPayload { sounds: AppSoundPreferences, downloads: AppDownloadPreferences, + prompt: AppPromptPreferences, +} + +export interface AppPromptPreferences { + // When on, Enter submits the prompt and Shift+Enter inserts a newline. + // When off, both insert a newline and only the button submits. + enter_to_generate: boolean, } export interface AppSoundPreferences { diff --git a/frontend/libs/tauri-api/src/lib/settings/UpdatePromptPreference.ts b/frontend/libs/tauri-api/src/lib/settings/UpdatePromptPreference.ts new file mode 100644 index 00000000..33aae156 --- /dev/null +++ b/frontend/libs/tauri-api/src/lib/settings/UpdatePromptPreference.ts @@ -0,0 +1,27 @@ +import { invoke } from "@tauri-apps/api/core"; +import { AppPromptPreferences } from "./GetAppPreferences"; + +export enum PromptPreferenceName { + EnterToGenerate = "enter_to_generate", +} + +export interface UpdatePromptPreferenceRequest { + preference: PromptPreferenceName, + value: boolean, +} + +export interface UpdatePromptPreferenceResult { + // The full prompt preferences after the change. + prompt: AppPromptPreferences, +} + +// Change one prompt-box preference. +export const UpdatePromptPreference = async (request: UpdatePromptPreferenceRequest) : Promise => { + let result = await invoke("update_prompt_preference_command", { + request: { + preference: request.preference, + value: request.value, + } + }); + return (result as UpdatePromptPreferenceResult); +} diff --git a/frontend/libs/tauri-api/src/lib/settings/appPreferencesStore.ts b/frontend/libs/tauri-api/src/lib/settings/appPreferencesStore.ts new file mode 100644 index 00000000..5ad2ea99 --- /dev/null +++ b/frontend/libs/tauri-api/src/lib/settings/appPreferencesStore.ts @@ -0,0 +1,63 @@ +import { create } from "zustand"; +import { AppPreferencesPayload, GetAppPreferences } from "./GetAppPreferences"; + +// ── App preferences cache ── +// +// The single frontend copy of the backend's app preferences. Hot paths (e.g. +// deciding what Enter does in a prompt box) read it synchronously; nothing +// calls the backend per keypress. +// +// Kept fresh by `useAppPreferencesSync` (in @storyteller/tauri-events), which +// loads it on mount, re-loads when the backend emits +// `app_preferences_changed_event` (sent after every preference write), and +// re-loads on window focus / when older than STALE_AFTER_MS as a safety net. +// Code that writes a preference can also call `refresh()` directly. + +export const STALE_AFTER_MS = 60_000; + +interface AppPreferencesStore { + preferences: AppPreferencesPayload | undefined; + // When `preferences` was last loaded (ms since epoch); undefined if never. + loadedAt: number | undefined; + // Re-read from the backend. Failures are logged and leave the cache as-is. + refresh: () => Promise; + // Re-read only if never loaded or older than STALE_AFTER_MS. + refreshIfStale: () => Promise; +} + +export const useAppPreferencesStore = create()((set, get) => ({ + preferences: undefined, + loadedAt: undefined, + + refresh: async () => { + try { + const result = await GetAppPreferences(); + set({ preferences: result.preferences, loadedAt: Date.now() }); + } catch (err) { + console.warn("Could not load app preferences; keeping the cached copy:", err); + } + }, + + refreshIfStale: async () => { + const { loadedAt, refresh } = get(); + if (loadedAt === undefined || Date.now() - loadedAt > STALE_AFTER_MS) { + await refresh(); + } + }, +})); + +// ── Selectors ── +// +// Each falls back to the backend's default until the first load completes. + +export const selectEnterToGenerate = (s: AppPreferencesStore): boolean => + s.preferences?.prompt.enter_to_generate ?? true; + +// Whether Enter submits the prompt (Shift+Enter inserts a newline). Reactive +// and synchronous — safe to read in key handlers. +export const useEnterToGenerate = (): boolean => + useAppPreferencesStore(selectEnterToGenerate); + +// Non-hook access for code outside React (e.g. sound playback). +export const getCachedAppPreferences = (): AppPreferencesPayload | undefined => + useAppPreferencesStore.getState().preferences; diff --git a/frontend/libs/tauri-events/src/index.ts b/frontend/libs/tauri-events/src/index.ts index 82c2da9a..be0f0155 100644 --- a/frontend/libs/tauri-events/src/index.ts +++ b/frontend/libs/tauri-events/src/index.ts @@ -1,5 +1,7 @@ export * from "./lib/common/BasicEventWrapper"; +export * from "./lib/events/functional/AppPreferencesChangedEvent"; export * from "./lib/events/functional/CreditsBalanceChangedEvent"; +export * from "./lib/sync/useAppPreferencesSync"; export * from "./lib/events/functional/ObjectGenerationCompleteEvent"; export * from "./lib/events/functional/RefreshAccountStateEvent"; export * from "./lib/events/functional/ShowProviderBillingModalEvent"; diff --git a/frontend/libs/tauri-events/src/lib/events/functional/AppPreferencesChangedEvent.ts b/frontend/libs/tauri-events/src/lib/events/functional/AppPreferencesChangedEvent.ts new file mode 100644 index 00000000..cb763d6a --- /dev/null +++ b/frontend/libs/tauri-events/src/lib/events/functional/AppPreferencesChangedEvent.ts @@ -0,0 +1,38 @@ +import { listen, UnlistenFn } from "@tauri-apps/api/event"; +import { BasicEventWrapper } from "../../common/BasicEventWrapper"; +import { useEffect } from "react"; + +const EVENT_NAME: string = "app_preferences_changed_event"; + +// The backend saved the app preferences (any command). No payload; re-read. +export interface AppPreferencesChangedEvent { +} + +export const useAppPreferencesChangedEvent = ( + asyncCallback: (event: AppPreferencesChangedEvent) => Promise +) => { + useEffect(() => { + let isUnmounted = false; + let unlisten: Promise; + + const setup = async () => { + unlisten = listen>( + EVENT_NAME, + async (wrappedEvent) => { + await asyncCallback(wrappedEvent.payload.data); + } + ); + + if (isUnmounted) { + unlisten.then((f) => f()); // Unsubscribe if unmounted early. + } + }; + + setup(); + + return () => { + isUnmounted = true; + unlisten.then((f) => f()); + }; + }, []); +}; diff --git a/frontend/libs/tauri-events/src/lib/sync/useAppPreferencesSync.ts b/frontend/libs/tauri-events/src/lib/sync/useAppPreferencesSync.ts new file mode 100644 index 00000000..56e1faaa --- /dev/null +++ b/frontend/libs/tauri-events/src/lib/sync/useAppPreferencesSync.ts @@ -0,0 +1,33 @@ +import { useEffect } from "react"; +import { STALE_AFTER_MS, useAppPreferencesStore } from "@storyteller/tauri-api"; +import { useAppPreferencesChangedEvent } from "../events/functional/AppPreferencesChangedEvent"; + +// Keeps the frontend's app-preferences cache (`useAppPreferencesStore`) in +// sync with the backend. Mount ONCE near the app root. +// +// - loads on mount +// - reloads immediately when the backend emits `app_preferences_changed_event` +// (every preference write does), so a change made in Settings — or from +// another window — is visible everywhere at once +// - reloads on window focus and on a stale timer as a safety net +export const useAppPreferencesSync = () => { + const refresh = useAppPreferencesStore((s) => s.refresh); + const refreshIfStale = useAppPreferencesStore((s) => s.refreshIfStale); + + useEffect(() => { + refresh(); + + const onFocus = () => { refreshIfStale(); }; + window.addEventListener("focus", onFocus); + const timer = window.setInterval(refreshIfStale, STALE_AFTER_MS); + + return () => { + window.removeEventListener("focus", onFocus); + window.clearInterval(timer); + }; + }, [refresh, refreshIfStale]); + + useAppPreferencesChangedEvent(async () => { + await refresh(); + }); +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6b4ad351..d024d92a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -737,7 +737,8 @@ "name": "@storyteller/tauri-api", "version": "0.0.1", "dependencies": { - "@tauri-apps/api": "^2.11.0" + "@tauri-apps/api": "^2.11.0", + "zustand": "^5.0.5" } }, "libs/tauri-events": {