This commit is contained in:
2025-12-24 22:52:46 +01:00
parent 4e1c06874c
commit 5ee00cc8c1
151 changed files with 3548 additions and 1 deletions

Binary file not shown.

View File

@@ -0,0 +1,9 @@
[Desktop Entry]
Name=SSH Launcher
Comment=Interface de connexion SSH et services
Exec=python3 /home/gilles/Documents/vscode/ssh-web-launcher/app/ssh-launcher-gtk.py
Icon=utilities-terminal
Type=Application
Terminal=false
Categories=Network;Utility;
Keywords=ssh;terminal;remote;connexion;

764
app/ssh-launcher-gtk.py Executable file
View File

@@ -0,0 +1,764 @@
#!/usr/bin/env python3
"""
Dashboard Launcher - Application GTK3 + WebKit2
Affiche une interface web pour lancer des connexions SSH et autres services.
"""
import os
import sys
import yaml
import gi
import subprocess
import re
import threading
gi.require_version('Gtk', '3.0')
gi.require_version('WebKit2', '4.1')
gi.require_version('Gdk', '3.0')
from gi.repository import Gtk, WebKit2, Gio, Gdk, GLib
import cairo
PID_FILE = '/tmp/dashboard-launcher.pid'
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_DIR = os.path.dirname(SCRIPT_DIR)
CONFIG_FILE = os.path.join(PROJECT_DIR, 'config', 'equipements.yaml')
ICONS_DIR = os.path.join(PROJECT_DIR, 'icons')
# Cache pour les résultats de ping
ping_results = {}
def ping_host(ip, timeout=1):
"""Ping une IP et retourne True si accessible."""
try:
# Nettoyer l'IP (enlever les doubles points, etc.)
clean_ip = ip.replace('..', '.')
result = subprocess.run(
['ping', '-c', '1', '-W', str(timeout), clean_ip],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=timeout + 1
)
return result.returncode == 0
except Exception:
return False
def ping_all_hosts(hosts, callback):
"""Ping toutes les IPs en parallèle et appelle callback avec les résultats."""
def ping_thread(ip):
result = ping_host(ip)
ping_results[ip] = result
threads = []
for ip in hosts:
t = threading.Thread(target=ping_thread, args=(ip,))
t.daemon = True
t.start()
threads.append(t)
def wait_and_callback():
for t in threads:
t.join(timeout=2)
GLib.idle_add(callback)
threading.Thread(target=wait_and_callback, daemon=True).start()
def load_config():
"""Charge la configuration depuis le fichier YAML."""
try:
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
return yaml.safe_load(f) or {}
except (FileNotFoundError, yaml.YAMLError) as e:
print(f"Erreur config: {e}")
return {}
def get_icon_html(icon_name, size=32):
"""Retourne le HTML pour une icône."""
if not icon_name:
return f'<span class="service-icon" style="font-size:{size}px">🔗</span>'
# Chemin vers image dans icons/
if icon_name.startswith('icons/'):
icon_path = os.path.join(PROJECT_DIR, icon_name)
if os.path.exists(icon_path):
return f'<img src="file://{icon_path}" class="service-img" style="width:{size}px;height:{size}px" alt="">'
# Emojis de fallback
icons = {
'utilities-terminal': '💻', 'terminal': '💻', 'web-browser': '🌐',
'applications-system': '⚙️', 'server': '🖥️', 'network-server': '🖧',
'preferences-desktop-remote-desktop': '🖥️', 'security-high': '🔒',
'go-home': '🏠', 'system-file-manager': '📁', 'firefox': '🦊',
'google-chrome': '🌐', 'code': '📝',
}
emoji = icons.get(icon_name, icon_name if len(icon_name) <= 2 else '🔗')
return f'<span class="service-icon" style="font-size:{size}px">{emoji}</span>'
def generate_html(config):
"""Génère le HTML avec layout: local en haut, distant à gauche, url à droite."""
apparence = config.get('apparence', {})
fenetre = config.get('fenetre', {})
theme = apparence.get('theme', 'dark')
font_size = apparence.get('police_taille', 14)
icon_distant = apparence.get('icon_taille_distant', 48)
icon_local = apparence.get('icon_taille_local', 64)
icon_url = apparence.get('icon_taille_url', 64)
icon_minitools = apparence.get('icon_taille_minitools', 32)
show_local_labels = apparence.get('afficher_label_local', True)
border_radius = apparence.get('border_radius', 12)
espacement_local = apparence.get('espacement_local', 10)
espacement_distant = apparence.get('espacement_distant', 6)
espacement_url = apparence.get('espacement_url', 8)
espacement_minitools = apparence.get('espacement_minitools', 8)
icon_fermer = apparence.get('icon_taille_fermer', 22)
icon_parametre = apparence.get('icon_taille_parametre', 22)
icon_theme_width = apparence.get('icon_taille_theme', 60)
icon_theme_height = int(icon_theme_width * 238 / 512) # Ratio de l'image night-day.png
border_radius_local = apparence.get('border_radius_local', 12)
# Colonnes configurables
col_url = fenetre.get('section_url', {}).get('colonne', 2)
distant = config.get('distant', [])
local = config.get('local', [])
urls = config.get('url', [])
# Section DISTANT avec status ping
distant_html = ""
for machine in distant:
ip = machine.get('ip', '')
nom = machine.get('nom', '')
# Vérifier le statut ping (vert si up, rouge si down, gris si inconnu)
is_up = ping_results.get(ip)
if is_up is None:
status_class = "status-unknown"
elif is_up:
status_class = "status-up"
else:
status_class = "status-down"
services_html = ""
for service in machine.get('services', []):
icon_html = get_icon_html(service.get('icon', ''), icon_distant)
services_html += f'''
<a href="{service.get('url', '')}" class="item" title="{nom} - {service.get('nom', '')}">
{icon_html}
<span class="label">{service.get('nom', '')}</span>
</a>'''
distant_html += f'''
<div class="ip-box">
<div class="ip-header"><span class="status-dot {status_class}" data-ip="{ip}"></span>{nom}</div>
<div class="ip-name">{ip}</div>
<div class="items">{services_html}</div>
</div>'''
# Section LOCAL
local_html = ""
for app in local:
icon_html = get_icon_html(app.get('icon', ''), icon_local)
label_html = f'<span class="label">{app.get("nom", "")}</span>' if show_local_labels else ''
local_html += f'''
<a href="app://run/{app.get('command', '')}" class="item item-local" title="{app.get('nom', '')}">
{icon_html}
{label_html}
</a>'''
minitools = config.get('minitools', [])
minitools_html = ""
for tool in minitools:
icon_html = get_icon_html(tool.get('icon', ''), icon_minitools)
label_html = f'<span class="label">{tool.get("nom", "")}</span>' if show_local_labels else ''
href = ''
if tool.get('command'):
href = f"app://run/{tool.get('command')}"
elif tool.get('url'):
href = tool.get('url')
if not href:
continue
minitools_html += f'''
<a href="{href}" class="item mini-item" title="{tool.get('nom', '')}">
{icon_html}
{label_html}
</a>'''
# Section URL
url_html = ""
for bookmark in urls:
icon_html = get_icon_html(bookmark.get('icon', ''), icon_url)
url_html += f'''
<a href="{bookmark.get('url', '')}" class="item item-url" title="{bookmark.get('nom', '')}">
{icon_html}
<span class="label">{bookmark.get('nom', '')}</span>
</a>'''
# Couleurs thème Adwaita - box = fond équipement (clair), item = fond service (foncé)
if theme == 'light':
c = {'bg': '#fafafa', 'container': '#ffffff', 'header': '#ebebeb', 'border': '#d0d0d0',
'text': '#2e2e2e', 'text2': '#5e5e5e', 'box': '#e8e8e8', 'item': '#f5f5f5',
'hover': '#888b8f', 'status': '#2ec27e'}
else:
c = {'bg': '#1e1e1e', 'container': '#242424', 'header': '#303030', 'border': '#1a1a1a',
'text': '#ffffff', 'text2': '#999999', 'box': '#3d3d3d', 'item': '#2d2d2d',
'hover': '#888b8f', 'status': '#2ec27e'}
# Surcharge avec les couleurs personnalisées si définies
if apparence.get('couleur_fond'):
c['container'] = apparence['couleur_fond']
if apparence.get('couleur_header'):
c['header'] = apparence['couleur_header']
else:
c['header'] = c['container'] # Par défaut, même couleur que le fond
if apparence.get('couleur_item'):
c['item'] = apparence['couleur_item']
if apparence.get('couleur_bordure'):
c['border'] = apparence['couleur_bordure']
if apparence.get('couleur_hover'):
c['hover'] = apparence['couleur_hover']
# Couleurs séparées pour chaque section (local, distant, url)
c['box_local'] = apparence.get('couleur_box_local') or c['box']
c['box_distant'] = apparence.get('couleur_box_distant') or c['box']
c['box_url'] = apparence.get('couleur_box_url') or c['box']
c['box_minitools'] = apparence.get('couleur_box_minitools') or c['box_local']
# Image night-day: 512x238 (ratio 2.15:1) - affichée entièrement comme toggle
html = f'''<!DOCTYPE html>
<html><head>
<meta charset="UTF-8">
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
html, body {{ height: 100%; overflow: hidden; background: transparent; }}
body {{
font-family: 'Cantarell', 'Segoe UI', sans-serif;
font-size: {font_size}px;
font-weight: 400;
background: transparent;
color: {c['text']};
}}
.container {{
height: 100%;
display: flex;
flex-direction: column;
background: {c['container']};
border-radius: {border_radius}px;
border: 1px solid {c['border']};
overflow: hidden;
}}
/* Header avec LOCAL intégré */
.header {{
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 8px 14px;
background: {c['header']};
min-height: 80px;
gap: 12px;
}}
.header-local {{
display: flex;
align-items: center;
gap: {espacement_local}px;
flex: 1;
padding: 8px 12px;
background: {c['box_local']};
border-radius: {border_radius_local}px;
}}
.header-controls {{
display: flex;
gap: 4px;
align-items: center;
flex-shrink: 0;
}}
.header-btn {{
width: 32px;
height: 32px;
background: transparent;
border: none;
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}}
.header-btn:hover {{
background: {c['hover']};
}}
.header-btn.btn-parametre img {{
width: {icon_parametre}px;
height: {icon_parametre}px;
object-fit: contain;
}}
.header-btn.btn-fermer img {{
width: {icon_fermer}px;
height: {icon_fermer}px;
object-fit: contain;
}}
.theme-btn {{
overflow: hidden;
width: {icon_theme_width}px;
height: {icon_theme_height}px;
padding: 0;
}}
.theme-btn img {{
width: 100%;
height: 100%;
object-fit: contain;
}}
.main {{
flex: 1;
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
padding: 12px;
overflow: hidden;
}}
/* DISTANT à gauche - flex wrap pour ajuster la taille des boxes */
.section-distant {{
display: flex;
flex-wrap: wrap;
gap: {espacement_distant}px;
align-content: start;
overflow-y: auto;
padding-right: 6px;
}}
/* URL à droite */
.section-url {{
display: grid;
grid-template-columns: repeat({col_url}, 1fr);
gap: {espacement_url}px;
padding: 12px;
background: {c['box_url']};
border-radius: 10px;
align-content: start;
min-width: 180px;
}}
/* Barre de mini outils */
.section-minitools {{
display: flex;
gap: {espacement_minitools}px;
padding: 10px 14px;
background: {c['box_minitools']};
border-top: 1px solid {c['border']};
flex-wrap: wrap;
align-items: center;
overflow-x: auto;
}}
.section-minitools .mini-item {{
flex-direction: row;
padding: 6px 10px;
min-width: 90px;
}}
.section-minitools .mini-item .label {{
font-size: {font_size - 2}px;
}}
/* IP box - taille ajustée au contenu */
.ip-box {{
background: {c['box_distant']};
border-radius: 8px;
padding: 10px;
flex-shrink: 0;
}}
.ip-header {{
display: flex;
align-items: center;
gap: 6px;
font-size: {font_size}px;
font-weight: 500;
color: {c['text']};
margin-bottom: 2px;
}}
.status-dot {{
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}}
.status-up {{
background: #2ec27e;
}}
.status-down {{
background: #e01b24;
}}
.status-unknown {{
background: #888888;
}}
.ip-name {{
font-size: {font_size - 2}px;
font-family: 'JetBrains Mono', 'Consolas', monospace;
color: {c['text2']};
margin-bottom: 8px;
margin-left: 14px;
}}
.items {{
display: flex;
flex-wrap: wrap;
gap: {espacement_distant}px;
}}
.item {{
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px;
background: {c['item']};
border-radius: 8px;
text-decoration: none;
color: {c['text']};
cursor: pointer;
transition: background 0.15s, transform 0.1s;
}}
.item:hover {{
background: {c['hover']};
transform: translateY(-2px);
color: #fff;
}}
.item-local {{
background: transparent;
padding: 8px 12px;
}}
.item-local:hover {{
background: {c['item']};
}}
.item-url {{
background: transparent;
padding: 10px;
}}
.item-url:hover {{
background: {c['item']};
}}
.service-icon {{
line-height: 1;
}}
.service-img {{
object-fit: contain;
}}
.label {{
font-size: {font_size - 2}px;
font-weight: 400;
text-align: center;
color: {c['text2']};
max-width: 70px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}}
.item:hover .label {{
color: #fff;
}}
/* Scrollbar style */
.section-distant::-webkit-scrollbar {{
width: 6px;
}}
.section-distant::-webkit-scrollbar-track {{
background: transparent;
}}
.section-distant::-webkit-scrollbar-thumb {{
background: {c['border']};
border-radius: 3px;
}}
.section-distant::-webkit-scrollbar-thumb:hover {{
background: {c['text2']};
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-local">{local_html}</div>
<div class="header-controls">
<button class="header-btn theme-btn" onclick="location.href='app://toggle-theme'" title="Thème">
<img src="file://{ICONS_DIR}/night-day.png" alt="">
</button>
<button class="header-btn btn-parametre" onclick="location.href='app://settings'" title="Paramètres">
<img src="file://{ICONS_DIR}/parametre.png" alt="">
</button>
<button class="header-btn btn-fermer" onclick="location.href='app://close'" title="Fermer">
<img src="file://{ICONS_DIR}/fermer.png" alt="">
</button>
</div>
</div>
<div class="main">
<div class="section-distant">{distant_html}</div>
<div class="section-url">{url_html}</div>
</div>
<div class="section-minitools">{minitools_html}</div>
</div>
</body></html>'''
return html
class DashboardLauncherWindow(Gtk.Window):
def __init__(self):
super().__init__(title="Dashboard Launcher")
self.config = load_config()
self.fenetre_config = self.config.get('fenetre', {})
# Activer la transparence pour éviter le flash blanc
screen = self.get_screen()
visual = screen.get_rgba_visual()
if visual:
self.set_visual(visual)
self.set_app_paintable(True)
self.connect('draw', self.on_draw)
self.set_type_hint(Gdk.WindowTypeHint.POPUP_MENU)
self.set_skip_taskbar_hint(True)
self.set_skip_pager_hint(True)
self.set_decorated(False) # Pas de décoration pour éviter le flash
self.set_resizable(False)
if self.fenetre_config.get('toujours_au_dessus', False):
self.set_keep_above(True)
largeur = self.fenetre_config.get('largeur', 800)
hauteur = self.fenetre_config.get('hauteur', 450)
self.set_default_size(largeur, hauteur)
display = Gdk.Display.get_default()
ecran_num = self.fenetre_config.get('ecran', -1)
if ecran_num is None or ecran_num < 0:
seat = display.get_default_seat()
pointer = seat.get_pointer()
_, mouse_x, mouse_y = pointer.get_position()
monitor = display.get_monitor_at_point(mouse_x, mouse_y)
else:
n_monitors = display.get_n_monitors()
monitor = display.get_monitor(ecran_num) if ecran_num < n_monitors else display.get_primary_monitor() or display.get_monitor(0)
geom = monitor.get_geometry()
centrer = self.fenetre_config.get('centrer', True)
x = self.fenetre_config.get('x', 0)
y = self.fenetre_config.get('y', 50)
if centrer:
self.move(geom.x + (geom.width - largeur) // 2, geom.y + y)
else:
self.move(geom.x + x, geom.y + y)
self.connect('key-press-event', self.on_key_press)
settings = WebKit2.Settings()
settings.set_property('hardware-acceleration-policy', WebKit2.HardwareAccelerationPolicy.NEVER)
self.webview = WebKit2.WebView()
self.webview.set_settings(settings)
self.webview.connect('decide-policy', self.on_decide_policy)
# Fond transparent pour éviter le flash blanc
bg_color = Gdk.RGBA()
bg_color.red = 0
bg_color.green = 0
bg_color.blue = 0
bg_color.alpha = 0 # Transparent
self.webview.set_background_color(bg_color)
self.reload_content()
self.add(self.webview)
self.connect('destroy', self.on_destroy)
# Lancer le ping en arrière-plan
self.ping_timer_id = None
self.autohide_timer_id = None
self.mouse_inside = True
self.start_ping_check()
# Configurer le ping périodique
ping_interval = self.config.get('apparence', {}).get('ping_intervalle', 360)
if ping_interval > 0:
self.ping_timer_id = GLib.timeout_add_seconds(ping_interval, self.on_ping_timer)
# Fermeture sur perte de focus (clic extérieur)
if self.fenetre_config.get('fermer_sur_clic_exterieur', True):
self.connect('focus-out-event', self.on_focus_out)
# Autohide: fermer si souris hors fenêtre pendant X secondes
autohide_delay = self.fenetre_config.get('autohide', 0)
if autohide_delay > 0:
self.autohide_delay = autohide_delay
self.connect('enter-notify-event', self.on_mouse_enter)
self.connect('leave-notify-event', self.on_mouse_leave)
def on_draw(self, widget, cr):
"""Dessine un fond transparent."""
cr.set_source_rgba(0, 0, 0, 0)
cr.set_operator(cairo.OPERATOR_SOURCE)
cr.paint()
return False
def reload_content(self):
self.config = load_config()
self.webview.load_html(generate_html(self.config), 'file://')
def start_ping_check(self):
"""Lance le ping de toutes les IPs en arrière-plan."""
distant = self.config.get('distant', [])
hosts = [m.get('ip', '') for m in distant if m.get('ip')]
ping_all_hosts(hosts, self.on_ping_complete)
def on_ping_complete(self):
"""Callback appelé quand tous les pings sont terminés."""
self.reload_content()
def on_ping_timer(self):
"""Timer pour le ping périodique."""
self.start_ping_check()
return True # Continuer le timer
def on_destroy(self, widget):
"""Nettoyage à la fermeture."""
if self.ping_timer_id:
GLib.source_remove(self.ping_timer_id)
if self.autohide_timer_id:
GLib.source_remove(self.autohide_timer_id)
Gtk.main_quit()
def on_focus_out(self, widget, event):
"""Ferme la fenêtre quand elle perd le focus."""
self.destroy()
return True
def on_mouse_enter(self, widget, event):
"""Souris entre dans la fenêtre - annuler le timer autohide."""
self.mouse_inside = True
if self.autohide_timer_id:
GLib.source_remove(self.autohide_timer_id)
self.autohide_timer_id = None
return False
def on_mouse_leave(self, widget, event):
"""Souris quitte la fenêtre - démarrer le timer autohide."""
self.mouse_inside = False
if self.autohide_timer_id:
GLib.source_remove(self.autohide_timer_id)
self.autohide_timer_id = GLib.timeout_add_seconds(
self.autohide_delay, self.on_autohide_timeout
)
return False
def on_autohide_timeout(self):
"""Timer autohide expiré - fermer si souris toujours hors fenêtre."""
if not self.mouse_inside:
self.destroy()
return False # Ne pas répéter
def toggle_theme(self):
current = self.config.get('apparence', {}).get('theme', 'dark')
new_theme = 'light' if current == 'dark' else 'dark'
try:
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
content = f.read()
# Regex précise: uniquement "theme:" en début de ligne (après espaces)
content = re.sub(r'^(\s*)theme:\s*"?\w+"?', rf'\1theme: "{new_theme}"', content, flags=re.MULTILINE)
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
f.write(content)
self.reload_content()
except Exception as e:
print(f"Erreur thème: {e}")
def on_key_press(self, widget, event):
if event.keyval == Gdk.KEY_Escape:
self.destroy()
return True
return False
def on_decide_policy(self, webview, decision, decision_type):
if decision_type == WebKit2.PolicyDecisionType.NAVIGATION_ACTION:
uri = decision.get_navigation_action().get_request().get_uri()
if uri == 'app://close':
self.destroy()
elif uri == 'app://toggle-theme':
self.toggle_theme()
elif uri == 'app://settings':
subprocess.Popen(['xdg-open', CONFIG_FILE])
elif uri and uri.startswith('app://run/'):
subprocess.Popen(uri.replace('app://run/', '').split())
elif uri and (uri.startswith('smb://') or uri.startswith('nfs://')):
# SMB/NFS: monter avec gio et ouvrir dans Nautilus
self.mount_and_open(uri)
elif uri and not uri.startswith('file://') and not uri.startswith('about:'):
try:
Gio.AppInfo.launch_default_for_uri(uri, None)
except Exception as e:
print(f"Erreur URI: {e}")
else:
return False
decision.ignore()
return True
return False
def mount_and_open(self, uri):
"""Monte un partage SMB/NFS avec gio et l'ouvre dans Nautilus."""
def open_nautilus():
subprocess.Popen(['nautilus', uri])
return False # Ne pas répéter
def mount_thread():
try:
# Monter le partage avec gio mount
subprocess.run(
['gio', 'mount', uri],
capture_output=True,
text=True,
timeout=30
)
except subprocess.TimeoutExpired:
print(f"Timeout montage: {uri}")
except Exception as e:
print(f"Erreur montage {uri}: {e}")
finally:
# Ouvrir dans Nautilus une seule fois
GLib.idle_add(open_nautilus)
# Lancer le montage en arrière-plan pour ne pas bloquer l'UI
threading.Thread(target=mount_thread, daemon=True).start()
def is_running():
if os.path.exists(PID_FILE):
try:
with open(PID_FILE, 'r') as f:
pid = int(f.read().strip())
os.kill(pid, 0)
return pid
except (OSError, ValueError):
pass
return None
def main():
if '--toggle' in sys.argv and is_running():
import signal
os.kill(is_running(), signal.SIGTERM)
if os.path.exists(PID_FILE):
os.remove(PID_FILE)
sys.exit(0)
if is_running():
print(f"Dashboard déjà en cours")
sys.exit(0)
os.environ['WEBKIT_DISABLE_COMPOSITING_MODE'] = '1'
with open(PID_FILE, 'w') as f:
f.write(str(os.getpid()))
import atexit
atexit.register(lambda: os.path.exists(PID_FILE) and os.remove(PID_FILE))
win = DashboardLauncherWindow()
win.show_all()
Gtk.main()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,45 @@
# Prompt de développement Color Picker mini-app
## Analyse du besoin
- Interface compacte avec roue chromatique, zone palette, volet latéral repliable "Theme CSS" affichant styles/typo.
- Actions attendues : sélectionner une couleur, afficher sa complémentaire, copier les codes HEX/RGB, afficher aperçu (pipette), streamer palettes CSS/typos stockées.
- Stockage : dossiers `themes/` et `palettes/` pour y déposer JSON/CSS réutilisables.
- Intégration : doit pouvoir être lancé depuis la barre mini-tools du dashboard et cohabiter avec d'autres mini-apps.
## Proposition d'architecture
1. Frontend web léger (HTML/CSS/JS) rendu via Tauri/Electron ou WebView GTK, pour un look moderne.
2. Backend minimal (Node.js ou Python) servant les fichiers statiques et exposant une API (ex. `GET /themes`, `GET /palettes`).
3. Composants clés :
- Colonne droite : palette avec carte couleur + bouton copier + couleur complémentaire.
- Centre : roue chromatique interactive + pipette et aperçu couleur sélectionnée.
- Volet gauche repliable : aperçu "Theme CSS" (fond, typographies, boutons) chargé depuis `themes/`.
- Barre inférieure : actions rapides (copier, exporter en CSS, ouvrir palette).
4. Données :
- `palettes/*.json` (nom, description, code couleur, tags, langage associé).
- `themes/*.css` (aperçu d'un thème complet plus métadonnées).
## Choix techniques
- **Langage** : JavaScript/TypeScript + Svelte ou Vue pour agile; empaquetage via Tauri pour faible empreinte. Alternativement, Python + GTK si lenvironnement cible préfère GTK.
- **UI** : `chroma.js` ou `tinycolor` pour la palette/roue et le calcul des complémentaires, Web APIs pour la pipette (canvas + input color).
- **Données** : JSON/YAML stockés dans `app/tools/color-picker/` et chargés dynamiquement côté front.
- **Lancement** : un script `scripts/color-picker.sh` ou un mini-wrapper Python qui ouvre la WebView / Tauri.
## Plan
1. Créer la structure `app/tools/color-picker/{src, themes, palettes, assets}`.
2. Initialiser un petit serveur `main.ts` ou `main.py` (selon stack) pour servir linterface et exposer les APIs.
3. Développer lUI (roue, volet thèmes, palettes, pipette) avec interactions décrites.
4. Charger dynamiquement `palettes/*.json` et `themes/*.css`, proposer un panneau pour ajouter/modifier.
5. Ajouter boutons “copier” qui placent HEX/RGB dans le presse-papier.
6. Documenter le lancement et lintégration dans `config/equipements.yaml` via mini-tool.
## Tests
- Test manuel : lancer lapp (via Tauri ou un script) et valider roue réactive, volet dépliable, copie de couleur.
- Tests unitaires (JS) sur le parseur de palettes et les calculs de couleurs (complémentaire, contraste).
## TODO
1. Définir format palette/metadata.
2. Créer jeux de palettes (Monokai, Solarized, thèmes web populaires).
3. Implémenter pipette + copie automatique des codes.
4. Ajouter vue “Theme CSS” avec CSS/Font preview.
5. Préparer commande de lancement utilisable dans `minitools`.
6. Documenter la mini-app dans `README`.

View File

@@ -0,0 +1,22 @@
# Color Picker Mini-App
Cette mini-application fournit une roue chromatique, des palettes prédéfinies, un volet "Theme CSS" et la capacité de copier des couleurs (HEX / complémentaire).
## Structure
- `run_color_picker.py` : lance un serveur HTTP local (`127.0.0.1:9005`) et ouvre le navigateur sur `src/index.html`.
- `src/` : interface brutes (`index.html`, `style.css`, `app.js`).
- `palettes/` : fichiers JSON décrivant les palettes disponibles.
- `themes/` : thèmes CSS et manifeste (`themes.json`).
- `assets/` : placeholders pour icônes, images, etc.
## Développement
1. Mettre à jour `palettes/*.json` pour ajouter de nouvelles palettes ou variantes.
2. Ajouter un thème CSS dans `themes/` puis lenregistrer dans `themes/themes.json` pour quil apparaisse dans la liste.
3. Modifier `src/app.js` pour ajouter des interactions (pipette, export CSS, etc.).
4. Lancer loutil avec :
```bash
python3 app/tools/color-picker/run_color_picker.py
```
Une fois prêt, créez une entrée `minitools` dans `config/equipements.yaml` qui lance ce script (par exemple `command: "python3 /home/.../run_color_picker.py"`).

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Popup GTK qui affiche l'interface Color Picker via WebKit"""
import os
import sys
from pathlib import Path
import gi
# for systems blocking direct GBM access we force WebKit to stay in software mode
os.environ.setdefault('WEBKIT_DISABLE_COMPOSITING_MODE', '1')
os.environ.setdefault('GDK_BACKEND', 'x11')
gi.require_version('Gtk', '3.0')
gi.require_version('WebKit2', '4.1')
from gi.repository import Gtk, WebKit2, GLib
SCRIPT_DIR = Path(__file__).resolve().parent
SRC_FILE = SCRIPT_DIR / 'src' / 'index.html'
class ColorPickerWindow(Gtk.Window):
def __init__(self):
super().__init__(title='Color Picker')
self.set_default_size(980, 640)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_border_width(8)
self.set_resizable(True)
settings = WebKit2.Settings()
settings.set_property('enable-developer-extras', False)
settings.set_property('enable-accelerated-2d-canvas', False)
self.webview = WebKit2.WebView()
self.webview.set_settings(settings)
self.add(self.webview)
uri = GLib.filename_to_uri(str(SRC_FILE), None)
self.webview.load_uri(uri)
self.connect('destroy', Gtk.main_quit)
self.show_all()
def main() -> None:
if not SRC_FILE.exists():
print(f'Fichier introuvable : {SRC_FILE}', file=sys.stderr)
sys.exit(1)
win = ColorPickerWindow()
Gtk.main()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,47 @@
{
"palettes": [
{
"name": "Monokai Pro",
"description": "Palette chaude / turquoise",
"language": "CSS",
"colors": [
"#f8f8f2",
"#f92672",
"#fd971f",
"#a6e22e",
"#66d9ef",
"#9effff",
"#ae81ff",
"#ffffff"
]
},
{
"name": "Solarized",
"description": "Teintes douces pour coding",
"language": "SCSS",
"colors": [
"#002b36",
"#073642",
"#586e75",
"#657b83",
"#839496",
"#b58900",
"#cb4b16",
"#dc322f"
]
},
{
"name": "Material",
"description": "Couleurs saturées modernes",
"language": "CSS",
"colors": [
"#0f9d58",
"#f4b400",
"#4285f4",
"#db4437",
"#ab47bc",
"#00acc1"
]
}
]
}

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Serve l'interface Color Picker depuis un mini-serveur HTTP local."""
import http.server
import socketserver
import threading
import webbrowser
from pathlib import Path
import signal
import os
PORT = int(os.environ.get('COLOR_PICKER_PORT', '9005'))
BASE_DIR = Path(__file__).resolve().parent
SRC_DIR = BASE_DIR / 'src'
class ColorRequestHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(BASE_DIR), **kwargs)
def main() -> None:
with socketserver.TCPServer(('127.0.0.1', PORT), ColorRequestHandler) as httpd:
url = f'http://127.0.0.1:{PORT}/src/index.html'
print(f'Color Picker disponible sur {url}')
threading.Timer(0.3, lambda: webbrowser.open(url)).start()
def stop(signum, frame):
print('Arrêt du serveur Color Picker...')
httpd.shutdown()
signal.signal(signal.SIGINT, stop)
signal.signal(signal.SIGTERM, stop)
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,159 @@
const colorInput = document.getElementById('color-input');
const selectedHexNode = document.getElementById('selected-hex');
const complementHexNode = document.getElementById('complement-hex');
const selectedColorBox = document.getElementById('selected-color');
const complementColorBox = document.getElementById('complement-color');
const paletteContainer = document.getElementById('palette-container');
const themeSelector = document.getElementById('theme-selector');
const themePreview = document.getElementById('theme-preview-content');
const themeDescription = document.getElementById('theme-description');
const paletteEndpoint = '../palettes/default.json';
const themesEndpoint = '../themes/themes.json';
let palettes = [];
let themes = [];
async function fetchPalettes() {
try {
const response = await fetch(paletteEndpoint);
const data = await response.json();
palettes = data.palettes || [];
renderPalettes(palettes);
} catch (err) {
paletteContainer.textContent = 'Impossible de charger les palettes.';
console.error(err);
}
}
async function fetchThemes() {
try {
const response = await fetch(themesEndpoint);
const data = await response.json();
themes = data.themes || [];
populateThemeSelector(themes);
} catch (err) {
themeDescription.textContent = 'Erreur lors du chargement des thèmes.';
console.error(err);
}
}
function populateThemeSelector(themeList) {
themeSelector.innerHTML = '';
themeList.forEach((theme, index) => {
const option = document.createElement('option');
option.value = theme.file;
option.textContent = theme.name;
themeSelector.appendChild(option);
if (index === 0) {
loadTheme(theme);
}
});
}
async function loadTheme(theme) {
try {
const response = await fetch(`../themes/${theme.file}`);
const content = await response.text();
themeDescription.textContent = theme.description || 'Aperçu CSS interactif.';
themePreview.textContent = content.trim();
} catch (err) {
themePreview.textContent = 'Impossible de charger le fichier.';
console.error(err);
}
}
function renderPalettes(list) {
paletteContainer.innerHTML = '';
list.forEach(palette => {
const card = document.createElement('article');
card.className = 'palette-card';
card.innerHTML = `
<header>
<h3>${palette.name}</h3>
<small>${palette.description}</small>
</header>
<div class="palette-colors"></div>
<button data-palette="${palette.name}">Copier la sélection</button>
`;
const colorGrid = card.querySelector('.palette-colors');
palette.colors.forEach(color => {
const swatch = document.createElement('span');
swatch.style.background = color;
swatch.dataset.hex = color;
swatch.title = color;
swatch.addEventListener('click', () => updateColor(color));
colorGrid.appendChild(swatch);
});
const copyButton = card.querySelector('button');
copyButton.addEventListener('click', () => {
const text = palette.colors.join(', ');
copyToClipboard(text, copyButton);
});
paletteContainer.appendChild(card);
});
}
function updateColor(hex) {
const normalized = hex.startsWith('#') ? hex : `#${hex}`;
colorInput.value = normalized;
selectedHexNode.textContent = normalized;
selectedColorBox.style.background = normalized;
const complement = getComplement(normalized);
complementColorBox.style.background = complement;
complementHexNode.textContent = complement;
}
function getComplement(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const complement = [255 - r, 255 - g, 255 - b]
.map(value => value.toString(16).padStart(2, '0'))
.join('');
return `#${complement}`;
}
function copyToClipboard(text, button) {
navigator.clipboard.writeText(text).then(() => {
if (button) {
const original = button.dataset.origText || button.textContent;
button.dataset.origText = original;
button.textContent = 'Copié';
button.classList.add('copied');
setTimeout(() => {
button.textContent = original;
button.classList.remove('copied');
}, 1200);
}
}).catch(err => {
console.error('Impossible de copier', err);
});
}
colorInput.addEventListener('input', (event) => {
updateColor(event.target.value);
});
document.querySelectorAll('button[data-copy]').forEach(button => {
button.addEventListener('click', () => {
const target = button.dataset.copy;
const text = target === 'selected' ? selectedHexNode.textContent : complementHexNode.textContent;
copyToClipboard(text, button);
});
});
themeSelector.addEventListener('change', () => {
const theme = themes.find(t => t.file === themeSelector.value);
if (theme) {
loadTheme(theme);
}
});
const themePanel = document.getElementById('theme-panel');
document.getElementById('theme-toggle').addEventListener('click', () => {
themePanel.classList.toggle('collapsed');
});
fetchPalettes();
fetchThemes();
updateColor(colorInput.value);

View File

@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Color Picker</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="app-shell">
<aside class="theme-panel" id="theme-panel">
<header class="theme-header">
<button id="theme-toggle" aria-label="Afficher/masquer le volet"></button>
<div>
<p>Theme CSS</p>
<select id="theme-selector"></select>
</div>
</header>
<div class="theme-preview">
<p id="theme-description">Chargement des thèmes...</p>
<pre id="theme-preview-content"></pre>
</div>
</aside>
<div class="workspace">
<header class="workspace-header">
<div>
<h1>Color Picker</h1>
<p>Roue chromatique, palettes et CSS rapide.</p>
</div>
<div class="window-actions">
<button aria-label="Réduire"></button>
<button aria-label="Agrandir"></button>
<button aria-label="Fermer"></button>
</div>
</header>
<section class="color-panel">
<div class="color-wheel" aria-hidden="true"></div>
<div class="color-info">
<p class="section-label">Couleur active</p>
<input type="color" id="color-input" value="#f94144">
<div class="color-swatch-row">
<div class="color-box large" id="selected-color"></div>
<div class="color-box large" id="complement-color"></div>
</div>
<div class="color-metadata">
<div>
<span>HEX</span>
<strong id="selected-hex">#f94144</strong>
</div>
<button data-copy="selected">Copier</button>
</div>
<div class="color-metadata">
<div>
<span>Complémentaire</span>
<strong id="complement-hex">#0b5ebb</strong>
</div>
<button data-copy="complement">Copier</button>
</div>
</div>
</section>
<section class="palette-section">
<header>
<h2>Palettes web</h2>
<p>Chaque palette contient un ensemble de couleurs recommandées et leur complément.</p>
</header>
<div id="palette-container" class="palette-grid"></div>
</section>
</div>
</div>
<script type="module" src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,294 @@
:root {
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: 'Inter', 'Segoe UI', sans-serif;
background: radial-gradient(circle at top, #1a1a1a, #050505 75%);
color: #f6f6f6;
}
.app-shell {
display: grid;
grid-template-columns: 260px auto;
gap: 14px;
padding: 18px;
min-height: 100vh;
}
.theme-panel {
background: rgba(30, 30, 30, 0.95);
border: 1px solid #2f2f2f;
border-radius: 18px;
display: flex;
flex-direction: column;
padding: 14px;
transition: transform 200ms ease;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.55);
}
.theme-panel.collapsed {
transform: translateX(-228px);
}
.theme-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.theme-header button {
background: transparent;
border: 1px solid #3d3d3d;
color: #f6f6f6;
border-radius: 12px;
width: 36px;
height: 36px;
cursor: pointer;
}
.theme-header select {
background: #1c1f2b;
border: 1px solid #323248;
border-radius: 8px;
color: #f6f6f6;
padding: 6px 10px;
}
.theme-preview {
flex: 1;
background: linear-gradient(145deg, #1f1f28, #14141b);
border-radius: 12px;
border: 1px solid #353545;
padding: 10px;
overflow: auto;
}
.theme-preview pre {
font-size: 0.8rem;
white-space: pre-wrap;
}
.workspace {
background: rgba(15, 15, 24, 0.92);
border-radius: 28px;
border: 1px solid #1d1f38;
padding: 22px;
display: flex;
flex-direction: column;
gap: 18px;
box-shadow: 0 28px 45px rgba(6, 7, 32, 0.65);
}
.workspace-header {
display: flex;
justify-content: space-between;
align-items: center;
color: #f1f4ff;
}
.workspace-header h1 {
margin: 0;
font-size: 1.5rem;
}
.workspace-header p {
margin: 4px 0 0;
color: #a9aacf;
}
.window-actions button {
background: #26273a;
border: 1px solid #3d3d3d;
color: #fff;
width: 32px;
height: 32px;
margin-left: 6px;
border-radius: 8px;
cursor: pointer;
}
.color-panel {
display: flex;
gap: 32px;
align-items: center;
background: rgba(20, 20, 32, 0.95);
border-radius: 22px;
border: 1px solid #2f354f;
padding: 18px;
box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.35);
}
.color-wheel {
width: 220px;
height: 220px;
border-radius: 50%;
border: 5px solid rgba(255, 255, 255, 0.12);
background: conic-gradient(
#f94144,
#f3722c,
#f8961e,
#f9c74f,
#90be6d,
#43aa8b,
#4d908e,
#577590,
#277da1,
#4b4bfb,
#7209b7,
#f94144);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
}
.color-info {
flex: 1;
background: rgba(16, 16, 24, 0.92);
border-radius: 22px;
border: 1px solid #303046;
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
}
.section-label {
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: 0.7rem;
color: #a3a7bc;
margin: 0;
}
.color-info input[type='color'] {
width: 100%;
height: 56px;
border-radius: 16px;
border: none;
cursor: pointer;
}
.color-swatch-row {
display: flex;
gap: 12px;
}
.color-box.large {
flex: 1;
height: 60px;
border-radius: 16px;
border: 1px solid #373a4f;
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.6);
}
.color-metadata {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
}
.color-metadata span {
font-size: 0.6rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: #7c81ad;
}
.color-metadata strong {
margin-left: 8px;
font-size: 1.1rem;
}
.color-metadata button {
background: #2f72ff;
border: none;
border-radius: 8px;
color: #fff;
padding: 6px 12px;
cursor: pointer;
font-size: 0.72rem;
}
.color-metadata button.copied,
.palette-card button.copied {
background: #4ccc97;
color: #0b2a1c;
}
.palette-section {
background: rgba(15, 15, 20, 0.9);
border-radius: 24px;
padding: 22px 18px 18px;
border: 1px solid #27293b;
}
.palette-section header {
margin-bottom: 22px;
}
.palette-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.palette-card {
background: rgba(20, 20, 30, 0.9);
border-radius: 18px;
border: 1px solid #30305c;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.palette-card h3 {
margin: 0;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.palette-card .palette-colors {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.palette-card .palette-colors span {
width: 100%;
padding-top: 100%;
border-radius: 10px;
border: 1px solid #1b1b25;
}
.palette-card small {
color: #9ea1c6;
}
.palette-card button {
border: none;
background: transparent;
color: #54f0b1;
cursor: pointer;
padding: 0;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
@media (max-width: 960px) {
.app-shell {
grid-template-columns: 1fr;
}
.theme-panel {
order: 2;
}
}

View File

@@ -0,0 +1,16 @@
:root {
--bg: #272822;
--fg: #f8f8f2;
--primary: #f92672;
--success: #a6e22e;
}
body {
background: var(--bg);
color: var(--fg);
font-family: 'Fira Code', 'JetBrains Mono', monospace;
}
button {
background: var(--primary);
color: var(--fg);
border: none;
}

View File

@@ -0,0 +1,13 @@
:root {
--bg: #fdf6e3;
--fg: #657b83;
--primary: #268bd2;
--accent: #2aa198;
}
body {
background: var(--bg);
color: var(--fg);
}
header, .palette-card {
border-color: var(--accent);
}

View File

@@ -0,0 +1,16 @@
{
"themes": [
{
"name": "Monokai",
"file": "monokai.css",
"description": "Palette sombre inspirée de Monokai",
"type": ".css"
},
{
"name": "Solarized Light",
"file": "solarized-light.css",
"description": "Theme clair Solarized",
"type": ".css"
}
]
}