mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 07:35:14 +00:00
feat(settings): add dynamic wallpaper switching to dynamic themes
This commit is contained in:
parent
3c3bf6599e
commit
451a0e7897
18 changed files with 708 additions and 71 deletions
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
57
src/tauri/main/adapters/st/appearance.js
Normal file
57
src/tauri/main/adapters/st/appearance.js
Normal 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);
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
67
tests/dynamic-appearance-contract.test.mjs
Normal file
67
tests/dynamic-appearance-contract.test.mjs
Normal 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/);
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue