// ui.js - Interface utilisateur pour le contrôle RGB du clavier ASUS import St from 'gi://St'; import Clutter from 'gi://Clutter'; import GObject from 'gi://GObject'; import Gio from 'gi://Gio'; import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; import * as Slider from 'resource:///org/gnome/shell/ui/slider.js'; import * as Backend from './backend.js'; /** * Indicateur du panneau pour le contrôle RGB du clavier */ export const KeyboardRGBIndicator = GObject.registerClass( class KeyboardRGBIndicator extends PanelMenu.Button { _init(settings) { super._init(0.0, 'ASUS Keyboard RGB'); this._settings = settings; this._maxBrightness = Backend.getMaxBrightness(); // Charger les valeurs depuis GSettings this._currentR = this._settings.get_int('red'); this._currentG = this._settings.get_int('green'); this._currentB = this._settings.get_int('blue'); this._currentMasterGain = this._settings.get_int('master-gain'); this._currentBrightnessLevel = this._settings.get_int('brightness-level'); // Mode d'affichage (roue chromatique par défaut) this._colorPickerMode = 'wheel'; // 'sliders' ou 'wheel' // Créer l'icône dans la top bar this._icon = new St.Icon({ icon_name: 'keyboard-brightness-symbolic', style_class: 'system-status-icon' }); this.add_child(this._icon); // Vérifier le support matériel et les permissions if (!Backend.checkHardwareSupport()) { this._buildErrorUI('Matériel non supporté', 'Aucun clavier ASUS RGB détecté sur ce système.'); return; } if (!Backend.checkPermissions()) { this._buildErrorUI('Permissions insuffisantes', 'Impossible d\'accéder au rétroéclairage.\nVeuillez configurer les règles udev.\n\nVoir: docs/INSTALL.md'); return; } // Construire l'UI normale this._buildUI(); // Restaurer l'état au démarrage this._applyCurrentState(); } /** * Construit une UI d'erreur simplifiée */ _buildErrorUI(title, message) { const errorItem = new PopupMenu.PopupMenuItem(''); const box = new St.BoxLayout({ vertical: true, style_class: 'error-box', x_expand: true }); const titleLabel = new St.Label({ text: `⚠️ ${title}`, style_class: 'error-title' }); const messageLabel = new St.Label({ text: message, style_class: 'error-message' }); box.add_child(titleLabel); box.add_child(messageLabel); errorItem.actor.add_child(box); this.menu.addMenuItem(errorItem); } /** * Construit l'interface utilisateur complète */ _buildUI() { // Section: Header avec titre et bouton de bascule this._buildHeader(); // Section: Boutons d'intensité (compact) this._buildBrightnessButtons(); // Section: Color picker (sliders ou roue chromatique) this._colorPickerContainer = new PopupMenu.PopupBaseMenuItem({ reactive: false, can_focus: false }); this.menu.addMenuItem(this._colorPickerContainer); // Construire le mode par défaut this._rebuildColorPicker(); // Section: Presets (compact) this._buildPresets(); // Section: Synchronisation thème GNOME this._buildSyncThemeOption(); } /** * Construit le header avec titre et bouton de bascule */ _buildHeader() { const headerItem = new PopupMenu.PopupBaseMenuItem({ reactive: false, can_focus: false, style_class: 'rgb-header' }); const headerBox = new St.BoxLayout({ vertical: false, x_expand: true, style: 'spacing: 8px;' }); // Bouton de bascule slider/roue const toggleButton = new St.Button({ style_class: 'button', x_expand: false, style: 'padding: 4px 8px; min-width: 32px;' }); const toggleIcon = new St.Icon({ icon_name: this._colorPickerMode === 'sliders' ? 'view-grid-symbolic' : 'view-list-symbolic', icon_size: 16 }); toggleButton.set_child(toggleIcon); toggleButton.connect('clicked', () => { this._colorPickerMode = this._colorPickerMode === 'sliders' ? 'wheel' : 'sliders'; toggleIcon.icon_name = this._colorPickerMode === 'sliders' ? 'view-grid-symbolic' : 'view-list-symbolic'; this._rebuildColorPicker(); }); // Titre const titleLabel = new St.Label({ text: 'Clavier RGB', style: 'font-weight: bold;', x_expand: true, y_align: Clutter.ActorAlign.CENTER }); // Aperçu de couleur (déplacé dans le header) this._colorPreview = new St.Bin({ style_class: 'color-preview', style: 'width: 40px; height: 24px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.3);' }); headerBox.add_child(toggleButton); headerBox.add_child(titleLabel); headerBox.add_child(this._colorPreview); headerItem.actor.add_child(headerBox); this.menu.addMenuItem(headerItem); // Initialiser l'aperçu this._updateColorPreview(); } /** * Reconstruit le color picker selon le mode actuel */ _rebuildColorPicker() { // Vider le container this._colorPickerContainer.actor.remove_all_children(); if (this._colorPickerMode === 'sliders') { this._buildRGBSliders(); } else { this._buildColorWheel(); } } /** * Construit les 4 boutons d'intensité (version compacte) */ _buildBrightnessButtons() { const buttonItem = new PopupMenu.PopupBaseMenuItem({ reactive: false, can_focus: false }); const container = new St.BoxLayout({ vertical: false, x_expand: true, style: 'spacing: 8px;' }); // Label compact const intensityLabel = new St.Label({ text: 'Intensité :', style: 'font-weight: bold; font-size: 0.9em;', y_align: Clutter.ActorAlign.CENTER }); container.add_child(intensityLabel); // Boutons compacts const buttonBox = new St.BoxLayout({ vertical: false, x_expand: true, style: 'spacing: 6px;' }); const buttonLabels = ['OFF', '1', '2', '3']; this._brightnessButtons = []; for (let i = 0; i < 4; i++) { const button = new St.Button({ label: buttonLabels[i], style_class: 'brightness-button', x_expand: true, can_focus: true, style: 'padding: 4px 8px; font-size: 0.9em;' }); button.level = i; button.connect('clicked', () => this._onBrightnessButtonClicked(i)); this._brightnessButtons.push(button); buttonBox.add_child(button); } container.add_child(buttonBox); buttonItem.actor.add_child(container); this.menu.addMenuItem(buttonItem); // Mettre à jour l'état actif this._updateBrightnessButtons(); } /** * Met à jour le style des boutons brightness (active/inactive) */ _updateBrightnessButtons() { this._brightnessButtons.forEach((button, index) => { if (index === this._currentBrightnessLevel) { button.add_style_class_name('active'); } else { button.remove_style_class_name('active'); } }); } /** * Callback: clic sur un bouton brightness */ _onBrightnessButtonClicked(level) { this._currentBrightnessLevel = level; this._settings.set_int('brightness-level', level); Backend.writeBrightness(level); this._updateBrightnessButtons(); this._updateInfoLine(); // Si brightness > 0, réappliquer RGB if (level > 0) { Backend.writeRGB(this._currentR, this._currentG, this._currentB, this._currentMasterGain); } } /** * Construit les sliders RGB + Master (version compacte) */ _buildRGBSliders() { const container = new St.BoxLayout({ vertical: true, x_expand: true, style: 'spacing: 4px; padding: 4px;' }); // Sliders RGB compacts const sliders = [ { label: 'R', color: '#ff4444', prop: '_currentR', key: 'red', max: 255 }, { label: 'V', color: '#44ff44', prop: '_currentG', key: 'green', max: 255 }, { label: 'B', color: '#4444ff', prop: '_currentB', key: 'blue', max: 255 }, { label: 'M', color: '#ffffff', prop: '_currentMasterGain', key: 'master-gain', max: 100 } ]; sliders.forEach(({ label, color, prop, key, max }) => { const sliderBox = new St.BoxLayout({ vertical: false, x_expand: true, style: 'spacing: 8px;' }); const sliderLabel = new St.Label({ text: label, style: `min-width: 12px; color: ${color}; font-weight: bold; font-size: 0.9em;`, y_align: Clutter.ActorAlign.CENTER }); const slider = new Slider.Slider(this[prop] / max); slider.accessible_name = label; slider.connect('notify::value', () => { const newValue = Math.floor(slider.value * max); this[prop] = newValue; this._settings.set_int(key, newValue); this._onRGBChanged(); }); // Stocker la référence if (label === 'R') this._redSlider = slider; else if (label === 'V') this._greenSlider = slider; else if (label === 'B') this._blueSlider = slider; else if (label === 'M') this._masterSlider = slider; sliderBox.add_child(sliderLabel); sliderBox.add_child(slider); container.add_child(sliderBox); }); this._colorPickerContainer.actor.add_child(container); } /** * Construit la roue chromatique (grille de couleurs cliquables) */ _buildColorWheel() { const container = new St.BoxLayout({ vertical: true, x_expand: true, style: 'spacing: 8px; padding: 8px;' }); // Grille de couleurs en forme de cercle (optimisée pour la visibilité) const gridBox = new St.BoxLayout({ vertical: true, style: 'spacing: 3px;' }); const size = 12; // Grille 12x12 (réduit pour boutons plus grands) const cellSize = 18; // Augmenté de 12px à 18px const centerX = size / 2; const centerY = size / 2; const maxRadius = size / 2 - 1; // Stocker les boutons de la roue pour la surbrillance this._wheelButtons = []; for (let y = 0; y < size; y++) { const row = new St.BoxLayout({ vertical: false, style: 'spacing: 3px;' }); for (let x = 0; x < size; x++) { const dx = x - centerX; const dy = y - centerY; const distance = Math.sqrt(dx * dx + dy * dy); if (distance <= maxRadius) { // Calculer teinte et saturation const angle = Math.atan2(dy, dx); const hue = ((angle * 180 / Math.PI) + 360) % 360; const saturation = distance / maxRadius; // Convertir HSL vers RGB const rgb = this._hslToRgb(hue, saturation, 0.5); const hex = Backend.rgbToHex(rgb.r, rgb.g, rgb.b); const button = new St.Button({ style: ` background-color: ${hex}; width: ${cellSize}px; height: ${cellSize}px; border-radius: 3px; padding: 0; min-width: ${cellSize}px; min-height: ${cellSize}px; border: 1px solid rgba(0,0,0,0.3); `, reactive: true, track_hover: true }); // Stocker les infos de couleur pour comparaison button._rgb = { r: rgb.r, g: rgb.g, b: rgb.b }; button._baseStyle = ` background-color: ${hex}; width: ${cellSize}px; height: ${cellSize}px; border-radius: 3px; padding: 0; min-width: ${cellSize}px; min-height: ${cellSize}px; border: 1px solid rgba(0,0,0,0.3); `; button.connect('clicked', () => { this._currentR = rgb.r; this._currentG = rgb.g; this._currentB = rgb.b; this._settings.set_int('red', this._currentR); this._settings.set_int('green', this._currentG); this._settings.set_int('blue', this._currentB); // Mettre à jour la surbrillance this._updateWheelSelection(); this._onRGBChanged(); }); this._wheelButtons.push(button); row.add_child(button); } else { // Espace vide en dehors du cercle const spacer = new St.Bin({ style: `width: ${cellSize}px; height: ${cellSize}px;` }); row.add_child(spacer); } } gridBox.add_child(row); } container.add_child(gridBox); // Slider Master en dessous const masterBox = new St.BoxLayout({ vertical: false, x_expand: true, style: 'spacing: 8px; margin-top: 8px;' }); const masterLabel = new St.Label({ text: 'Luminosité', style: 'font-weight: bold; font-size: 0.9em; min-width: 60px;', y_align: Clutter.ActorAlign.CENTER }); const masterSlider = new Slider.Slider(this._currentMasterGain / 100); masterSlider.accessible_name = 'Master'; masterSlider.connect('notify::value', () => { this._currentMasterGain = Math.floor(masterSlider.value * 100); this._settings.set_int('master-gain', this._currentMasterGain); this._onRGBChanged(); }); this._masterSlider = masterSlider; masterBox.add_child(masterLabel); masterBox.add_child(masterSlider); container.add_child(masterBox); this._colorPickerContainer.actor.add_child(container); // Initialiser la surbrillance de la couleur actuelle this._updateWheelSelection(); } /** * Met à jour la surbrillance de la couleur sélectionnée dans la roue */ _updateWheelSelection() { if (!this._wheelButtons) return; this._wheelButtons.forEach(button => { const rgb = button._rgb; // Comparer avec une tolérance (les couleurs HSL peuvent varier légèrement) const tolerance = 10; const matches = Math.abs(rgb.r - this._currentR) <= tolerance && Math.abs(rgb.g - this._currentG) <= tolerance && Math.abs(rgb.b - this._currentB) <= tolerance; if (matches) { // Bordure blanche épaisse pour la sélection button.style = button._baseStyle.replace( 'border: 1px solid rgba(0,0,0,0.3)', 'border: 3px solid white; box-shadow: 0 0 5px rgba(255,255,255,0.8)' ); } else { // Style normal button.style = button._baseStyle; } }); } /** * Met à jour la surbrillance du preset sélectionné */ _updatePresetSelection() { if (!this._presetButtons) return; this._presetButtons.forEach(button => { const preset = button._preset; // Comparer avec une tolérance const tolerance = 10; const matches = Math.abs(preset.r - this._currentR) <= tolerance && Math.abs(preset.g - this._currentG) <= tolerance && Math.abs(preset.b - this._currentB) <= tolerance; if (matches) { // Cercle blanc épais autour du preset sélectionné button.style = button._baseStyle + ' border: 3px solid white; box-shadow: 0 0 5px rgba(255,255,255,0.8);'; } else { // Style normal button.style = button._baseStyle + ' border: 2px solid rgba(255,255,255,0.3);'; } }); } /** * Trouve la couleur accent GNOME la plus proche d'une couleur RGB */ _rgbToGnomeAccent(r, g, b) { const colors = { blue: { r: 53, g: 132, b: 228 }, // Bleu GNOME teal: { r: 51, g: 209, b: 122 }, // Turquoise green: { r: 87, g: 227, b: 137 }, // Vert yellow: { r: 246, g: 211, b: 45 }, // Jaune orange: { r: 255, g: 120, b: 0 }, // Orange red: { r: 237, g: 51, b: 59 }, // Rouge pink: { r: 246, g: 97, b: 81 }, // Rose purple: { r: 145, g: 65, b: 172 }, // Violet slate: { r: 119, g: 118, b: 123 } // Gris ardoise }; let minDistance = Infinity; let closestColor = 'blue'; // Calculer la distance euclidienne dans l'espace RGB for (const [name, color] of Object.entries(colors)) { const distance = Math.sqrt( Math.pow(r - color.r, 2) + Math.pow(g - color.g, 2) + Math.pow(b - color.b, 2) ); if (distance < minDistance) { minDistance = distance; closestColor = name; } } return closestColor; } /** * Synchronise la couleur du clavier avec le thème GNOME */ _syncGnomeTheme() { // Vérifier si la synchronisation est activée if (!this._settings.get_boolean('sync-gnome-theme')) { return; } // Trouver la couleur accent GNOME la plus proche const accentColor = this._rgbToGnomeAccent( this._currentR, this._currentG, this._currentB ); try { // Appliquer la couleur d'accentuation GNOME const interfaceSettings = new Gio.Settings({ schema: 'org.gnome.desktop.interface' }); interfaceSettings.set_string('accent-color', accentColor); log(`[ASUS RGB] Thème GNOME synchronisé → ${accentColor} (RGB: ${this._currentR}, ${this._currentG}, ${this._currentB})`); } catch (error) { logError(error, '[ASUS RGB] Erreur lors de la synchronisation du thème GNOME'); } } /** * Callback: RGB ou Master a changé */ _onRGBChanged() { // Mettre à jour l'aperçu de couleur this._updateColorPreview(); // Mettre à jour les surbrillances this._updateWheelSelection(); this._updatePresetSelection(); // Synchroniser avec le thème GNOME si activé this._syncGnomeTheme(); // Appliquer avec debouncing Backend.writeRGBDebounced( this._currentR, this._currentG, this._currentB, this._currentMasterGain, () => this._updateInfoLine() ); } /** * Convertit RGB (0-255) vers HSL (H: 0-360, S: 0-1, L: 0-1) */ _rgbToHsl(r, g, b) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); let h, s, l = (max + min) / 2; if (max === min) { h = s = 0; // Gris (pas de saturation) } else { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; case g: h = ((b - r) / d + 2) / 6; break; case b: h = ((r - g) / d + 4) / 6; break; } } return { h: h * 360, s: s, l: l }; } /** * Convertit HSL (H: 0-360, S: 0-1, L: 0-1) vers RGB (0-255) */ _hslToRgb(h, s, l) { h /= 360; let r, g, b; if (s === 0) { r = g = b = l; // Gris } else { const hue2rgb = (p, q, t) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1/6) return p + (q - p) * 6 * t; if (t < 1/2) return q; if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; return p; }; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; r = hue2rgb(p, q, h + 1/3); g = hue2rgb(p, q, h); b = hue2rgb(p, q, h - 1/3); } return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }; } /** * Met à jour l'aperçu de couleur (version HSL avec correction gamma) */ _updateColorPreview() { if (this._colorPreview) { // Convertir RGB vers HSL const hsl = this._rgbToHsl(this._currentR, this._currentG, this._currentB); // Appliquer correction gamma pour progression perceptuelle naturelle // Gamma 2.2 est la norme pour les écrans sRGB const GAMMA = 2.2; const gain = this._currentMasterGain / 100.0; // Linéariser la luminosité, appliquer le gain, puis re-gamma const linearL = Math.pow(hsl.l, GAMMA); const adjustedLinearL = linearL * gain; hsl.l = Math.pow(adjustedLinearL, 1 / GAMMA); // Reconvertir HSL vers RGB const rgb = this._hslToRgb(hsl.h, hsl.s, hsl.l); const hex = Backend.rgbToHex(rgb.r, rgb.g, rgb.b); this._colorPreview.style = `background-color: ${hex}; width: 50px; height: 30px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.3);`; } } /** * Construit la ligne d'information */ /** * Met à jour la ligne d'information (vide - infos dans l'aperçu) */ _updateInfoLine() { // Les informations sont maintenant affichées via l'aperçu de couleur dans le header } /** * Construit les 6 presets couleur */ /** * Construit les presets de couleurs (version compacte) */ _buildPresets() { const presetItem = new PopupMenu.PopupBaseMenuItem({ reactive: false, can_focus: false }); const container = new St.BoxLayout({ vertical: false, x_expand: true, style: 'spacing: 6px;' }); const presetLabel = new St.Label({ text: 'Presets :', style: 'font-weight: bold; font-size: 0.9em;', y_align: Clutter.ActorAlign.CENTER }); container.add_child(presetLabel); // Créer 9 boutons preset compacts (couleurs GNOME) - ronds const buttonBox = new St.BoxLayout({ vertical: false, x_expand: true, style: 'spacing: 3px;' }); // Stocker les boutons pour la mise à jour de la sélection this._presetButtons = []; for (let i = 1; i <= 9; i++) { const presetString = this._settings.get_string(`preset-${i}`); const preset = Backend.parsePreset(presetString); if (preset) { const button = new St.Button({ style_class: 'button', x_expand: true, can_focus: true }); const hex = Backend.rgbToHex(preset.r, preset.g, preset.b); // Ronds avec border-radius 50% const baseStyle = `background-color: ${hex}; width: 26px; height: 26px; border-radius: 50%; padding: 0;`; button.style = baseStyle + ' border: 2px solid rgba(255,255,255,0.3);'; // Stocker les infos pour la mise à jour button._preset = preset; button._baseStyle = baseStyle; button.connect('clicked', () => this._onPresetClicked(preset)); buttonBox.add_child(button); this._presetButtons.push(button); } } container.add_child(buttonBox); presetItem.actor.add_child(container); this.menu.addMenuItem(presetItem); // Initialiser la surbrillance du preset actuel this._updatePresetSelection(); } /** * Construit l'option de synchronisation avec le thème GNOME */ _buildSyncThemeOption() { const syncItem = new PopupMenu.PopupSwitchMenuItem( 'Synchroniser thème GNOME', this._settings.get_boolean('sync-gnome-theme') ); syncItem.connect('toggled', (item) => { this._settings.set_boolean('sync-gnome-theme', item.state); // Si activé, appliquer immédiatement if (item.state) { this._syncGnomeTheme(); } else { // Si désactivé, restaurer la couleur GNOME par défaut (blue) try { const interfaceSettings = new Gio.Settings({ schema: 'org.gnome.desktop.interface' }); interfaceSettings.set_string('accent-color', 'blue'); log('[ASUS RGB] Thème GNOME restauré → blue (défaut)'); } catch (error) { logError(error, '[ASUS RGB] Erreur lors de la restauration du thème GNOME'); } } }); this.menu.addMenuItem(syncItem); } /** * Callback: clic sur un preset */ _onPresetClicked(preset) { this._currentR = preset.r; this._currentG = preset.g; this._currentB = preset.b; // Sauvegarder this._settings.set_int('red', this._currentR); this._settings.set_int('green', this._currentG); this._settings.set_int('blue', this._currentB); // Mettre à jour les sliders si en mode sliders if (this._redSlider) { this._redSlider.value = this._currentR / 255; this._greenSlider.value = this._currentG / 255; this._blueSlider.value = this._currentB / 255; } // Appliquer via _onRGBChanged qui gère tout (aperçu, surbrillances, sync thème, etc.) this._onRGBChanged(); } /** * Applique l'état actuel au matériel (au démarrage) */ _applyCurrentState() { Backend.writeBrightness(this._currentBrightnessLevel); if (this._currentBrightnessLevel > 0) { Backend.writeRGB(this._currentR, this._currentG, this._currentB, this._currentMasterGain); } } /** * Nettoyage lors de la destruction */ destroy() { Backend.cleanup(); super.destroy(); } });