This commit is contained in:
2025-12-21 06:55:49 +01:00
parent 8b80ad87c6
commit 896173815c
21 changed files with 4767 additions and 1 deletions

852
extension/ui.js Normal file
View File

@@ -0,0 +1,852 @@
// 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();
}
});