Add dynamic tray icon with usage display and comprehensive theming system

Implemented several major enhancements to the Claude Usage Widget:

Features Added:
- Dynamic tray icon with circular progress pie chart showing usage percentage
- Configurable tray display mode (Session/Weekly) and refresh interval (10-300s, default 30s)
- Optional percentage text overlay in tray icon (default: off)
- Independent color customization for tray icon (normal/warning/danger states)
- Static color mode toggle for both main window and tray icon (single color vs gradients)
- Comprehensive theming system for main widget and settings window:
  * Background gradient customization
  * Primary and secondary text color controls
  * Title bar color with opacity slider
  * Border color with opacity slider
- Collapsible settings sections with smooth animations (minimized by default)
- Custom dark-themed scrollbar with purple accents matching app theme
- Real-time theme synchronization between main and settings windows

Technical Implementation:
- Zero-dependency tray icon generation using Electron's built-in Canvas API via hidden BrowserWindow
- IPC-based bi-directional communication for real-time theme updates
- Persistent settings storage using electron-store
- Conditional UI rendering for static vs gradient color modes
- All changes maintain upstream compatibility (no external npm dependencies added)

Files Modified:
- main.js: Added icon generator window, tray icon generation, theme IPC handlers
- preload.js: Added IPC methods for tray settings and theme management
- src/renderer/app.js: Added theme application and usage data forwarding
- src/renderer/settings.html: Added theming controls, collapsible sections, toggles
- src/renderer/settings.css: Added styles for new UI components and custom scrollbar
- src/renderer/settings.js: Added theme/tray settings logic with real-time updates

Files Added:
- src/icon-generator.html: Hidden window for canvas-based tray icon generation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude 2025-12-24 16:09:31 -05:00
parent 4717458b82
commit c4c758fbbd
7 changed files with 1271 additions and 48 deletions

187
main.js
View file

@ -12,6 +12,12 @@ let loginWindow = null;
let silentLoginWindow = null;
let settingsWindow = null;
let tray = null;
let iconGeneratorWindow = null;
// Tray icon state
let cachedUsageData = null;
let trayUpdateInterval = null;
let lastTrayUpdate = 0;
// Window configuration
const WIDGET_WIDTH = 480;
@ -323,6 +329,117 @@ async function attemptSilentLogin() {
});
}
// Create hidden window for generating tray icons
function createIconGeneratorWindow() {
if (iconGeneratorWindow) return;
iconGeneratorWindow = new BrowserWindow({
width: 100,
height: 100,
show: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});
iconGeneratorWindow.loadFile(path.join(__dirname, 'src/icon-generator.html'));
iconGeneratorWindow.on('closed', () => {
iconGeneratorWindow = null;
});
}
// Generate tray icon with circular progress indicator
async function generateTrayIcon(percentage, colors, showText = true) {
const { nativeImage } = require('electron');
if (!iconGeneratorWindow) {
createIconGeneratorWindow();
// Wait for window to load
await new Promise(resolve => setTimeout(resolve, 1000));
}
return new Promise((resolve) => {
ipcMain.once('icon-generated', (event, dataUrl) => {
resolve(nativeImage.createFromDataURL(dataUrl));
});
iconGeneratorWindow.webContents.send('generate-icon', { percentage, colors, showText });
});
}
// Update tray icon with current usage data
async function updateTrayIcon() {
if (!tray) return;
// If no data yet, show default icon
if (!cachedUsageData) {
tray.setImage(path.join(__dirname, 'assets/tray-icon.png'));
tray.setToolTip('Claude Usage Widget - Loading...');
return;
}
// Get tray settings from store
const traySettings = store.get('traySettings', {
displayMode: 'session',
showText: false,
colors: {
normal: { start: '#8b5cf6', end: '#a78bfa' },
warning: { start: '#f59e0b', end: '#fbbf24' },
danger: { start: '#ef4444', end: '#f87171' }
}
});
// Get appropriate usage data based on display mode
let percentage, resetsAt;
if (traySettings.displayMode === 'weekly') {
percentage = cachedUsageData.seven_day?.utilization || 0;
resetsAt = cachedUsageData.seven_day?.resets_at;
} else {
percentage = cachedUsageData.five_hour?.utilization || 0;
resetsAt = cachedUsageData.five_hour?.resets_at;
}
// Generate and set new icon
try {
const icon = await generateTrayIcon(percentage, traySettings.colors, traySettings.showText);
tray.setImage(icon);
// Update tooltip with detailed info
const modeLabel = traySettings.displayMode === 'weekly' ? '7-day' : '5-hour';
const resetTime = resetsAt ? new Date(resetsAt).toLocaleString() : 'N/A';
tray.setToolTip(
`Claude Usage: ${Math.round(percentage)}% (${modeLabel})\nResets: ${resetTime}`
);
} catch (error) {
console.error('Failed to generate tray icon:', error);
}
}
// Throttled tray update to avoid excessive updates
function updateTrayIconThrottled() {
const now = Date.now();
const interval = store.get('trayUpdateInterval', 30) * 1000; // Convert to ms
if (now - lastTrayUpdate >= interval) {
updateTrayIcon();
lastTrayUpdate = now;
}
}
// Start periodic tray icon updates
function startTrayUpdateTimer() {
if (trayUpdateInterval) clearInterval(trayUpdateInterval);
const interval = store.get('trayUpdateInterval', 30) * 1000;
trayUpdateInterval = setInterval(() => {
if (cachedUsageData) {
updateTrayIcon();
}
}, interval);
}
function createTray() {
try {
tray = new Tray(path.join(__dirname, 'assets/tray-icon.png'));
@ -559,10 +676,71 @@ ipcMain.handle('fetch-usage-data', async () => {
}
});
// Tray icon IPC handlers
ipcMain.on('usage-data-update', (event, data) => {
cachedUsageData = data;
updateTrayIconThrottled();
});
ipcMain.handle('get-tray-settings', () => {
return store.get('traySettings', {
displayMode: 'session',
showText: false,
colors: {
normal: { start: '#8b5cf6', end: '#a78bfa' },
warning: { start: '#f59e0b', end: '#fbbf24' },
danger: { start: '#ef4444', end: '#f87171' }
}
});
});
ipcMain.on('set-tray-settings', (event, settings) => {
store.set('traySettings', settings);
updateTrayIcon(); // Immediate update
});
ipcMain.handle('get-tray-update-interval', () => {
return store.get('trayUpdateInterval', 30);
});
ipcMain.on('set-tray-update-interval', (event, seconds) => {
store.set('trayUpdateInterval', seconds);
startTrayUpdateTimer(); // Restart timer with new interval
});
// Theme settings IPC handlers
ipcMain.handle('get-theme-settings', () => {
return store.get('themeSettings', {
backgroundStart: '#1e1e2e',
backgroundEnd: '#2a2a3e',
textPrimary: '#e0e0e0',
textSecondary: '#a0a0a0',
titleBarBg: '#000000',
titleBarOpacity: 30,
borderColor: '#ffffff',
borderOpacity: 10
});
});
ipcMain.handle('set-theme-settings', (event, theme) => {
store.set('themeSettings', theme);
return true;
});
ipcMain.handle('notify-theme-change', (event, theme) => {
// Notify main window to update theme
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('theme-changed', theme);
}
return true;
});
// App lifecycle
app.whenReady().then(() => {
createMainWindow();
createTray();
createIconGeneratorWindow();
startTrayUpdateTimer();
// Check if we have credentials
// const hasCredentials = store.get('sessionKey') && store.get('organizationId');
@ -573,6 +751,15 @@ app.whenReady().then(() => {
// }
});
app.on('before-quit', () => {
if (trayUpdateInterval) {
clearInterval(trayUpdateInterval);
}
if (iconGeneratorWindow && !iconGeneratorWindow.isDestroyed()) {
iconGeneratorWindow.destroy();
}
});
app.on('window-all-closed', () => {
// Don't quit on macOS
if (process.platform !== 'darwin') {

View file

@ -45,5 +45,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
notifyColorChange: (preferences) => ipcRenderer.invoke('notify-color-change', preferences),
onColorsChanged: (callback) => {
ipcRenderer.on('colors-changed', (event, preferences) => callback(preferences));
},
// Tray icon
sendUsageToMain: (data) => ipcRenderer.send('usage-data-update', data),
getTraySettings: () => ipcRenderer.invoke('get-tray-settings'),
setTraySettings: (settings) => ipcRenderer.send('set-tray-settings', settings),
getTrayUpdateInterval: () => ipcRenderer.invoke('get-tray-update-interval'),
setTrayUpdateInterval: (seconds) => ipcRenderer.send('set-tray-update-interval', seconds),
// Theme settings
getThemeSettings: () => ipcRenderer.invoke('get-theme-settings'),
setThemeSettings: (theme) => ipcRenderer.invoke('set-theme-settings', theme),
notifyThemeChange: (theme) => ipcRenderer.invoke('notify-theme-change', theme),
onThemeChanged: (callback) => {
ipcRenderer.on('theme-changed', (event, theme) => callback(theme));
}
});

86
src/icon-generator.html Normal file
View file

@ -0,0 +1,86 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Icon Generator</title>
</head>
<body>
<canvas id="iconCanvas" width="128" height="128"></canvas>
<script>
const { ipcRenderer } = require('electron');
const canvas = document.getElementById('iconCanvas');
const ctx = canvas.getContext('2d');
ipcRenderer.on('generate-icon', (event, { percentage, colors, showText }) => {
// Clear canvas
ctx.clearRect(0, 0, 128, 128);
const centerX = 64;
const centerY = 64;
const radius = 58;
// Determine colors based on percentage
let startColor, endColor;
if (percentage >= 90) {
startColor = colors.danger.start;
endColor = colors.danger.end;
} else if (percentage >= 75) {
startColor = colors.warning.start;
endColor = colors.warning.end;
} else {
startColor = colors.normal.start;
endColor = colors.normal.end;
}
// Draw background circle (dark gray)
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.fillStyle = '#3a3a3a';
ctx.fill();
// Draw progress pie (filled wedge)
const startAngle = -Math.PI / 2; // Start at top (12 o'clock)
const endAngle = startAngle + (percentage / 100) * Math.PI * 2;
if (percentage > 0) {
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.arc(centerX, centerY, radius, startAngle, endAngle);
ctx.closePath();
// Create gradient for progress pie
const gradient = ctx.createLinearGradient(0, 0, 128, 128);
gradient.addColorStop(0, startColor);
gradient.addColorStop(1, endColor);
ctx.fillStyle = gradient;
ctx.fill();
}
// Draw subtle border around the circle
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
ctx.lineWidth = 2;
ctx.stroke();
// Draw percentage text (if enabled)
if (showText) {
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 32px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
ctx.shadowBlur = 4;
ctx.shadowOffsetX = 1;
ctx.shadowOffsetY = 1;
const text = Math.round(percentage).toString() + '%';
ctx.fillText(text, centerX, centerY);
}
// Send back as data URL
ipcRenderer.send('icon-generated', canvas.toDataURL());
});
</script>
</body>
</html>

View file

@ -39,6 +39,10 @@ async function init() {
const colorPrefs = await window.electronAPI.getColorPreferences();
applyColorPreferences(colorPrefs);
// Load and apply theme settings
const themeSettings = await window.electronAPI.getThemeSettings();
applyThemePreferences(themeSettings);
if (credentials.sessionKey && credentials.organizationId) {
showMainContent();
await fetchUsageData();
@ -114,6 +118,12 @@ function setupEventListeners() {
console.log('Colors changed, applying new preferences');
applyColorPreferences(preferences);
});
// Listen for theme changes from settings window
window.electronAPI.onThemeChanged((theme) => {
console.log('Theme changed, applying new theme');
applyThemePreferences(theme);
});
}
// Fetch usage data from Claude API
@ -159,6 +169,11 @@ function hasNoUsage(data) {
function updateUI(data) {
latestUsageData = data;
// Send usage data to main process for tray icon
if (window.electronAPI && window.electronAPI.sendUsageToMain) {
window.electronAPI.sendUsageToMain(data);
}
// Check if there's no usage data
if (hasNoUsage(data)) {
showNoUsage();
@ -389,6 +404,36 @@ function applyColorPreferences(prefs) {
root.style.setProperty('--color-danger-end', prefs.danger.end);
}
// Theme preference management
function applyThemePreferences(theme) {
const root = document.documentElement;
const widgetContainer = document.querySelector('.widget-container');
const titleBar = document.querySelector('.title-bar');
// Apply background gradient
if (widgetContainer) {
widgetContainer.style.background = `linear-gradient(135deg, ${theme.backgroundStart} 0%, ${theme.backgroundEnd} 100%)`;
}
// Apply text colors
root.style.setProperty('--text-primary', theme.textPrimary);
root.style.setProperty('--text-secondary', theme.textSecondary);
// Apply title bar background
if (titleBar) {
const opacity = theme.titleBarOpacity / 100;
const bgColor = theme.titleBarBg;
titleBar.style.background = `rgba(${parseInt(bgColor.slice(1, 3), 16)}, ${parseInt(bgColor.slice(3, 5), 16)}, ${parseInt(bgColor.slice(5, 7), 16)}, ${opacity})`;
}
// Apply border color
if (widgetContainer) {
const borderOpacity = theme.borderOpacity / 100;
const borderColor = theme.borderColor;
widgetContainer.style.borderColor = `rgba(${parseInt(borderColor.slice(1, 3), 16)}, ${parseInt(borderColor.slice(3, 5), 16)}, ${parseInt(borderColor.slice(5, 7), 16)}, ${borderOpacity})`;
}
}
// Auto-update management
function startAutoUpdate() {
stopAutoUpdate();

View file

@ -86,6 +86,8 @@ body {
margin: 0 auto;
padding: 24px;
flex: 1;
overflow-y: auto;
max-height: calc(100vh - 36px); /* Account for title bar height */
}
.settings-section {
@ -228,3 +230,309 @@ body {
.disclaimer {
margin-top: 16px;
}
/* Setting Groups */
.setting-group {
margin-bottom: 25px;
}
.setting-group h3 {
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
color: #e5e7eb;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.setting-description {
color: #9ca3af;
font-size: 12px;
margin-bottom: 12px;
line-height: 1.4;
}
/* Radio Buttons */
.radio-group {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
}
.radio-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: all 0.2s;
background: rgba(255, 255, 255, 0.03);
}
.radio-item:hover {
background: rgba(139, 92, 246, 0.1);
border-color: rgba(139, 92, 246, 0.3);
}
.radio-item input[type="radio"] {
width: 18px;
height: 18px;
cursor: pointer;
margin: 0;
flex-shrink: 0;
accent-color: #8b5cf6;
}
.radio-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.radio-label {
font-weight: 500;
color: #e5e7eb;
font-size: 13px;
}
.radio-sublabel {
font-size: 11px;
color: #9ca3af;
}
/* Slider */
.slider-container {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
}
.slider-container input[type="range"] {
width: 100%;
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.1);
outline: none;
-webkit-appearance: none;
}
.slider-container input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #8b5cf6;
cursor: pointer;
transition: background 0.2s;
}
.slider-container input[type="range"]::-webkit-slider-thumb:hover {
background: #a78bfa;
}
.slider-container input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #8b5cf6;
cursor: pointer;
border: none;
transition: background 0.2s;
}
.slider-container input[type="range"]::-moz-range-thumb:hover {
background: #a78bfa;
}
.slider-value {
text-align: center;
font-size: 14px;
color: #e5e7eb;
}
.slider-value span {
font-weight: 600;
color: #8b5cf6;
font-size: 16px;
}
/* Toggle Switch */
.toggle-switch {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
}
.toggle-switch input[type="checkbox"] {
display: none;
}
.toggle-slider {
position: relative;
width: 48px;
height: 24px;
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
transition: background 0.3s;
flex-shrink: 0;
}
.toggle-slider::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
background: #9ca3af;
top: 2px;
left: 2px;
transition: all 0.3s;
}
.toggle-switch input[type="checkbox"]:checked + .toggle-slider {
background: rgba(139, 92, 246, 0.3);
}
.toggle-switch input[type="checkbox"]:checked + .toggle-slider::before {
background: #8b5cf6;
transform: translateX(24px);
}
.toggle-label {
font-size: 13px;
color: #9ca3af;
transition: color 0.3s;
}
.toggle-switch input[type="checkbox"]:checked ~ .toggle-label {
color: #8b5cf6;
}
/* Custom Scrollbar */
.settings-container::-webkit-scrollbar {
width: 8px;
}
.settings-container::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.settings-container::-webkit-scrollbar-thumb {
background: rgba(139, 92, 246, 0.3);
border-radius: 4px;
transition: background 0.3s;
}
.settings-container::-webkit-scrollbar-thumb:hover {
background: rgba(139, 92, 246, 0.5);
}
/* Collapsible Sections */
.collapsible-section {
margin-bottom: 16px;
}
.collapsible-header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
color: #e5e7eb;
font-size: 13px;
font-weight: 600;
}
.collapsible-header:hover {
background: rgba(139, 92, 246, 0.1);
border-color: rgba(139, 92, 246, 0.3);
}
.collapsible-header span {
text-transform: uppercase;
letter-spacing: 0.5px;
}
.collapsible-header .chevron {
transition: transform 0.3s ease;
color: #9ca3af;
}
.collapsible-header.active .chevron {
transform: rotate(180deg);
}
.collapsible-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
padding: 0 16px;
}
.collapsible-content.active {
max-height: 1000px;
padding: 16px;
padding-top: 16px;
}
/* Theme Grid */
.theme-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 16px;
}
.theme-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.theme-item label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #a0a0a0;
text-align: center;
}
.theme-item input[type="color"] {
width: 80px;
height: 50px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
background: transparent;
cursor: pointer;
padding: 4px;
transition: all 0.2s ease;
}
.theme-item input[type="color"]:hover {
border-color: rgba(255, 255, 255, 0.4);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.theme-item input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
.theme-item input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 6px;
}

View file

@ -27,43 +27,249 @@
<div class="settings-container">
<!-- Color Customization -->
<!-- Main Window Section -->
<section class="settings-section">
<h2>Customize Colors</h2>
<h2>Main Window</h2>
<div class="color-grid-2col">
<div class="color-item">
<label>Normal Start</label>
<input type="color" id="colorNormalStart" value="#8b5cf6">
</div>
<!-- Collapsible Color Customization -->
<div class="collapsible-section">
<button class="collapsible-header" id="mainWindowColorsHeader">
<span>Colors</span>
<svg class="chevron" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="collapsible-content" id="mainWindowColorsContent">
<!-- Static Color Toggle -->
<label class="toggle-switch" style="margin-bottom: 16px;">
<input type="checkbox" id="mainWindowStaticColor">
<span class="toggle-slider"></span>
<span class="toggle-label">Use Static Color</span>
</label>
<div class="color-item">
<label>Normal End</label>
<input type="color" id="colorNormalEnd" value="#a78bfa">
</div>
<!-- Static Color Picker (shown when toggle is ON) -->
<div id="mainWindowStaticColorPicker" style="display: none; margin-bottom: 16px;">
<div class="color-item" style="width: 100%;">
<label>Progress Bar Color</label>
<input type="color" id="mainWindowStaticColorValue" value="#8b5cf6">
</div>
</div>
<div class="color-item">
<label>Warning Start</label>
<input type="color" id="colorWarningStart" value="#f59e0b">
</div>
<!-- Gradient Color Pickers (shown when toggle is OFF) -->
<div class="color-grid-2col" id="mainWindowGradientPickers">
<div class="color-item">
<label>Normal Start</label>
<input type="color" id="colorNormalStart" value="#8b5cf6">
</div>
<div class="color-item">
<label>Warning End</label>
<input type="color" id="colorWarningEnd" value="#fbbf24">
</div>
<div class="color-item">
<label>Normal End</label>
<input type="color" id="colorNormalEnd" value="#a78bfa">
</div>
<div class="color-item">
<label>Danger Start</label>
<input type="color" id="colorDangerStart" value="#ef4444">
</div>
<div class="color-item">
<label>Warning Start</label>
<input type="color" id="colorWarningStart" value="#f59e0b">
</div>
<div class="color-item">
<label>Danger End</label>
<input type="color" id="colorDangerEnd" value="#f87171">
<div class="color-item">
<label>Warning End</label>
<input type="color" id="colorWarningEnd" value="#fbbf24">
</div>
<div class="color-item">
<label>Danger Start</label>
<input type="color" id="colorDangerStart" value="#ef4444">
</div>
<div class="color-item">
<label>Danger End</label>
<input type="color" id="colorDangerEnd" value="#f87171">
</div>
</div>
<button class="btn-secondary" id="resetColorsBtn">Reset to Defaults</button>
</div>
</div>
<button class="btn-secondary" id="resetColorsBtn">Reset to Defaults</button>
<!-- Theme Settings -->
<div class="collapsible-section">
<button class="collapsible-header" id="mainWindowThemeHeader">
<span>Theme</span>
<svg class="chevron" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="collapsible-content" id="mainWindowThemeContent">
<p class="setting-description">Customize the widget's overall appearance</p>
<div class="theme-grid">
<!-- Background Gradient -->
<div class="theme-item">
<label>Background Start</label>
<input type="color" id="backgroundColorStart" value="#1e1e2e">
</div>
<div class="theme-item">
<label>Background End</label>
<input type="color" id="backgroundColorEnd" value="#2a2a3e">
</div>
<!-- Text Colors -->
<div class="theme-item">
<label>Primary Text</label>
<input type="color" id="textColorPrimary" value="#e0e0e0">
</div>
<div class="theme-item">
<label>Secondary Text</label>
<input type="color" id="textColorSecondary" value="#a0a0a0">
</div>
<!-- UI Elements -->
<div class="theme-item">
<label>Title Bar Background</label>
<input type="color" id="titleBarBackground" value="#000000">
<input type="range" id="titleBarOpacity" min="0" max="100" value="30" style="width: 100%; margin-top: 4px;">
<div style="text-align: center; font-size: 10px; color: #9ca3af; margin-top: 2px;">
Opacity: <span id="titleBarOpacityValue">30</span>%
</div>
</div>
<div class="theme-item">
<label>Border Color</label>
<input type="color" id="borderColor" value="#ffffff">
<input type="range" id="borderOpacity" min="0" max="100" value="10" style="width: 100%; margin-top: 4px;">
<div style="text-align: center; font-size: 10px; color: #9ca3af; margin-top: 2px;">
Opacity: <span id="borderOpacityValue">10</span>%
</div>
</div>
</div>
<button class="btn-secondary" id="resetThemeBtn">Reset Theme</button>
</div>
</div>
</section>
<div class="divider"></div>
<!-- Tray Icon Settings -->
<section class="settings-section">
<h2>Tray Icon</h2>
<!-- Display Mode -->
<div class="setting-group">
<h3>Display Mode</h3>
<p class="setting-description">Choose which usage metric to display in the system tray</p>
<div class="radio-group">
<label class="radio-item">
<input type="radio" name="trayDisplay" value="session" id="trayDisplaySession" checked>
<div class="radio-content">
<span class="radio-label">Session Usage</span>
<span class="radio-sublabel">5-hour limit</span>
</div>
</label>
<label class="radio-item">
<input type="radio" name="trayDisplay" value="weekly" id="trayDisplayWeekly">
<div class="radio-content">
<span class="radio-label">Weekly Usage</span>
<span class="radio-sublabel">7-day limit</span>
</div>
</label>
</div>
</div>
<!-- Update Interval -->
<div class="setting-group">
<h3>Update Interval</h3>
<p class="setting-description">How often to refresh the tray icon (in seconds)</p>
<div class="slider-container">
<input type="range" id="trayUpdateInterval" min="10" max="300" value="30" step="10">
<div class="slider-value">
<span id="trayUpdateValue">30</span> seconds
</div>
</div>
</div>
<!-- Show Text Toggle -->
<div class="setting-group">
<h3>Show Percentage Text</h3>
<p class="setting-description">Display percentage number on the tray icon</p>
<label class="toggle-switch">
<input type="checkbox" id="trayShowText" checked>
<span class="toggle-slider"></span>
<span class="toggle-label" id="trayShowTextLabel">Enabled</span>
</label>
</div>
<!-- Tray Icon Colors -->
<div class="setting-group">
<div class="collapsible-section">
<button class="collapsible-header" id="trayColorsHeader">
<span>Colors</span>
<svg class="chevron" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="collapsible-content" id="trayColorsContent">
<p class="setting-description">Customize colors for the tray icon progress indicator</p>
<!-- Static Color Toggle -->
<label class="toggle-switch" style="margin-bottom: 16px;">
<input type="checkbox" id="trayStaticColor">
<span class="toggle-slider"></span>
<span class="toggle-label">Use Static Color</span>
</label>
<!-- Static Color Picker (shown when toggle is ON) -->
<div id="trayStaticColorPicker" style="display: none; margin-bottom: 16px;">
<div class="color-item" style="width: 100%;">
<label>Tray Icon Color</label>
<input type="color" id="trayStaticColorValue" value="#8b5cf6">
</div>
</div>
<!-- Gradient Color Pickers (shown when toggle is OFF) -->
<div class="color-grid-2col" id="trayGradientPickers">
<div class="color-item">
<label>Normal Start</label>
<input type="color" id="trayColorNormalStart" value="#8b5cf6">
</div>
<div class="color-item">
<label>Normal End</label>
<input type="color" id="trayColorNormalEnd" value="#a78bfa">
</div>
<div class="color-item">
<label>Warning Start</label>
<input type="color" id="trayColorWarningStart" value="#f59e0b">
</div>
<div class="color-item">
<label>Warning End</label>
<input type="color" id="trayColorWarningEnd" value="#fbbf24">
</div>
<div class="color-item">
<label>Danger Start</label>
<input type="color" id="trayColorDangerStart" value="#ef4444">
</div>
<div class="color-item">
<label>Danger End</label>
<input type="color" id="trayColorDangerEnd" value="#f87171">
</div>
</div>
<button class="btn-secondary" id="resetTrayColorsBtn">Reset Tray Colors</button>
</div>
</div>
</div>
</section>
<div class="divider"></div>

View file

@ -10,16 +10,72 @@ const elements = {
logoutBtn: document.getElementById('logoutBtn'),
coffeeBtn: document.getElementById('coffeeBtn'),
minimizeBtn: document.getElementById('minimizeBtn'),
closeBtn: document.getElementById('closeBtn')
closeBtn: document.getElementById('closeBtn'),
// Tray icon elements
trayDisplaySession: document.getElementById('trayDisplaySession'),
trayDisplayWeekly: document.getElementById('trayDisplayWeekly'),
trayUpdateInterval: document.getElementById('trayUpdateInterval'),
trayUpdateValue: document.getElementById('trayUpdateValue'),
trayShowText: document.getElementById('trayShowText'),
trayShowTextLabel: document.getElementById('trayShowTextLabel'),
trayColorNormalStart: document.getElementById('trayColorNormalStart'),
trayColorNormalEnd: document.getElementById('trayColorNormalEnd'),
trayColorWarningStart: document.getElementById('trayColorWarningStart'),
trayColorWarningEnd: document.getElementById('trayColorWarningEnd'),
trayColorDangerStart: document.getElementById('trayColorDangerStart'),
trayColorDangerEnd: document.getElementById('trayColorDangerEnd'),
resetTrayColorsBtn: document.getElementById('resetTrayColorsBtn'),
// Static color toggles
mainWindowStaticColor: document.getElementById('mainWindowStaticColor'),
mainWindowStaticColorPicker: document.getElementById('mainWindowStaticColorPicker'),
mainWindowStaticColorValue: document.getElementById('mainWindowStaticColorValue'),
mainWindowGradientPickers: document.getElementById('mainWindowGradientPickers'),
trayStaticColor: document.getElementById('trayStaticColor'),
trayStaticColorPicker: document.getElementById('trayStaticColorPicker'),
trayStaticColorValue: document.getElementById('trayStaticColorValue'),
trayGradientPickers: document.getElementById('trayGradientPickers'),
// Theme elements
backgroundColorStart: document.getElementById('backgroundColorStart'),
backgroundColorEnd: document.getElementById('backgroundColorEnd'),
textColorPrimary: document.getElementById('textColorPrimary'),
textColorSecondary: document.getElementById('textColorSecondary'),
titleBarBackground: document.getElementById('titleBarBackground'),
titleBarOpacity: document.getElementById('titleBarOpacity'),
titleBarOpacityValue: document.getElementById('titleBarOpacityValue'),
borderColor: document.getElementById('borderColor'),
borderOpacity: document.getElementById('borderOpacity'),
borderOpacityValue: document.getElementById('borderOpacityValue'),
resetThemeBtn: document.getElementById('resetThemeBtn')
};
// Initialize
async function init() {
setupEventListeners();
setupCollapsibleSections();
// Load and apply color preferences
const colorPrefs = await window.electronAPI.getColorPreferences();
loadColorPickerValues(colorPrefs);
// Load and apply tray settings
const traySettings = await window.electronAPI.getTraySettings();
loadTraySettings(traySettings);
// Load tray update interval
const interval = await window.electronAPI.getTrayUpdateInterval();
loadTrayUpdateInterval(interval);
// Load theme settings
await loadThemeSettings();
// Apply theme to settings window
const theme = await window.electronAPI.getThemeSettings();
applyThemeToSettings(theme);
// Listen for theme changes
window.electronAPI.onThemeChanged((theme) => {
applyThemeToSettings(theme);
});
}
// Event Listeners
@ -70,38 +126,358 @@ function setupEventListeners() {
elements.closeBtn.addEventListener('click', () => {
window.close();
});
// Tray icon event listeners
elements.trayDisplaySession.addEventListener('change', saveTraySettings);
elements.trayDisplayWeekly.addEventListener('change', saveTraySettings);
elements.trayShowText.addEventListener('change', () => {
elements.trayShowTextLabel.textContent = elements.trayShowText.checked ? 'Enabled' : 'Disabled';
saveTraySettings();
});
const trayColorInputs = [
elements.trayColorNormalStart,
elements.trayColorNormalEnd,
elements.trayColorWarningStart,
elements.trayColorWarningEnd,
elements.trayColorDangerStart,
elements.trayColorDangerEnd
];
trayColorInputs.forEach(input => {
input.addEventListener('change', saveTraySettings);
});
elements.trayUpdateInterval.addEventListener('input', () => {
elements.trayUpdateValue.textContent = elements.trayUpdateInterval.value;
});
elements.trayUpdateInterval.addEventListener('change', () => {
window.electronAPI.setTrayUpdateInterval(parseInt(elements.trayUpdateInterval.value));
});
elements.resetTrayColorsBtn.addEventListener('click', async () => {
const defaults = {
displayMode: elements.trayDisplaySession.checked ? 'session' : 'weekly',
showText: false,
colors: {
normal: { start: '#8b5cf6', end: '#a78bfa' },
warning: { start: '#f59e0b', end: '#fbbf24' },
danger: { start: '#ef4444', end: '#f87171' }
}
};
await window.electronAPI.setTraySettings(defaults);
loadTraySettings(defaults);
});
// Main Window static color toggle
elements.mainWindowStaticColor.addEventListener('change', () => {
const isStatic = elements.mainWindowStaticColor.checked;
elements.mainWindowStaticColorPicker.style.display = isStatic ? 'block' : 'none';
elements.mainWindowGradientPickers.style.display = isStatic ? 'none' : 'grid';
saveCurrentColors();
});
elements.mainWindowStaticColorValue.addEventListener('change', saveCurrentColors);
// Tray static color toggle
elements.trayStaticColor.addEventListener('change', () => {
const isStatic = elements.trayStaticColor.checked;
elements.trayStaticColorPicker.style.display = isStatic ? 'block' : 'none';
elements.trayGradientPickers.style.display = isStatic ? 'none' : 'grid';
saveTraySettings();
});
elements.trayStaticColorValue.addEventListener('change', saveTraySettings);
// Theme settings
const themeInputs = [
elements.backgroundColorStart,
elements.backgroundColorEnd,
elements.textColorPrimary,
elements.textColorSecondary,
elements.titleBarBackground,
elements.borderColor
];
themeInputs.forEach(input => {
input.addEventListener('change', saveThemeSettings);
});
// Opacity sliders
elements.titleBarOpacity.addEventListener('input', () => {
elements.titleBarOpacityValue.textContent = elements.titleBarOpacity.value;
});
elements.titleBarOpacity.addEventListener('change', saveThemeSettings);
elements.borderOpacity.addEventListener('input', () => {
elements.borderOpacityValue.textContent = elements.borderOpacity.value;
});
elements.borderOpacity.addEventListener('change', saveThemeSettings);
// Reset theme button
elements.resetThemeBtn.addEventListener('click', resetTheme);
}
// Helper functions
function loadColorPickerValues(prefs) {
elements.colorNormalStart.value = prefs.normal.start;
elements.colorNormalEnd.value = prefs.normal.end;
elements.colorWarningStart.value = prefs.warning.start;
elements.colorWarningEnd.value = prefs.warning.end;
elements.colorDangerStart.value = prefs.danger.start;
elements.colorDangerEnd.value = prefs.danger.end;
// Check if static mode
const isStatic = prefs.isStatic || false;
elements.mainWindowStaticColor.checked = isStatic;
if (isStatic) {
// Load static color
elements.mainWindowStaticColorValue.value = prefs.staticColor || '#8b5cf6';
elements.mainWindowStaticColorPicker.style.display = 'block';
elements.mainWindowGradientPickers.style.display = 'none';
} else {
// Load gradient colors
elements.colorNormalStart.value = prefs.normal.start;
elements.colorNormalEnd.value = prefs.normal.end;
elements.colorWarningStart.value = prefs.warning.start;
elements.colorWarningEnd.value = prefs.warning.end;
elements.colorDangerStart.value = prefs.danger.start;
elements.colorDangerEnd.value = prefs.danger.end;
elements.mainWindowStaticColorPicker.style.display = 'none';
elements.mainWindowGradientPickers.style.display = 'grid';
}
}
async function saveCurrentColors() {
const prefs = {
normal: {
start: elements.colorNormalStart.value,
end: elements.colorNormalEnd.value
},
warning: {
start: elements.colorWarningStart.value,
end: elements.colorWarningEnd.value
},
danger: {
start: elements.colorDangerStart.value,
end: elements.colorDangerEnd.value
}
};
const isStatic = elements.mainWindowStaticColor.checked;
let prefs;
if (isStatic) {
// Use static color for all states
const staticColor = elements.mainWindowStaticColorValue.value;
prefs = {
isStatic: true,
staticColor: staticColor,
normal: { start: staticColor, end: staticColor },
warning: { start: staticColor, end: staticColor },
danger: { start: staticColor, end: staticColor }
};
} else {
// Use gradient colors
prefs = {
isStatic: false,
normal: {
start: elements.colorNormalStart.value,
end: elements.colorNormalEnd.value
},
warning: {
start: elements.colorWarningStart.value,
end: elements.colorWarningEnd.value
},
danger: {
start: elements.colorDangerStart.value,
end: elements.colorDangerEnd.value
}
};
}
await window.electronAPI.setColorPreferences(prefs);
// Notify main window to update colors
await window.electronAPI.notifyColorChange(prefs);
}
// Tray settings functions
function loadTraySettings(settings) {
// Set display mode
if (settings.displayMode === 'weekly') {
elements.trayDisplayWeekly.checked = true;
} else {
elements.trayDisplaySession.checked = true;
}
// Set show text toggle
const showText = settings.showText !== undefined ? settings.showText : false;
elements.trayShowText.checked = showText;
elements.trayShowTextLabel.textContent = showText ? 'Enabled' : 'Disabled';
// Check if static mode
const isStatic = settings.isStatic || false;
elements.trayStaticColor.checked = isStatic;
if (isStatic) {
// Load static color
elements.trayStaticColorValue.value = settings.staticColor || '#8b5cf6';
elements.trayStaticColorPicker.style.display = 'block';
elements.trayGradientPickers.style.display = 'none';
} else {
// Load gradient colors
elements.trayColorNormalStart.value = settings.colors.normal.start;
elements.trayColorNormalEnd.value = settings.colors.normal.end;
elements.trayColorWarningStart.value = settings.colors.warning.start;
elements.trayColorWarningEnd.value = settings.colors.warning.end;
elements.trayColorDangerStart.value = settings.colors.danger.start;
elements.trayColorDangerEnd.value = settings.colors.danger.end;
elements.trayStaticColorPicker.style.display = 'none';
elements.trayGradientPickers.style.display = 'grid';
}
}
function loadTrayUpdateInterval(interval) {
elements.trayUpdateInterval.value = interval;
elements.trayUpdateValue.textContent = interval;
}
async function saveTraySettings() {
const isStatic = elements.trayStaticColor.checked;
let settings;
if (isStatic) {
// Use static color for all states
const staticColor = elements.trayStaticColorValue.value;
settings = {
displayMode: elements.trayDisplayWeekly.checked ? 'weekly' : 'session',
showText: elements.trayShowText.checked,
isStatic: true,
staticColor: staticColor,
colors: {
normal: { start: staticColor, end: staticColor },
warning: { start: staticColor, end: staticColor },
danger: { start: staticColor, end: staticColor }
}
};
} else {
// Use gradient colors
settings = {
displayMode: elements.trayDisplayWeekly.checked ? 'weekly' : 'session',
showText: elements.trayShowText.checked,
isStatic: false,
colors: {
normal: {
start: elements.trayColorNormalStart.value,
end: elements.trayColorNormalEnd.value
},
warning: {
start: elements.trayColorWarningStart.value,
end: elements.trayColorWarningEnd.value
},
danger: {
start: elements.trayColorDangerStart.value,
end: elements.trayColorDangerEnd.value
}
}
};
}
await window.electronAPI.setTraySettings(settings);
}
// Theme settings functions
async function loadThemeSettings() {
const theme = await window.electronAPI.getThemeSettings();
elements.backgroundColorStart.value = theme.backgroundStart || '#1e1e2e';
elements.backgroundColorEnd.value = theme.backgroundEnd || '#2a2a3e';
elements.textColorPrimary.value = theme.textPrimary || '#e0e0e0';
elements.textColorSecondary.value = theme.textSecondary || '#a0a0a0';
elements.titleBarBackground.value = theme.titleBarBg || '#000000';
elements.titleBarOpacity.value = theme.titleBarOpacity || 30;
elements.titleBarOpacityValue.textContent = elements.titleBarOpacity.value;
elements.borderColor.value = theme.borderColor || '#ffffff';
elements.borderOpacity.value = theme.borderOpacity || 10;
elements.borderOpacityValue.textContent = elements.borderOpacity.value;
}
async function saveThemeSettings() {
const theme = {
backgroundStart: elements.backgroundColorStart.value,
backgroundEnd: elements.backgroundColorEnd.value,
textPrimary: elements.textColorPrimary.value,
textSecondary: elements.textColorSecondary.value,
titleBarBg: elements.titleBarBackground.value,
titleBarOpacity: parseInt(elements.titleBarOpacity.value),
borderColor: elements.borderColor.value,
borderOpacity: parseInt(elements.borderOpacity.value)
};
await window.electronAPI.setThemeSettings(theme);
// Apply theme to settings window immediately
applyThemeToSettings(theme);
// Notify main window to update
await window.electronAPI.notifyThemeChange(theme);
}
async function resetTheme() {
const defaults = {
backgroundStart: '#1e1e2e',
backgroundEnd: '#2a2a3e',
textPrimary: '#e0e0e0',
textSecondary: '#a0a0a0',
titleBarBg: '#000000',
titleBarOpacity: 30,
borderColor: '#ffffff',
borderOpacity: 10
};
await window.electronAPI.setThemeSettings(defaults);
await loadThemeSettings();
// Apply theme to settings window immediately
applyThemeToSettings(defaults);
// Notify main window to update
await window.electronAPI.notifyThemeChange(defaults);
}
// Apply theme to settings window
function applyThemeToSettings(theme) {
const settingsApp = document.getElementById('settings-app');
const titleBar = document.querySelector('.title-bar');
// Apply background gradient
if (settingsApp) {
settingsApp.style.background = `linear-gradient(135deg, ${theme.backgroundStart} 0%, ${theme.backgroundEnd} 100%)`;
}
// Apply title bar background
if (titleBar) {
const opacity = theme.titleBarOpacity / 100;
const bgColor = theme.titleBarBg;
titleBar.style.background = `rgba(${parseInt(bgColor.slice(1, 3), 16)}, ${parseInt(bgColor.slice(3, 5), 16)}, ${parseInt(bgColor.slice(5, 7), 16)}, ${opacity})`;
}
// Apply text colors via CSS variables
const root = document.documentElement;
root.style.setProperty('--text-primary', theme.textPrimary);
root.style.setProperty('--text-secondary', theme.textSecondary);
// Update primary text color (titles, labels)
const primaryTextElements = document.querySelectorAll('.title span, h2, h3, .radio-label, .toggle-label, .setting-description');
primaryTextElements.forEach(el => {
if (el.classList.contains('setting-description')) {
// Secondary text for descriptions
el.style.color = theme.textSecondary;
} else {
// Primary text for titles/labels
el.style.color = theme.textPrimary;
}
});
}
// Setup collapsible sections
function setupCollapsibleSections() {
const collapsibleHeaders = document.querySelectorAll('.collapsible-header');
collapsibleHeaders.forEach(header => {
header.addEventListener('click', () => {
const content = header.nextElementSibling;
const isActive = header.classList.contains('active');
// Toggle active state
header.classList.toggle('active');
content.classList.toggle('active');
});
});
}
// Start the application
init();