feat(settings): add dynamic wallpaper switching to dynamic themes

This commit is contained in:
Darkatse 2026-06-12 01:20:17 +08:00
parent 3c3bf6599e
commit 451a0e7897
18 changed files with 708 additions and 71 deletions

View file

@ -67,6 +67,9 @@ pub struct DynamicThemeSettingsDto {
pub enabled: bool,
pub day_theme: String,
pub night_theme: String,
pub wallpaper_enabled: bool,
pub day_wallpaper: String,
pub night_wallpaper: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -74,6 +77,9 @@ pub struct UpdateDynamicThemeSettingsDto {
pub enabled: Option<bool>,
pub day_theme: Option<String>,
pub night_theme: Option<String>,
pub wallpaper_enabled: Option<bool>,
pub day_wallpaper: Option<String>,
pub night_wallpaper: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -221,6 +227,9 @@ impl From<DynamicThemeSettings> for DynamicThemeSettingsDto {
enabled: settings.enabled,
day_theme: settings.day_theme,
night_theme: settings.night_theme,
wallpaper_enabled: settings.wallpaper_enabled,
day_wallpaper: settings.day_wallpaper,
night_wallpaper: settings.night_wallpaper,
}
}
}

View file

@ -161,6 +161,18 @@ impl SettingsService {
settings.dynamic_theme.night_theme = night_theme;
}
if let Some(wallpaper_enabled) = dynamic_theme.wallpaper_enabled {
settings.dynamic_theme.wallpaper_enabled = wallpaper_enabled;
}
if let Some(day_wallpaper) = dynamic_theme.day_wallpaper {
settings.dynamic_theme.day_wallpaper = day_wallpaper;
}
if let Some(night_wallpaper) = dynamic_theme.night_wallpaper {
settings.dynamic_theme.night_wallpaper = night_wallpaper;
}
if settings.dynamic_theme.enabled {
if settings.dynamic_theme.day_theme.trim().is_empty() {
return Err(ApplicationError::ValidationError(
@ -174,6 +186,20 @@ impl SettingsService {
));
}
}
if settings.dynamic_theme.wallpaper_enabled {
if settings.dynamic_theme.day_wallpaper.trim().is_empty() {
return Err(ApplicationError::ValidationError(
"Dynamic wallpaper day wallpaper is required".to_string(),
));
}
if settings.dynamic_theme.night_wallpaper.trim().is_empty() {
return Err(ApplicationError::ValidationError(
"Dynamic wallpaper night wallpaper is required".to_string(),
));
}
}
}
if let Some(models) = dto.models {

View file

@ -103,6 +103,12 @@ pub struct DynamicThemeSettings {
pub day_theme: String,
#[serde(default)]
pub night_theme: String,
#[serde(default)]
pub wallpaper_enabled: bool,
#[serde(default)]
pub day_wallpaper: String,
#[serde(default)]
pub night_wallpaper: String,
}
impl Default for DynamicThemeSettings {
@ -111,6 +117,9 @@ impl Default for DynamicThemeSettings {
enabled: false,
day_theme: String::new(),
night_theme: String::new(),
wallpaper_enabled: false,
day_wallpaper: String::new(),
night_wallpaper: String::new(),
}
}
}

View file

@ -18,5 +18,15 @@
"tauritavern.error.network.body_interrupted": "The response was interrupted while it was being read.",
"tauritavern.error.network.request_failed": "Network request failed.",
"tauritavern.error.network.action": "Check your network, VPN, proxy, or custom endpoint address, then try again.",
"tauritavern.error.network.endpoint_label": "Endpoint"
"tauritavern.error.network.endpoint_label": "Endpoint",
"Dynamic Theme & Wallpaper": "Dynamic Theme & Wallpaper",
"Enable Theme Switching": "Enable Theme Switching",
"Enable Wallpaper Switching": "Enable Wallpaper Switching",
"Day Wallpaper": "Day Wallpaper",
"Night Wallpaper": "Night Wallpaper",
"Choose Wallpaper": "Choose Wallpaper",
"No backgrounds available": "No backgrounds available",
"Dynamic Theme & Wallpaper hint": "Automatically switch TauriTavern themes and global wallpapers with the system light/dark mode.",
"Dynamic Theme help: mapping": "Day = system light mode. Night = system dark mode.",
"Dynamic Theme help: behavior": "Theme switching behaves like changing the theme dropdown. Wallpaper switching updates the global SillyTavern background and respects chat-locked backgrounds."
}

View file

@ -3891,11 +3891,19 @@
"Character/User avatar originals help: off": "关闭:应用会为角色头像/用户头像提供缓存/生成的缩略图。",
"Dynamic Theme": "动态主题",
"Enable Dynamic Theme": "启用动态主题",
"Dynamic Theme & Wallpaper": "动态主题与壁纸",
"Enable Theme Switching": "启用主题切换",
"Enable Wallpaper Switching": "启用壁纸切换",
"Day Theme": "日间主题",
"Night Theme": "夜间主题",
"Day Wallpaper": "日间壁纸",
"Night Wallpaper": "夜间壁纸",
"Choose Wallpaper": "选择壁纸",
"No backgrounds available": "暂无可用背景",
"Dynamic Theme hint": "根据系统浅色/深色模式自动切换 TauriTavern 主题。",
"Dynamic Theme & Wallpaper hint": "根据系统浅色/深色模式自动切换 TauriTavern 主题与全局壁纸。",
"Dynamic Theme help: mapping": "日间 = 系统浅色模式。夜间 = 系统深色模式。",
"Dynamic Theme help: behavior": "切换效果与手动切换主题相同。",
"Dynamic Theme help: behavior": "主题切换效果与手动切换主题相同。壁纸切换会更新 SillyTavern 全局背景,并尊重聊天锁定的背景。",
"Prompt Cache": "提示词缓存",
"Claude Prompt Cache": "Claude 提示词缓存",
"5m (Default TTL)": "5m(默认 TTL)",

View file

@ -4407,11 +4407,19 @@
"Character/User avatar originals help: off": "關閉:應用會為角色頭像/使用者頭像提供快取/產生的縮圖。",
"Dynamic Theme": "動態主題",
"Enable Dynamic Theme": "啟用動態主題",
"Dynamic Theme & Wallpaper": "動態主題與壁紙",
"Enable Theme Switching": "啟用主題切換",
"Enable Wallpaper Switching": "啟用壁紙切換",
"Day Theme": "日間主題",
"Night Theme": "夜間主題",
"Day Wallpaper": "日間壁紙",
"Night Wallpaper": "夜間壁紙",
"Choose Wallpaper": "選擇壁紙",
"No backgrounds available": "暫無可用背景",
"Dynamic Theme hint": "根據系統淺色/深色模式自動切換 TauriTavern 主題。",
"Dynamic Theme & Wallpaper hint": "根據系統淺色/深色模式自動切換 TauriTavern 主題與全域壁紙。",
"Dynamic Theme help: mapping": "日間 = 系統淺色模式。夜間 = 系統深色模式。",
"Dynamic Theme help: behavior": "切換效果等同於手動更改主題下拉選單。",
"Dynamic Theme help: behavior": "主題切換效果等同於手動更改主題下拉選單。壁紙切換會更新 SillyTavern 全域背景,並尊重聊天鎖定的背景。",
"Prompt Cache": "Prompt 快取",
"Claude Prompt Cache": "Claude Prompt 快取",
"5m (Default TTL)": "5m(預設 TTL)",

View file

@ -169,6 +169,24 @@ export let background_settings = {
sortOrder: BG_SORT_OPTIONS.AZ,
};
export function getSystemBackgroundEntries() {
return cachedSystemBackgrounds.map(({ filename, isAnimated }) => ({ filename, isAnimated }));
}
export function assertSystemBackgroundExists(bg) {
const filename = String(bg || '');
if (!filename) {
throw new Error('Background filename is required');
}
const entry = cachedSystemBackgrounds.find((candidate) => candidate.filename === filename);
if (!entry) {
throw new Error(`Background not found: ${filename}`);
}
return entry;
}
/**
* Sorts an array of background entries based on the current sort order.
* @param {Array<string|{filename: string, isAnimated?: boolean}>} backgrounds - Array of background entries
@ -817,7 +835,7 @@ function renderChatBackgrounds(backgrounds) {
activateLazyLoader();
}
export async function getBackgrounds() {
export async function refreshSystemBackgroundEntries() {
const response = await fetch('/api/backgrounds/all', {
method: 'POST',
headers: getRequestHeaders(),
@ -843,6 +861,12 @@ export async function getBackgrounds() {
}
}
return getSystemBackgroundEntries();
}
export async function getBackgrounds() {
await refreshSystemBackgroundEntries();
// Load folders first so getFilteredImages() works correctly in folder view
await loadFolders();
@ -1563,6 +1587,17 @@ async function setBackground(bg, url) {
saveSettingsDebounced();
}
export async function applyGlobalBackground(bg) {
const entry = assertSystemBackgroundExists(bg);
const url = generateUrlParameter(entry.filename, false);
if (background_settings.name === entry.filename && background_settings.url === url) {
return;
}
await setBackground(entry.filename, url);
highlightSelectedBackground();
}
async function delBackground(bg) {
const response = await fetch('/api/backgrounds/delete', {
method: 'POST',

View file

@ -151,7 +151,9 @@
.tt-settings-switch input:focus-visible + span,
.tt-settings-icon-button:focus-visible,
.tt-settings-action-button:focus-visible {
.tt-settings-action-button:focus-visible,
.tt-settings-wallpaper-button:focus-visible,
.tt-wallpaper-picker-item:focus-visible {
outline: 2px solid var(--SmartThemeQuoteColor);
outline-offset: 2px;
}
@ -260,6 +262,102 @@
margin: 0;
}
.tt-settings-wallpaper-button {
display: grid;
grid-template-columns: 68px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
width: 100%;
min-height: 48px;
margin: 0;
padding: 4px 8px 4px 4px;
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 8px;
color: var(--SmartThemeBodyColor);
background: var(--black10a);
cursor: pointer;
}
.tt-settings-wallpaper-button:hover:not(:disabled) {
background: var(--white20a);
}
.tt-settings-wallpaper-button:disabled {
cursor: not-allowed;
opacity: 0.64;
}
.tt-settings-wallpaper-swatch {
display: inline-flex;
align-items: center;
justify-content: center;
width: 68px;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--SmartThemeBorderColor) 80%, transparent);
background-color: var(--black30a);
background-position: center;
background-size: cover;
}
.tt-settings-wallpaper-swatch i {
color: var(--SmartThemeQuoteColor);
}
.tt-settings-wallpaper-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.tt-wallpaper-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: 10px;
width: min(100%, 760px);
}
.tt-wallpaper-picker-item {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
margin: 0;
padding: 6px;
border: 1px solid color-mix(in srgb, var(--SmartThemeBorderColor) 76%, transparent);
border-radius: 8px;
color: var(--SmartThemeBodyColor);
background: var(--black10a);
cursor: pointer;
}
.tt-wallpaper-picker-item:hover,
.tt-wallpaper-picker-item.is-selected {
border-color: var(--SmartThemeQuoteColor);
background: color-mix(in srgb, var(--SmartThemeQuoteColor) 14%, var(--black10a));
}
.tt-wallpaper-picker-thumb {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 6px;
background-color: var(--black30a);
background-position: center;
background-size: cover;
}
.tt-wallpaper-picker-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
@media (max-width: 640px) {
.tt-settings-row {
grid-template-columns: 1fr;
@ -295,4 +393,8 @@
.tt-settings-action-button {
width: 100%;
}
.tt-wallpaper-picker {
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
}
}

View file

@ -17,9 +17,12 @@ export function buildTauriTavernSettingsUpdate(initial, draft) {
const nextChatHistoryMode = normalizeChatHistoryModeName(draft.chatHistoryMode);
const nextCloseToTrayOnClose = Boolean(draft.closeToTrayOnClose);
const nextDynamicThemeEnabled = Boolean(draft.dynamicTheme?.enabled);
const nextDynamicThemeEnabled = Boolean(draft.dynamicTheme?.themeEnabled);
const nextDynamicThemeDayTheme = String(draft.dynamicTheme?.dayTheme || '').trim();
const nextDynamicThemeNightTheme = String(draft.dynamicTheme?.nightTheme || '').trim();
const nextDynamicThemeWallpaperEnabled = Boolean(draft.dynamicTheme?.wallpaperEnabled);
const nextDynamicThemeDayWallpaper = String(draft.dynamicTheme?.dayWallpaper || '');
const nextDynamicThemeNightWallpaper = String(draft.dynamicTheme?.nightWallpaper || '');
const nextAllowKeysExposure = Boolean(draft.allowKeysExposure);
const nextAvatarPersonaOriginalImagesEnabled = Boolean(draft.avatarPersonaOriginalImagesEnabled);
@ -41,9 +44,12 @@ export function buildTauriTavernSettingsUpdate(initial, draft) {
&& (nextEmbeddedRuntimeProfile !== initial.embeddedRuntimeProfile || requiresEmbeddedRuntimeMigration);
const hasChatHistoryModeChange = nextChatHistoryMode !== initial.chatHistoryMode;
const hasCloseToTrayOnCloseChange = nextCloseToTrayOnClose !== initial.closeToTrayOnClose;
const hasDynamicThemeChange = nextDynamicThemeEnabled !== initial.dynamicTheme.enabled
const hasDynamicThemeChange = nextDynamicThemeEnabled !== initial.dynamicTheme.themeEnabled
|| nextDynamicThemeDayTheme !== initial.dynamicTheme.dayTheme
|| nextDynamicThemeNightTheme !== initial.dynamicTheme.nightTheme;
|| nextDynamicThemeNightTheme !== initial.dynamicTheme.nightTheme
|| nextDynamicThemeWallpaperEnabled !== initial.dynamicTheme.wallpaperEnabled
|| nextDynamicThemeDayWallpaper !== initial.dynamicTheme.dayWallpaper
|| nextDynamicThemeNightWallpaper !== initial.dynamicTheme.nightWallpaper;
const hasAllowKeysExposureChange = nextAllowKeysExposure !== initial.allowKeysExposure;
const hasAvatarPersonaOriginalImagesEnabledChange =
nextAvatarPersonaOriginalImagesEnabled !== initial.avatarPersonaOriginalImagesEnabled;
@ -90,6 +96,9 @@ export function buildTauriTavernSettingsUpdate(initial, draft) {
enabled: nextDynamicThemeEnabled,
day_theme: nextDynamicThemeDayTheme,
night_theme: nextDynamicThemeNightTheme,
wallpaper_enabled: nextDynamicThemeWallpaperEnabled,
day_wallpaper: nextDynamicThemeDayWallpaper,
night_wallpaper: nextDynamicThemeNightWallpaper,
};
}
if (hasAllowKeysExposureChange) {
@ -127,9 +136,12 @@ export function buildTauriTavernSettingsUpdate(initial, draft) {
chatHistoryMode: nextChatHistoryMode,
closeToTrayOnClose: nextCloseToTrayOnClose,
dynamicTheme: {
enabled: nextDynamicThemeEnabled,
themeEnabled: nextDynamicThemeEnabled,
dayTheme: nextDynamicThemeDayTheme,
nightTheme: nextDynamicThemeNightTheme,
wallpaperEnabled: nextDynamicThemeWallpaperEnabled,
dayWallpaper: nextDynamicThemeDayWallpaper,
nightWallpaper: nextDynamicThemeNightWallpaper,
},
allowKeysExposure: nextAllowKeysExposure,
avatarPersonaOriginalImagesEnabled: nextAvatarPersonaOriginalImagesEnabled,

View file

@ -67,7 +67,7 @@ const HELP_TOPICS = {
],
},
dynamicTheme: {
title: 'Dynamic Theme',
title: 'Dynamic Theme & Wallpaper',
lines: [
'Dynamic Theme help: mapping',
'Dynamic Theme help: behavior',
@ -103,6 +103,33 @@ function readThemeOptions() {
}));
}
function getBackgroundThumbnailUrl(filename) {
if (typeof window.__TAURITAVERN_THUMBNAIL__ === 'function') {
return window.__TAURITAVERN_THUMBNAIL__('bg', filename);
}
return `/thumbnail?type=bg&file=${encodeURIComponent(filename)}`;
}
async function readBackgroundOptions() {
const {
background_settings,
refreshSystemBackgroundEntries,
} = await import('../../../backgrounds.js');
const backgroundEntries = await refreshSystemBackgroundEntries();
return {
currentBackground: String(background_settings.name || ''),
backgroundOptions: backgroundEntries.map((entry) => ({
value: entry.filename,
label: entry.filename,
thumbnailUrl: getBackgroundThumbnailUrl(entry.filename),
isAnimated: entry.isAnimated,
})),
};
}
function createPopupColumn() {
const root = document.createElement('div');
root.className = 'flex-container flexFlowColumn';
@ -110,6 +137,71 @@ function createPopupColumn() {
return root;
}
function setWallpaperPickerSelection(root, selectedValue) {
for (const item of root.querySelectorAll('.tt-wallpaper-picker-item')) {
item.classList.toggle('is-selected', item.getAttribute('data-value') === selectedValue);
}
}
function createWallpaperPickerItem(option, selected, onSelect) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'tt-wallpaper-picker-item';
item.dataset.value = option.value;
item.title = option.label;
const thumbnail = document.createElement('span');
thumbnail.className = 'tt-wallpaper-picker-thumb';
thumbnail.style.backgroundImage = `url("${option.thumbnailUrl}")`;
const label = document.createElement('span');
label.className = 'tt-wallpaper-picker-label';
label.textContent = option.label;
item.append(thumbnail, label);
item.classList.toggle('is-selected', selected === option.value);
item.addEventListener('click', () => onSelect(option.value));
return item;
}
async function chooseWallpaper(backgroundOptions, request = {}) {
if (backgroundOptions.length === 0) {
await callGenericPopup(translate('No backgrounds available'), POPUP_TYPE.TEXT, '', {
okButton: translate('OK'),
allowVerticalScrolling: true,
wide: false,
large: false,
});
return null;
}
const requestedValue = String(request?.currentValue || '');
let selected = backgroundOptions.some((option) => option.value === requestedValue)
? requestedValue
: backgroundOptions[0].value;
const picker = document.createElement('div');
picker.className = 'tt-wallpaper-picker';
for (const option of backgroundOptions) {
picker.appendChild(createWallpaperPickerItem(option, selected, (nextValue) => {
selected = nextValue;
setWallpaperPickerSelection(picker, selected);
}));
}
const result = await callGenericPopup(picker, POPUP_TYPE.CONFIRM, '', {
okButton: translate('Select'),
cancelButton: translate('Cancel'),
allowVerticalScrolling: true,
wide: true,
large: false,
});
return result === POPUP_RESULT.AFFIRMATIVE ? selected : null;
}
async function showHelpTopic(topicId) {
const topic = HELP_TOPICS[topicId];
if (!topic) {
@ -182,9 +274,10 @@ async function chooseDataRoot() {
return normalized;
}
function createSettingsActions() {
function createSettingsActions(backgroundOptions) {
return {
chooseDataRoot: () => runTaskOrPopup(chooseDataRoot),
chooseWallpaper: (request) => runTaskOrPopup(() => chooseWallpaper(backgroundOptions, request)),
showHelp: (topicId) => runTaskOrPopup(() => showHelpTopic(topicId)),
reloadFrontend: () => runTaskOrPopup(async () => {
window.location.reload();
@ -211,16 +304,19 @@ function createSettingsActions() {
export async function openTauriTavernSettingsPopup() {
ensureSettingsStyle();
const [viewModel, bundle] = await Promise.all([
const [viewModel, bundle, backgroundModel] = await Promise.all([
loadTauriTavernSettingsViewModel(),
importSettingsBundle(),
readBackgroundOptions(),
]);
const mount = document.createElement('div');
const appHandle = bundle.mountTauriTavernSettingsApp(mount, {
viewModel,
themeOptions: readThemeOptions(),
actions: createSettingsActions(),
backgroundOptions: backgroundModel.backgroundOptions,
currentBackground: backgroundModel.currentBackground,
actions: createSettingsActions(backgroundModel.backgroundOptions),
tr: translate,
});

View file

@ -96,9 +96,12 @@ export function createTauriTavernSettingsState(settings, options = {}) {
? options.nativeRegexBackendEnabled
: readNativeRegexBackendEnabledFromSettings(settings),
dynamicTheme: {
enabled: Boolean(dynamicTheme.enabled),
themeEnabled: Boolean(dynamicTheme.enabled),
dayTheme: String(dynamicTheme.day_theme || '').trim(),
nightTheme: String(dynamicTheme.night_theme || '').trim(),
wallpaperEnabled: Boolean(dynamicTheme.wallpaper_enabled),
dayWallpaper: String(dynamicTheme.day_wallpaper || ''),
nightWallpaper: String(dynamicTheme.night_wallpaper || ''),
},
promptCacheTtl: PROMPT_CACHE_TTL_VALUES.includes(rawPromptCacheTtl) ? rawPromptCacheTtl : 'off',
promptCacheTtlSource: rawPromptCacheTtl,

View file

@ -4,6 +4,7 @@ import {
SettingRow,
SettingsSection,
ToggleSwitch,
WallpaperField,
} from './components.js';
const PANEL_RUNTIME_OPTIONS = [
@ -32,8 +33,10 @@ const PROMPT_CACHE_OPTIONS = [
function cloneOptions(options) {
return options.map((option) => ({
value: String(option.value || '').trim(),
label: String(option.label || option.value || '').trim(),
value: String(option.value || ''),
label: String(option.label || option.value || ''),
thumbnailUrl: String(option.thumbnailUrl || ''),
isAnimated: Boolean(option.isAnimated),
}));
}
@ -44,8 +47,12 @@ function translateOptions(options, tr) {
}));
}
function cloneDraft(values, themeOptions) {
function cloneDraft(values, themeOptions, backgroundOptions, currentBackground) {
const fallbackTheme = themeOptions[0]?.value || '';
const normalizedCurrentBackground = String(currentBackground || '');
const fallbackBackground = backgroundOptions.some((option) => option.value === normalizedCurrentBackground)
? normalizedCurrentBackground
: backgroundOptions[0]?.value || '';
return {
panelRuntimeProfile: values.panelRuntimeProfile,
@ -61,9 +68,12 @@ function cloneDraft(values, themeOptions) {
avatarPersonaOriginalImagesEnabled: values.avatarPersonaOriginalImagesEnabled,
nativeRegexBackendEnabled: values.nativeRegexBackendEnabled,
dynamicTheme: {
enabled: values.dynamicTheme.enabled,
themeEnabled: values.dynamicTheme.themeEnabled,
dayTheme: values.dynamicTheme.dayTheme || fallbackTheme,
nightTheme: values.dynamicTheme.nightTheme || fallbackTheme,
wallpaperEnabled: values.dynamicTheme.wallpaperEnabled,
dayWallpaper: values.dynamicTheme.dayWallpaper || (values.dynamicTheme.wallpaperEnabled ? fallbackBackground : ''),
nightWallpaper: values.dynamicTheme.nightWallpaper || (values.dynamicTheme.wallpaperEnabled ? fallbackBackground : ''),
},
promptCacheTtl: values.promptCacheTtl,
};
@ -96,8 +106,10 @@ export function createTauriTavernSettingsApp(options) {
}
const themeOptions = cloneOptions(options.themeOptions || []);
const backgroundOptions = cloneOptions(options.backgroundOptions || []);
const currentBackground = String(options.currentBackground || '');
const capabilities = { ...viewModel.capabilities };
const initialDraft = cloneDraft(viewModel.values, themeOptions);
const initialDraft = cloneDraft(viewModel.values, themeOptions, backgroundOptions, currentBackground);
const initialDataRoot = cloneDataRoot(viewModel.dataRoot);
return {
@ -108,11 +120,13 @@ export function createTauriTavernSettingsApp(options) {
SettingRow,
SettingsSection,
ToggleSwitch,
WallpaperField,
},
data() {
return {
capabilities,
themeOptions,
backgroundOptions,
draft: initialDraft,
dataRoot: initialDataRoot,
details: {
@ -144,7 +158,7 @@ export function createTauriTavernSettingsApp(options) {
requestProxySummary() {
return this.tr(this.details.requestProxy ? 'Click to collapse' : 'Click to expand');
},
dynamicThemeSummary() {
dynamicAppearanceSummary() {
return this.tr(this.details.dynamicTheme ? 'Click to collapse' : 'Click to expand');
},
dataRootSummary() {
@ -194,6 +208,36 @@ export function createTauriTavernSettingsApp(options) {
{ value: normalized, label: normalized },
];
},
backgroundOptionsWithStored(storedValue) {
const normalized = String(storedValue || '');
if (!normalized || this.backgroundOptions.some((option) => option.value === normalized)) {
return this.backgroundOptions;
}
return [
...this.backgroundOptions,
{
value: normalized,
label: normalized,
thumbnailUrl: '',
isAnimated: false,
},
];
},
backgroundOption(storedValue) {
const normalized = String(storedValue || '');
return this.backgroundOptionsWithStored(normalized)
.find((option) => option.value === normalized) || null;
},
ensureWallpaperDefaults() {
const fallback = this.backgroundOptions[0]?.value || '';
if (!this.draft.dynamicTheme.dayWallpaper) {
this.draft.dynamicTheme.dayWallpaper = fallback;
}
if (!this.draft.dynamicTheme.nightWallpaper) {
this.draft.dynamicTheme.nightWallpaper = fallback;
}
},
showHelp(topicId) {
void requireAction(actions, 'showHelp')(topicId);
},
@ -226,14 +270,33 @@ export function createTauriTavernSettingsApp(options) {
this.details.requestProxy = true;
this.$nextTick(() => this.$refs.requestProxyUrl?.focus?.());
},
setDynamicThemeEnabled(enabled) {
this.draft.dynamicTheme.enabled = enabled;
setThemeSwitchingEnabled(enabled) {
this.draft.dynamicTheme.themeEnabled = enabled;
if (!enabled) {
return;
}
this.details.dynamicTheme = true;
this.$nextTick(() => this.$refs.dynamicThemeDay?.$el?.focus?.());
},
setWallpaperSwitchingEnabled(enabled) {
this.draft.dynamicTheme.wallpaperEnabled = enabled;
if (!enabled) {
return;
}
this.ensureWallpaperDefaults();
this.details.dynamicTheme = true;
},
async chooseWallpaper(targetKey) {
const selected = await requireAction(actions, 'chooseWallpaper')({
currentValue: this.draft.dynamicTheme[targetKey],
});
if (!selected) {
return;
}
this.draft.dynamicTheme[targetKey] = selected;
},
getDraft() {
return {
panelRuntimeProfile: this.draft.panelRuntimeProfile,
@ -419,22 +482,22 @@ export function createTauriTavernSettingsApp(options) {
@toggle="details.dynamicTheme = $event.currentTarget.open"
>
<summary>
<span>{{ tr('Dynamic Theme') }}</span>
<span>{{ tr('Dynamic Theme & Wallpaper') }}</span>
<span class="tt-settings-summary-meta">
<small>{{ dynamicThemeSummary }}</small>
<small>{{ dynamicAppearanceSummary }}</small>
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</span>
</summary>
<div class="tt-settings-disclosure-body">
<SettingRow
:label="tr('Enable Dynamic Theme')"
:label="tr('Enable Theme Switching')"
help-topic="dynamicTheme"
:help-title="tr('Learn more')"
@help="showHelp"
>
<ToggleSwitch
:model-value="draft.dynamicTheme.enabled"
@update:model-value="setDynamicThemeEnabled"
:model-value="draft.dynamicTheme.themeEnabled"
@update:model-value="setThemeSwitchingEnabled"
/>
</SettingRow>
<SettingRow :label="tr('Day Theme')">
@ -442,17 +505,41 @@ export function createTauriTavernSettingsApp(options) {
ref="dynamicThemeDay"
v-model="draft.dynamicTheme.dayTheme"
:options="themeOptionsWithStored(draft.dynamicTheme.dayTheme)"
:disabled="!draft.dynamicTheme.enabled"
:disabled="!draft.dynamicTheme.themeEnabled"
/>
</SettingRow>
<SettingRow :label="tr('Night Theme')">
<SelectField
v-model="draft.dynamicTheme.nightTheme"
:options="themeOptionsWithStored(draft.dynamicTheme.nightTheme)"
:disabled="!draft.dynamicTheme.enabled"
:disabled="!draft.dynamicTheme.themeEnabled"
/>
</SettingRow>
<small class="tt-settings-section-note">{{ tr('Dynamic Theme hint') }}</small>
<SettingRow :label="tr('Enable Wallpaper Switching')">
<ToggleSwitch
:model-value="draft.dynamicTheme.wallpaperEnabled"
@update:model-value="setWallpaperSwitchingEnabled"
/>
</SettingRow>
<SettingRow :label="tr('Day Wallpaper')">
<WallpaperField
:option="backgroundOption(draft.dynamicTheme.dayWallpaper)"
:value="draft.dynamicTheme.dayWallpaper"
:placeholder="tr('Choose Wallpaper')"
:disabled="!draft.dynamicTheme.wallpaperEnabled"
@choose="chooseWallpaper('dayWallpaper')"
/>
</SettingRow>
<SettingRow :label="tr('Night Wallpaper')">
<WallpaperField
:option="backgroundOption(draft.dynamicTheme.nightWallpaper)"
:value="draft.dynamicTheme.nightWallpaper"
:placeholder="tr('Choose Wallpaper')"
:disabled="!draft.dynamicTheme.wallpaperEnabled"
@choose="chooseWallpaper('nightWallpaper')"
/>
</SettingRow>
<small class="tt-settings-section-note">{{ tr('Dynamic Theme & Wallpaper hint') }}</small>
</div>
</details>
</SettingsSection>

View file

@ -88,6 +88,43 @@ export const SelectField = {
`,
};
export const WallpaperField = {
props: {
option: { type: Object, default: null },
value: { type: String, default: '' },
placeholder: { type: String, required: true },
disabled: { type: Boolean, default: false },
},
emits: ['choose'],
computed: {
label() {
return this.option?.label || this.value || this.placeholder;
},
swatchStyle() {
if (!this.option?.thumbnailUrl) {
return {};
}
return { backgroundImage: `url("${this.option.thumbnailUrl}")` };
},
},
template: `
<button
type="button"
class="tt-settings-wallpaper-button"
:disabled="disabled"
:title="label"
@click="$emit('choose')"
>
<span class="tt-settings-wallpaper-swatch" :style="swatchStyle">
<i v-if="!option?.thumbnailUrl" class="fa-solid fa-image" aria-hidden="true"></i>
</span>
<span class="tt-settings-wallpaper-label">{{ label }}</span>
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</button>
`,
};
export const ActionButton = {
props: {
label: { type: String, required: true },

View file

@ -0,0 +1,57 @@
// @ts-check
function getSillyTavernThemeSelector() {
const selector = document.getElementById('themes');
if (!(selector instanceof HTMLSelectElement)) {
throw new Error('Dynamic appearance: SillyTavern theme selector not found');
}
return selector;
}
/**
* @param {string} themeName
*/
export function assertSillyTavernThemeAvailable(themeName) {
const targetTheme = String(themeName || '').trim();
if (!targetTheme) {
throw new Error('Dynamic appearance theme target is empty');
}
const selector = getSillyTavernThemeSelector();
const exists = Array.from(selector.options).some((option) => option.value === targetTheme);
if (!exists) {
throw new Error(`Dynamic appearance theme target not found: ${targetTheme}`);
}
return targetTheme;
}
/**
* @param {string} themeName
*/
export function applySillyTavernTheme(themeName) {
const targetTheme = assertSillyTavernThemeAvailable(themeName);
const selector = getSillyTavernThemeSelector();
if (selector.value === targetTheme) {
return;
}
selector.value = targetTheme;
selector.dispatchEvent(new Event('change', { bubbles: true }));
}
/**
* @param {string} filename
*/
export async function assertSillyTavernGlobalBackgroundAvailable(filename) {
const { assertSystemBackgroundExists } = await import('../../../../scripts/backgrounds.js');
return assertSystemBackgroundExists(filename).filename;
}
/**
* @param {string} filename
*/
export async function applySillyTavernGlobalBackground(filename) {
const { applyGlobalBackground } = await import('../../../../scripts/backgrounds.js');
await applyGlobalBackground(filename);
}

View file

@ -2,16 +2,14 @@
import { eventSource, event_types } from '../../../../scripts/events.js';
import { getTauriTavernSettings } from '../../../../tauri-bridge.js';
import {
applySillyTavernGlobalBackground,
applySillyTavernTheme,
assertSillyTavernGlobalBackgroundAvailable,
assertSillyTavernThemeAvailable,
} from '../../adapters/st/appearance.js';
import { DYNAMIC_THEME_CHANGED_EVENT } from './constants.js';
function getSillyTavernThemeSelector() {
const selector = document.getElementById('themes');
if (!(selector instanceof HTMLSelectElement)) {
throw new Error('Dynamic theme: SillyTavern theme selector not found');
}
return selector;
}
function readSystemThemeFromMedia() {
const query = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
if (!query) {
@ -40,29 +38,14 @@ function normalizeDynamicThemeSettings(payload) {
const settings = /** @type {any} */ (payload);
const enabled = Boolean(settings.enabled);
const themeEnabled = Boolean(settings.enabled);
const dayTheme = String(settings.day_theme || '').trim();
const nightTheme = String(settings.night_theme || '').trim();
const wallpaperEnabled = Boolean(settings.wallpaper_enabled);
const dayWallpaper = String(settings.day_wallpaper || '');
const nightWallpaper = String(settings.night_wallpaper || '');
return { enabled, dayTheme, nightTheme };
}
/**
* @param {string} themeName
*/
function applySillyTavernTheme(themeName) {
const selector = getSillyTavernThemeSelector();
if (selector.value === themeName) {
return;
}
const exists = Array.from(selector.options).some((option) => option.value === themeName);
if (!exists) {
throw new Error(`Dynamic theme target not found: ${themeName}`);
}
selector.value = themeName;
selector.dispatchEvent(new Event('change', { bubbles: true }));
return { themeEnabled, dayTheme, nightTheme, wallpaperEnabled, dayWallpaper, nightWallpaper };
}
export function installDynamicTheme() {
@ -70,20 +53,51 @@ export function installDynamicTheme() {
let dynamicTheme = normalizeDynamicThemeSettings(settings.dynamic_theme);
const preferredColorSchemeQuery = getPreferredColorSchemeQuery();
let systemTheme = preferredColorSchemeQuery?.matches ? 'dark' : readSystemThemeFromMedia();
let hasPendingVisibilitySync = false;
/** @param {string} reason */
const syncNow = (reason) => {
if (!dynamicTheme.enabled) {
const syncNow = async (reason) => {
if (!dynamicTheme.themeEnabled && !dynamicTheme.wallpaperEnabled) {
return;
}
const targetTheme = systemTheme === 'dark' ? dynamicTheme.nightTheme : dynamicTheme.dayTheme;
if (!targetTheme) {
throw new Error('Dynamic theme is enabled but the target theme is empty');
/** @type {{ theme?: string, wallpaper?: string }} */
const applied = {};
let targetTheme = '';
let targetWallpaper = '';
if (dynamicTheme.themeEnabled) {
targetTheme = systemTheme === 'dark' ? dynamicTheme.nightTheme : dynamicTheme.dayTheme;
if (!targetTheme) {
throw new Error('Dynamic theme is enabled but the target theme is empty');
}
}
applySillyTavernTheme(targetTheme);
console.debug('Dynamic theme applied', { reason, systemTheme, targetTheme });
if (dynamicTheme.wallpaperEnabled) {
targetWallpaper = systemTheme === 'dark' ? dynamicTheme.nightWallpaper : dynamicTheme.dayWallpaper;
if (!targetWallpaper) {
throw new Error('Dynamic wallpaper is enabled but the target wallpaper is empty');
}
}
if (dynamicTheme.themeEnabled) {
assertSillyTavernThemeAvailable(targetTheme);
}
if (dynamicTheme.wallpaperEnabled) {
await assertSillyTavernGlobalBackgroundAvailable(targetWallpaper);
}
if (dynamicTheme.themeEnabled) {
applySillyTavernTheme(targetTheme);
applied.theme = targetTheme;
}
if (dynamicTheme.wallpaperEnabled) {
await applySillyTavernGlobalBackground(targetWallpaper);
applied.wallpaper = targetWallpaper;
}
console.debug('Dynamic appearance applied', { reason, systemTheme, ...applied });
};
/**
@ -97,13 +111,15 @@ export function installDynamicTheme() {
systemTheme = nextTheme;
if (document.visibilityState === 'hidden') {
hasPendingVisibilitySync = true;
return;
}
hasPendingVisibilitySync = false;
void Promise.resolve()
.then(() => syncNow(reason))
.catch((error) => {
console.error('Dynamic theme sync failed after system theme change', error);
console.error('Dynamic appearance sync failed after system theme change', error);
});
};
@ -113,7 +129,7 @@ export function installDynamicTheme() {
void Promise.resolve()
.then(() => syncNow('config-changed'))
.catch((error) => {
console.error('Dynamic theme sync failed after config change', error);
console.error('Dynamic appearance sync failed after config change', error);
});
};
@ -123,11 +139,11 @@ export function installDynamicTheme() {
void Promise.resolve()
.then(() => syncNow('startup'))
.catch((error) => {
console.error('Dynamic theme initial sync failed', error);
console.error('Dynamic appearance initial sync failed', error);
});
if (!preferredColorSchemeQuery) {
throw new Error('Dynamic theme: matchMedia is unavailable');
throw new Error('Dynamic appearance: matchMedia is unavailable');
}
const handlePreferredColorSchemeChange = (/** @type {any} */ event) => {
@ -140,12 +156,12 @@ export function installDynamicTheme() {
} else if (typeof preferredColorSchemeQuery.addListener === 'function') {
preferredColorSchemeQuery.addListener(handlePreferredColorSchemeChange);
} else {
throw new Error('Dynamic theme: matchMedia change listener is unavailable');
throw new Error('Dynamic appearance: matchMedia change listener is unavailable');
}
const listen = window.__TAURI__?.event?.listen;
if (typeof listen !== 'function') {
throw new Error('Dynamic theme: Tauri theme listener is unavailable');
throw new Error('Dynamic appearance: Tauri theme listener is unavailable');
}
void listen('tauri://theme-changed', (/** @type {any} */ event) => {
@ -159,6 +175,16 @@ export function installDynamicTheme() {
}
const nextTheme = preferredColorSchemeQuery.matches ? 'dark' : 'light';
if (nextTheme === systemTheme && hasPendingVisibilitySync) {
hasPendingVisibilitySync = false;
void Promise.resolve()
.then(() => syncNow('visibilitychange'))
.catch((error) => {
console.error('Dynamic appearance sync failed after visibility change', error);
});
return;
}
updateSystemThemeAndSync(nextTheme, 'visibilitychange');
});
});

View file

@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
async function readRepoFile(relativePath) {
return readFile(path.join(REPO_ROOT, relativePath), 'utf8');
}
test('dynamic appearance runtime applies SillyTavern appearance through the ST adapter', async () => {
const runtime = await readRepoFile('src/tauri/main/services/dynamic-theme/install.js');
const adapter = await readRepoFile('src/tauri/main/adapters/st/appearance.js');
assert.match(runtime, /applySillyTavernTheme/);
assert.match(runtime, /applySillyTavernGlobalBackground/);
assert.doesNotMatch(runtime, /scripts\/backgrounds\.js/);
assert.match(adapter, /import\('\.\.\/\.\.\/\.\.\/\.\.\/scripts\/backgrounds\.js'\)/);
});
test('dynamic appearance runtime validates targets before applying appearance', async () => {
const runtime = await readRepoFile('src/tauri/main/services/dynamic-theme/install.js');
const themeAssert = runtime.indexOf('assertSillyTavernThemeAvailable(targetTheme)');
const wallpaperAssert = runtime.indexOf('await assertSillyTavernGlobalBackgroundAvailable(targetWallpaper)');
const themeApply = runtime.indexOf('applySillyTavernTheme(targetTheme)');
const wallpaperApply = runtime.indexOf('await applySillyTavernGlobalBackground(targetWallpaper)');
assert.ok(themeAssert > -1);
assert.ok(wallpaperAssert > -1);
assert.ok(themeApply > -1);
assert.ok(wallpaperApply > -1);
assert.ok(themeAssert < themeApply);
assert.ok(wallpaperAssert < wallpaperApply);
});
test('global background helper reuses background state instead of simulating UI clicks', async () => {
const source = await readRepoFile('src/scripts/backgrounds.js');
const start = source.indexOf('export async function applyGlobalBackground');
const end = source.indexOf('async function delBackground', start);
assert.notEqual(start, -1);
assert.notEqual(end, -1);
const helper = source.slice(start, end);
assert.match(helper, /assertSystemBackgroundExists/);
assert.match(helper, /setBackground/);
assert.match(helper, /highlightSelectedBackground/);
assert.doesNotMatch(helper, /\.click\(/);
});
test('background option refresh is separated from drawer rendering', async () => {
const source = await readRepoFile('src/scripts/backgrounds.js');
const refreshStart = source.indexOf('export async function refreshSystemBackgroundEntries');
const getBackgroundsStart = source.indexOf('export async function getBackgrounds');
const getBackgroundsEnd = source.indexOf('/**\n * Preloads all image metadata', getBackgroundsStart);
assert.notEqual(refreshStart, -1);
assert.notEqual(getBackgroundsStart, -1);
assert.notEqual(getBackgroundsEnd, -1);
const refresh = source.slice(refreshStart, getBackgroundsStart);
const getBackgrounds = source.slice(getBackgroundsStart, getBackgroundsEnd);
assert.doesNotMatch(refresh, /renderSystemBackgrounds|loadFolders|preloadImageMetadata/);
assert.match(getBackgrounds, /refreshSystemBackgroundEntries/);
assert.match(getBackgrounds, /renderSystemBackgrounds/);
});

View file

@ -22,6 +22,9 @@ function createSettings(overrides = {}) {
enabled: false,
day_theme: 'Default',
night_theme: 'Dark',
wallpaper_enabled: false,
day_wallpaper: ' Day.png',
night_wallpaper: 'Night .png',
},
models: {
claude: {
@ -37,6 +40,9 @@ test('buildTauriTavernSettingsUpdate returns an empty patch for unchanged settin
nativeRegexBackendEnabled: true,
});
assert.equal(initial.dynamicTheme.dayWallpaper, ' Day.png');
assert.equal(initial.dynamicTheme.nightWallpaper, 'Night .png');
const update = buildTauriTavernSettingsUpdate(initial, {
...initial,
dynamicTheme: { ...initial.dynamicTheme },
@ -81,6 +87,34 @@ test('buildTauriTavernSettingsUpdate preserves minimal nested patch semantics',
});
});
test('buildTauriTavernSettingsUpdate persists dynamic wallpaper settings with theme settings', () => {
const initial = createTauriTavernSettingsState(createSettings(), {
nativeRegexBackendEnabled: true,
});
const update = buildTauriTavernSettingsUpdate(initial, {
...initial,
dynamicTheme: {
...initial.dynamicTheme,
wallpaperEnabled: true,
dayWallpaper: ' Soft Morning.png',
nightWallpaper: 'Deep Night .webp',
},
});
assert.equal(update.hasChanges, true);
assert.deepEqual(update.patch, {
dynamic_theme: {
enabled: false,
day_theme: 'Default',
night_theme: 'Dark',
wallpaper_enabled: true,
day_wallpaper: ' Soft Morning.png',
night_wallpaper: 'Deep Night .webp',
},
});
});
test('buildTauriTavernSettingsUpdate persists embedded runtime legacy migration', () => {
const initial = createTauriTavernSettingsState(createSettings({
embedded_runtime_profile: 'compat',

View file

@ -44,6 +44,13 @@ test('TauriTavern Settings popup is a host wrapper around the Vue bundle', async
assert.match(source, /applyTauriTavernSettingsUpdateEffects\(update,\s*updatedSettings\)/);
});
test('TauriTavern Settings wallpaper options use the no-render background refresh', async () => {
const source = await readRepoFile('src/scripts/tauri/setting/setting-panel/settings-popup.js');
assert.match(source, /refreshSystemBackgroundEntries/);
assert.doesNotMatch(source, /getBackgrounds/);
});
test('webpack exposes a dedicated TauriTavern Settings Vue entry', async () => {
const source = await readRepoFile('webpack.config.js');
@ -76,6 +83,10 @@ test('TauriTavern Settings Vue app stays presentation-only', async () => {
const entry = await readRepoFile('src/scripts/tauri/setting/settings-app/index.js');
assert.match(entry, /from\s+['"]vue\/dist\/vue\.esm-bundler\.js['"]/);
assert.match(entry, /export\s+function\s+mountTauriTavernSettingsApp/);
const app = await readRepoFile('src/scripts/tauri/setting/settings-app/SettingsApp.js');
assert.match(app, /Dynamic Theme & Wallpaper/);
assert.match(app, /WallpaperField/);
});
test('TauriTavern Settings keeps mobile toggle rows inline', async () => {