feat: Add desktop collapsed strip widgets

- Add new desktopStripWidgets settings in state.js with toggles for weather, clock, date, location, stats, and attributes
- Add strip widget container in template.html with animated clock face
- Add CSS styles for strip widgets with wider collapsed panel (5rem), vertical layout, and theme support
- Implement updateStripWidgets() in desktop.js to populate widgets from tracker data
- Wire up settings handlers in index.js for all strip widget toggles
- Call updateStripWidgets() on data updates in sillytavern.js integration
- Trigger widget update when panel is collapsed in layout.js

The strip widgets display compact stats/info in the collapsed panel strip on desktop, similar to mobile FAB widgets, eliminating the need to expand the panel to view basic data.
This commit is contained in:
tomt610
2026-01-13 00:08:00 +00:00
parent b18aaee0c0
commit 4644e0fd93
7 changed files with 763 additions and 11 deletions
+10
View File
@@ -84,6 +84,16 @@ export let extensionSettings = {
stats: { enabled: true, position: 5 }, // All stats as compact numbers
attributes: { enabled: true, position: 6 } // Compact RPG attributes display
},
// Desktop strip widget display options (shown in collapsed panel strip)
desktopStripWidgets: {
enabled: false, // Master toggle for strip widgets (disabled by default)
weatherIcon: { enabled: true }, // Weather emoji (☀️, 🌧️, etc.)
clock: { enabled: true }, // Current time display
date: { enabled: true }, // Date display
location: { enabled: true }, // Location name
stats: { enabled: true }, // All stats as compact numbers
attributes: { enabled: true } // Compact RPG attributes display
},
userStats: JSON.stringify({
stats: [
{ id: 'health', name: 'Health', value: 100 },
+14 -6
View File
@@ -45,6 +45,7 @@ import { getSafeThumbnailUrl } from '../../utils/avatars.js';
// UI
import { setFabLoadingState, updateFabWidgets } from '../ui/mobile.js';
import { updateStripWidgets } from '../ui/desktop.js';
// Chapter checkpoint
import { updateAllCheckpointIndicators } from '../ui/checkpointUI.js';
@@ -232,8 +233,9 @@ export async function onMessageReceived(data) {
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets with newly parsed data
// Update FAB widgets and strip widgets with newly parsed data
updateFabWidgets();
updateStripWidgets();
// Then update the DOM to reflect the cleaned message
// Using updateMessageBlock to perform macro substitutions + regex formatting
@@ -266,9 +268,10 @@ export async function onMessageReceived(data) {
if (extensionSettings.autoUpdate && isAwaitingNewMessage) {
setTimeout(async () => {
await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory);
// Update FAB widgets after separate/external mode update completes
// Update FAB widgets and strip widgets after separate/external mode update completes
setFabLoadingState(false);
updateFabWidgets();
updateStripWidgets();
}, 500);
}
}
@@ -294,6 +297,7 @@ export async function onMessageReceived(data) {
// Stop FAB loading state and update widgets
setFabLoadingState(false);
updateFabWidgets();
updateStripWidgets();
// Re-apply checkpoint in case SillyTavern unhid messages during generation
await restoreCheckpointOnLoad();
@@ -332,8 +336,9 @@ export function onCharacterChanged() {
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets with loaded data
// Update FAB widgets and strip widgets with loaded data
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays
updateChatThoughts();
@@ -501,8 +506,9 @@ export function onMessageDeleted(messageIndex) {
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets
// Update FAB widgets and strip widgets
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays (removes any remaining)
updateChatThoughts();
@@ -555,8 +561,9 @@ export function onMessageDeleted(messageIndex) {
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets
// Update FAB widgets and strip widgets
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays
updateChatThoughts();
@@ -591,8 +598,9 @@ export function onMessageDeleted(messageIndex) {
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets
// Update FAB widgets and strip widgets
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays
updateChatThoughts();
+265 -2
View File
@@ -1,10 +1,273 @@
/**
* Desktop UI Module
* Handles desktop-specific UI functionality: tab navigation
* Handles desktop-specific UI functionality: tab navigation and strip widgets
*/
import { i18n } from '../../core/i18n.js';
import { extensionSettings } from '../../core/state.js';
import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js';
/**
* Helper to parse time string and calculate clock hand angles
*/
function parseTimeForClock(timeStr) {
const timeMatch = timeStr.match(/(\d+):(\d+)/);
if (timeMatch) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute
const minuteAngle = minutes * 6; // 6° per minute
return { hourAngle, minuteAngle };
}
return { hourAngle: 0, minuteAngle: 0 };
}
/**
* Updates the desktop strip widgets display based on current tracker data and settings.
* Strip widgets are shown vertically in the collapsed panel strip.
*/
export function updateStripWidgets() {
const $panel = $('#rpg-companion-panel');
const $container = $('#rpg-strip-widget-container');
if ($panel.length === 0 || $container.length === 0) return;
// Check if strip widgets are enabled
const widgetSettings = extensionSettings.desktopStripWidgets;
if (!widgetSettings || !widgetSettings.enabled) {
$panel.removeClass('rpg-strip-widgets-enabled');
$container.find('.rpg-strip-widget').removeClass('rpg-strip-widget-visible');
return;
}
// Add enabled class to panel for CSS styling (wider collapsed width)
$panel.addClass('rpg-strip-widgets-enabled');
// Get tracker data - use imported state directly
const infoBox = lastGeneratedData?.infoBox || committedTrackerData?.infoBox;
// Parse infoBox if it's a string
let infoData = null;
if (infoBox) {
try {
infoData = typeof infoBox === 'string' ? JSON.parse(infoBox) : infoBox;
} catch (e) {
console.warn('[RPG Strip Widgets] Failed to parse infoBox:', e);
}
}
// Weather Icon Widget (with description)
const $weatherWidget = $container.find('.rpg-strip-widget-weather');
if (widgetSettings.weatherIcon?.enabled && infoData?.weather?.emoji) {
$weatherWidget.find('.rpg-strip-widget-icon').text(infoData.weather.emoji);
// Show weather description truncated
const forecast = infoData.weather.forecast || '';
const displayForecast = forecast.length > 12 ? forecast.substring(0, 10) + '…' : forecast;
$weatherWidget.find('.rpg-strip-widget-desc').text(displayForecast);
$weatherWidget.attr('title', forecast || 'Weather');
$weatherWidget.addClass('rpg-strip-widget-visible');
} else {
$weatherWidget.removeClass('rpg-strip-widget-visible');
}
// Clock Widget with animated face
const $clockWidget = $container.find('.rpg-strip-widget-clock');
if (widgetSettings.clock?.enabled && infoData?.time) {
const timeStr = infoData.time.end || infoData.time.value || infoData.time.start || '';
if (timeStr) {
// Update clock hands
const { hourAngle, minuteAngle } = parseTimeForClock(timeStr);
$clockWidget.find('.rpg-strip-clock-hour').css('transform', `rotate(${hourAngle}deg)`);
$clockWidget.find('.rpg-strip-clock-minute').css('transform', `rotate(${minuteAngle}deg)`);
$clockWidget.find('.rpg-strip-widget-value').text(timeStr);
$clockWidget.attr('title', `Time: ${timeStr}`);
$clockWidget.addClass('rpg-strip-widget-visible');
} else {
$clockWidget.removeClass('rpg-strip-widget-visible');
}
} else {
$clockWidget.removeClass('rpg-strip-widget-visible');
}
// Date Widget
const $dateWidget = $container.find('.rpg-strip-widget-date');
if (widgetSettings.date?.enabled && infoData?.date?.value) {
const dateVal = infoData.date.value;
// Truncate long dates for display
const displayDate = dateVal.length > 20 ? dateVal.substring(0, 18) + '…' : dateVal;
$dateWidget.find('.rpg-strip-widget-value').text(displayDate);
$dateWidget.attr('title', dateVal);
$dateWidget.addClass('rpg-strip-widget-visible');
} else {
$dateWidget.removeClass('rpg-strip-widget-visible');
}
// Location Widget
const $locationWidget = $container.find('.rpg-strip-widget-location');
if (widgetSettings.location?.enabled && infoData?.location?.value) {
const loc = infoData.location.value;
// Truncate long locations for display
const displayLoc = loc.length > 15 ? loc.substring(0, 13) + '…' : loc;
$locationWidget.find('.rpg-strip-widget-value').text(displayLoc);
$locationWidget.attr('title', loc);
$locationWidget.addClass('rpg-strip-widget-visible');
} else {
$locationWidget.removeClass('rpg-strip-widget-visible');
}
// Stats Widget - get from lastGeneratedData or committedTrackerData first, fallback to extensionSettings
const $statsWidget = $container.find('.rpg-strip-widget-stats');
if (widgetSettings.stats?.enabled) {
let allStats = [];
// Try to get stats from tracker data first (most current)
const userStatsData = lastGeneratedData?.userStats || committedTrackerData?.userStats;
if (userStatsData) {
try {
const parsedStats = typeof userStatsData === 'string' ? JSON.parse(userStatsData) : userStatsData;
if (parsedStats?.stats) {
allStats = parsedStats.stats;
}
} catch (e) {
console.warn('[RPG Strip Widgets] Failed to parse tracker userStats:', e);
}
}
// Fallback to extensionSettings.userStats
if (allStats.length === 0 && extensionSettings.userStats) {
try {
const userStatsJson = extensionSettings.userStats;
const parsedUserStats = typeof userStatsJson === 'string' ? JSON.parse(userStatsJson) : userStatsJson;
if (parsedUserStats?.stats) {
allStats = parsedUserStats.stats;
}
} catch (e) {
console.warn('[RPG Strip Widgets] Failed to parse extensionSettings.userStats:', e);
}
}
if (allStats.length > 0) {
// Get enabled stats from trackerConfig
const configuredStats = extensionSettings.trackerConfig?.userStats?.customStats || [];
const enabledStatMap = new Map();
configuredStats.forEach(s => {
if (s.enabled !== false) {
enabledStatMap.set(s.id?.toLowerCase(), true);
enabledStatMap.set(s.name?.toLowerCase(), true);
}
});
const $statsList = $statsWidget.find('.rpg-strip-stats-list');
$statsList.empty();
allStats.forEach(stat => {
// Filter by config if available - but if no config, show all
if (configuredStats.length > 0) {
const statId = stat.id?.toLowerCase();
const statName = stat.name?.toLowerCase();
if (!enabledStatMap.has(statId) && !enabledStatMap.has(statName)) return;
}
const value = typeof stat.value === 'number' ? stat.value : parseInt(stat.value) || 0;
const color = getStatColor(value);
const abbr = stat.name.substring(0, 3).toUpperCase();
const $item = $(`<div class="rpg-strip-stat-item" title="${stat.name}: ${value}">
<span class="rpg-strip-stat-name">${abbr}</span>
<span class="rpg-strip-stat-value" style="color: ${color};">${value}</span>
</div>`);
$statsList.append($item);
});
if ($statsList.children().length > 0) {
$statsWidget.addClass('rpg-strip-widget-visible');
} else {
$statsWidget.removeClass('rpg-strip-widget-visible');
}
} else {
$statsWidget.removeClass('rpg-strip-widget-visible');
}
} else {
$statsWidget.removeClass('rpg-strip-widget-visible');
}
// Attributes Widget
const $attrsWidget = $container.find('.rpg-strip-widget-attributes');
if (widgetSettings.attributes?.enabled) {
const showRPGAttributes = extensionSettings.trackerConfig?.userStats?.showRPGAttributes !== false;
if (showRPGAttributes && extensionSettings.classicStats) {
// Get enabled attributes from trackerConfig
const configuredAttrs = extensionSettings.trackerConfig?.userStats?.rpgAttributes || [];
const enabledAttrIds = configuredAttrs.filter(a => a.enabled !== false).map(a => a.id);
const attrs = extensionSettings.classicStats;
const $attrsGrid = $attrsWidget.find('.rpg-strip-attributes-grid');
$attrsGrid.empty();
Object.entries(attrs).forEach(([key, value]) => {
// Filter by config if available
if (enabledAttrIds.length > 0 && !enabledAttrIds.includes(key.toLowerCase())) {
return;
}
const $item = $(`<div class="rpg-strip-attr-item" title="${key.toUpperCase()}: ${value}">
<span class="rpg-strip-attr-name">${key.toUpperCase()}</span>
<span class="rpg-strip-attr-value">${value}</span>
</div>`);
$attrsGrid.append($item);
});
if ($attrsGrid.children().length > 0) {
$attrsWidget.addClass('rpg-strip-widget-visible');
} else {
$attrsWidget.removeClass('rpg-strip-widget-visible');
}
} else {
$attrsWidget.removeClass('rpg-strip-widget-visible');
}
} else {
$attrsWidget.removeClass('rpg-strip-widget-visible');
}
}
/**
* Gets a color interpolated between low and high based on stat value (0-100).
* @param {number} value - The stat value (0-100)
* @returns {string} CSS color value
*/
function getStatColor(value) {
const lowColor = extensionSettings.statBarColorLow || '#cc3333';
const highColor = extensionSettings.statBarColorHigh || '#33cc66';
// Simple linear interpolation between low and high colors
const percent = Math.min(100, Math.max(0, value)) / 100;
// Parse colors
const lowRGB = hexToRgb(lowColor);
const highRGB = hexToRgb(highColor);
if (!lowRGB || !highRGB) return value > 50 ? highColor : lowColor;
const r = Math.round(lowRGB.r + (highRGB.r - lowRGB.r) * percent);
const g = Math.round(lowRGB.g + (highRGB.g - lowRGB.g) * percent);
const b = Math.round(lowRGB.b + (highRGB.b - lowRGB.b) * percent);
return `rgb(${r}, ${g}, ${b})`;
}
/**
* Converts a hex color to RGB object.
* @param {string} hex - Hex color string (e.g., "#cc3333")
* @returns {{r: number, g: number, b: number}|null}
*/
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
/**
* Sets up desktop tab navigation for organizing content.
+4 -1
View File
@@ -19,7 +19,7 @@ import {
} from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
import { setupMobileTabs, removeMobileTabs } from './mobile.js';
import { setupDesktopTabs, removeDesktopTabs } from './desktop.js';
import { setupDesktopTabs, removeDesktopTabs, updateStripWidgets } from './desktop.js';
/**
* Toggles the visibility of plot buttons based on settings.
@@ -243,6 +243,9 @@ export function setupCollapseToggle() {
} else if ($panel.hasClass('rpg-position-left')) {
$icon.removeClass('fa-chevron-left').addClass('fa-chevron-right');
}
// Update strip widgets when collapsing (they show in collapsed state)
updateStripWidgets();
}
});