app settings
This commit is contained in:
parent
da1fdc25f8
commit
96ec5caa5f
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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": "<name>", "value": <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<UpdateAppPreferencesResponse, String> {
|
||||
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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "<name>", "value": <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<UpdatePromptPreferenceResponse, String> {
|
||||
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::<UpdatePromptPreferenceRequest>(r#"{"preference":"enter_to_generate","value":false}"#).unwrap(),
|
||||
UpdatePromptPreferenceRequest::EnterToGenerate(false),
|
||||
));
|
||||
assert!(serde_json::from_str::<UpdatePromptPreferenceRequest>(r#"{"preference":"enter_to_generate","value":"yes"}"#).is_err());
|
||||
assert!(serde_json::from_str::<UpdatePromptPreferenceRequest>(r#"{"preference":"tab_to_generate","value":true}"#).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<UpdateSoundPreferenceResponse, String> {
|
||||
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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement, MentionTextareaProps>(
|
|||
// 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<MentionState>({
|
||||
isOpen: false,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -293,47 +293,8 @@ export const usePromptEditStore = create<PromptEditStore>()((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<EnterToGenerateStore>()((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 {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="text-base-fg">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"zustand": "^5.0.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<UpdatePromptPreferenceResult> => {
|
||||
let result = await invoke("update_prompt_preference_command", {
|
||||
request: {
|
||||
preference: request.preference,
|
||||
value: request.value,
|
||||
}
|
||||
});
|
||||
return (result as UpdatePromptPreferenceResult);
|
||||
}
|
||||
|
|
@ -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<void>;
|
||||
// Re-read only if never loaded or older than STALE_AFTER_MS.
|
||||
refreshIfStale: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAppPreferencesStore = create<AppPreferencesStore>()((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;
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
) => {
|
||||
useEffect(() => {
|
||||
let isUnmounted = false;
|
||||
let unlisten: Promise<UnlistenFn>;
|
||||
|
||||
const setup = async () => {
|
||||
unlisten = listen<BasicEventWrapper<AppPreferencesChangedEvent>>(
|
||||
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());
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
};
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue