APP.PY
#!/usr/bin/env python3
"""
Nomi Configurator - Combined backend with cross-platform system keys and live profile updates.
Dependencies:
pip install pyserial flask flask-socketio pywebview pynput psutil pywin32
Notes:
- On Windows this script uses ctypes to send media key events (VK_MEDIA_* & VK_VOLUME_*).
- On macOS this script uses osascript (AppleScript) calls for volume and music control.
- On Linux, this script may require 'xdotool' for app detection (`sudo apt-get install xdotool`).
- Ensure Nomi_Profile.json exists in same folder (the app will create a default if missing).
- Uses pynput for reliable hotkey combinations.
--- NEW FEATURES IN THIS VERSION ---
1. Smooth LED Fade: Layer changes now trigger a smooth color transition by sending
a "FADE:" command to the firmware.
2. Disconnected State (Firmware): The script supports a blinking red LED state.
- FIRMWARE REQUIREMENT: The device should blink red on its own when not connected.
- The first command sent from this script upon connection will stop the blinking.
3. Cycle Layers on Hold (Firmware): Holding Button 1 and turning the encoder cycles layers.
- FIRMWARE REQUIREMENT: The device must be programmed to send "CMD:CYCLE_LAYER"
for each encoder click while Button 1 is held.
4. Automatic App-Based Layer Switching: The backend now monitors the active window
and switches to a layer if it's linked to that application.
5. Dynamic App List: The UI can request a list of running applications for easier linking.
"""
import os
import sys
import json
import time
import threading
import platform
import subprocess
import serial
import serial.tools.list_ports
import webview
from flask import Flask, render_template
from flask_socketio import SocketIO
from pynput.keyboard import Controller, Key
import psutil # For listing processes
# --- Flask App & WebSocket Setup ---
basedir = os.path.abspath(os.path.dirname(__file__))
app = Flask(__name__, template_folder=os.path.join(basedir, 'web'))
socketio = SocketIO(app, cors_allowed_origins="*")
# --- Globals ---
keyboard = Controller()
serial_connection = None
PROFILE_FILE = os.path.join(basedir, 'Nomi_Profile.json')
state = {}
# --- Platform info ---
PLATFORM = platform.system() # 'Windows', 'Darwin' (macOS), 'Linux'
# Platform-specific imports for active window detection
if PLATFORM == "Windows":
import win32process
import win32gui
# --- Profile Management ---
def load_profile():
global state
if os.path.exists(PROFILE_FILE):
try:
with open(PROFILE_FILE, 'r') as f:
state = json.load(f)
print(f"Loaded profile: {PROFILE_FILE}")
except Exception as e:
print(f"Error loading profile JSON: {e}")
state = {}
else:
print("No profile found, creating default profile.")
state = {
"last_port": None,
"currentView": "configure",
"currentLayer": 0,
"layers": [
{
"name": "Default Layer",
"linkedApp": "",
"color": "#000000",
"ledColor": "#FFFFFF",
"keymap": [{"k":"..."} for _ in range(6)],
"encoder": {"cw":{"k":"..."}, "ccw":{"k":"..."}, "press":{"k":"..."}}
}
],
"macros": [],
"keycodes": {}
}
save_profile()
def save_profile():
try:
with open(PROFILE_FILE, 'w') as f:
json.dump(state, f, indent=4)
# print("Profile saved.") # Commented out to reduce console spam
except Exception as e:
print(f"Error saving profile: {e}")
# --- Utility: normalize incoming serial line ---
def normalize_device_line(line: str) -> str:
"""Extract the payload like BTN:1 or ENC:CW from various formatted lines."""
if not line:
return ''
line = line.strip()
prefix = 'Received from device:'
if line.startswith(prefix):
return line[len(prefix):].strip()
parts = line.split()
for p in parts:
if ':' in p:
token = p.strip().strip('"').strip("'").strip()
if token.upper().startswith(('BTN:', 'ENC:', 'CMD:')):
return token
return line
# --- Cross-platform system key helpers ---
def send_system_key_by_name(name: str):
"""Send system/media key by a short name (e.g. VOLU, VOLD, MUTE, PLAY, NEXT, PREV)."""
n = str(name).upper()
try:
if PLATFORM == "Windows":
_win_send_media_key(n)
elif PLATFORM == "Darwin": # macOS
_mac_send_media_key(n)
else:
_linux_send_media_key(n)
except Exception as e:
print(f"Error sending system key {n}: {e}")
# --- Windows implementation using ctypes keybd_event ---
if PLATFORM == "Windows":
import ctypes
user32 = ctypes.windll.user32
VK_VOLUME_MUTE = 0xAD
VK_VOLUME_DOWN = 0xAE
VK_VOLUME_UP = 0xAF
VK_MEDIA_NEXT_TRACK = 0xB0
VK_MEDIA_PREV_TRACK = 0xB1
VK_MEDIA_STOP = 0xB2
VK_MEDIA_PLAY_PAUSE = 0xB3
def _win_send_key(vk):
user32.keybd_event(vk, 0, 0, 0)
time.sleep(0.02)
user32.keybd_event(vk, 0, 2, 0)
def _win_send_media_key(name: str):
if name == "VOLU": _win_send_key(VK_VOLUME_UP)
elif name == "VOLD": _win_send_key(VK_VOLUME_DOWN)
elif name in ("MUTE", "VOLM"): _win_send_key(VK_VOLUME_MUTE)
elif name == "NEXT": _win_send_key(VK_MEDIA_NEXT_TRACK)
elif name == "PREV": _win_send_key(VK_MEDIA_PREV_TRACK)
elif name == "PLAY": _win_send_key(VK_MEDIA_PLAY_PAUSE)
elif name == "STOP": _win_send_key(VK_MEDIA_STOP)
else: print(f"[Windows] Unknown media key: {name}")
# --- macOS implementation using osascript ---
def _mac_send_media_key(name: str):
name = name.upper()
if name == "VOLU":
cmd = 'set o to output volume of (get volume settings)\nset o to o + 6\nif o > 100 then set o to 100\nset volume output volume o'
subprocess.run(['osascript', '-e', cmd])
elif name == "VOLD":
cmd = 'set o to output volume of (get volume settings)\nset o to o - 6\nif o < 0 then set o to 0\nset volume output volume o'
subprocess.run(['osascript', '-e', cmd])
elif name in ("MUTE", "VOLM"):
try:
res = subprocess.check_output(['osascript', '-e', 'output muted of (get volume settings)']).decode().strip()
if res.lower() in ('true', '1'):
subprocess.run(['osascript', '-e', 'set volume without output muted'])
else:
subprocess.run(['osascript', '-e', 'set volume with output muted'])
except Exception:
subprocess.run(['osascript', '-e', 'set volume with output muted'])
elif name == "PLAY":
for app in ("Music", "Spotify"):
try: subprocess.run(['osascript', '-e', f'tell application "{app}" to playpause'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception: pass
elif name == "STOP":
for app in ("Music", "Spotify"):
try: subprocess.run(['osascript', '-e', f'tell application "{app}" to stop'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception: pass
elif name == "NEXT":
for app in ("Music", "Spotify"):
try: subprocess.run(['osascript', '-e', f'tell application "{app}" to next track'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception: pass
elif name == "PREV":
for app in ("Music", "Spotify"):
try: subprocess.run(['osascript', '-e', f'tell application "{app}" to previous track'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception: pass
else:
print(f"[macOS] Unknown media key: {name}")
# --- Linux best-effort fallback ---
def _linux_send_media_key(name: str):
name = name.upper()
def _run(cmd):
try:
subprocess.run(cmd, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
except Exception: return False
if name in ("PLAY","STOP","NEXT","PREV"):
if _run("playerctl play-pause") and name == "PLAY": return
if name == "NEXT": _run("playerctl next")
elif name == "PREV": _run("playerctl previous")
elif name == "STOP": _run("playerctl stop")
elif name in ("VOLU","VOLD","MUTE","VOLM"):
if name == "VOLU": _run("amixer -D pulse sset Master 5%+")
elif name == "VOLD": _run("amixer -D pulse sset Master 5%-")
else: _run("amixer -D pulse sset Master toggle")
else:
print(f"[Linux] Unknown media key: {name}")
# --- NEW: Active Application Monitoring ---
def get_active_process_name():
"""Gets the executable name of the currently focused window."""
try:
if PLATFORM == "Windows":
hwnd = win32gui.GetForegroundWindow()
_, pid = win32process.GetWindowThreadProcessId(hwnd)
return psutil.Process(pid).name()
elif PLATFORM == "Darwin": # macOS
cmd = 'tell application "System Events" to get name of first process whose frontmost is true'
result = subprocess.check_output(['osascript', '-e', cmd]).decode().strip()
return result
elif PLATFORM == "Linux":
# Requires 'xdotool' to be installed (sudo apt-get install xdotool)
pid_cmd = 'xdotool getwindowfocus getwindowpid'
pid = subprocess.check_output(pid_cmd, shell=True).decode().strip()
return psutil.Process(int(pid)).name()
except Exception:
return None
return None
def app_monitoring_thread():
"""A thread that monitors the active application and switches layers."""
last_app_name = None
while True:
app_name = get_active_process_name()
if app_name and app_name != last_app_name:
print(f"Active App Changed: {app_name}")
last_app_name = app_name
matched_layer_index = -1
for i, layer in enumerate(state.get('layers', [])):
linked_app = layer.get('linkedApp', '')
if linked_app and linked_app.lower() in app_name.lower():
matched_layer_index = i
break
if matched_layer_index != -1 and state.get('currentLayer') != matched_layer_index:
print(f"Switching to layer {matched_layer_index} for {app_name}")
state['currentLayer'] = matched_layer_index
save_profile()
new_led_color = state['layers'][matched_layer_index].get('ledColor')
fade_led_color(new_led_color)
socketio.emit('layer_changed', {'layer': matched_layer_index})
time.sleep(1) # Check once per second
# --- Serial reader thread ---
def serial_reader():
global serial_connection
while True:
if serial_connection and serial_connection.is_open:
try:
raw = serial_connection.readline()
if not raw:
time.sleep(0.01)
continue
try:
line = raw.decode('utf-8', errors='ignore').strip()
except Exception:
line = raw.decode('latin-1', errors='ignore').strip()
if line:
# print(f"Raw from device: {line}") # Can be noisy
payload = normalize_device_line(line)
if payload:
try:
handle_device_input({'data': payload})
except Exception as e:
print(f"Error processing device input locally: {e}")
try:
socketio.emit('handle_device_input_event', {'data': payload})
except Exception as e:
print(f"Error emitting to UI: {e}")
except Exception as e:
print(f"Serial read error / disconnected: {e}")
try:
if serial_connection: serial_connection.close()
except Exception: pass
serial_connection = None
try:
socketio.emit('device_status', {'status': 'disconnected', 'port': ''})
except Exception: pass
time.sleep(0.01)
# --- Device input handling (Socket event + local calls) ---
@socketio.on('handle_device_input_event')
def handle_device_input(json_data):
data = None
if isinstance(json_data, dict): data = json_data.get('data')
elif isinstance(json_data, str): data = json_data
if not data:
print("handle_device_input called with no data")
return
if not state.get('layers'):
print("No layers configured in state")
return
try:
parts = data.split(':', 1)
if len(parts) != 2:
# print(f"Unexpected input format: {data}") # Can be noisy
return
input_type, value = parts[0].upper(), parts[1]
current_layer_index = int(state.get('currentLayer', 0))
layers = state.get('layers', [])
if not (0 <= current_layer_index < len(layers)):
print(f"Invalid currentLayer index: {current_layer_index}")
return
current_layer = layers[current_layer_index]
if input_type == 'CMD' and value.upper() == 'CYCLE_LAYER':
print("Received command to cycle layer.")
num_layers = len(layers)
if num_layers > 1:
next_layer_index = (current_layer_index + 1) % num_layers
state['currentLayer'] = next_layer_index
save_profile()
new_led_color = layers[next_layer_index].get('ledColor')
fade_led_color(new_led_color)
socketio.emit('layer_changed', {'layer': next_layer_index})
return
action_key = None
if input_type == 'BTN':
try:
idx = int(value) - 1
if 0 <= idx < len(current_layer.get('keymap', [])):
action_key = current_layer['keymap'][idx].get('k')
except ValueError: print(f"Invalid BTN value: {value}")
elif input_type == 'ENC':
action_map = {'CW':'cw', 'CCW':'ccw', 'P':'press'}
mapped = action_map.get(value.upper())
if mapped:
action_key = current_layer.get('encoder', {}).get(mapped, {}).get('k')
if action_key:
execute_action(action_key)
else:
# print(f"No action mapped for input {data}") # Can be noisy
pass
except Exception as e:
print(f"Error handling device input: {e}")
# --- Action execution ---
def execute_action(key):
"""
Executes a macro with intelligent modifier key handling,
a layer switch, or a single key/system action.
"""
if not key:
return
print(f"Executing action: {key}")
MODIFIER_KEYS = {
'CTRL', 'CONTROL', 'SHIFT', 'SHFT', 'ALT', 'OPTION',
'WIN', 'WINDOWS', 'CMD', 'COMMAND', 'SUPER'
}
macro = next((m for m in state.get('macros', []) if m.get('name') == key), None)
if macro:
print(f"Running macro: {macro.get('name')}")
actions = macro.get('actions', [])
i = 0
while i < len(actions):
act = actions[i]
atype = act.get('type')
val = str(act.get('value', '')).upper()
if atype == 'keystroke' and val in MODIFIER_KEYS:
hotkey_parts = [val]
j = i + 1
final_key_found = False
while j < len(actions):
next_act = actions[j]
if next_act.get('type') == 'keystroke':
next_val = str(next_act.get('value', '')).upper()
hotkey_parts.append(next_val)
if next_val not in MODIFIER_KEYS:
final_key_found = True
break
j += 1
else:
break
if final_key_found:
hotkey_string = "+".join(hotkey_parts)
print(f"Executing hotkey: {hotkey_string}")
press_key(hotkey_string)
i = j
else:
press_key(hotkey_parts[0])
else:
if atype == 'keystroke':
press_key(act.get('value'))
elif atype == 'text':
keyboard.type(act.get('value', ''))
elif atype == 'delay':
try:
time.sleep(int(act.get('value', 0)) / 1000.0)
except (ValueError, TypeError):
pass
i += 1
elif isinstance(key, str) and key.startswith('Lyr '):
try:
li = int(key.split(' ')[1])
if 0 <= li < len(state.get('layers', [])):
state['currentLayer'] = li
save_profile()
new_led_color = state['layers'][li].get('ledColor')
fade_led_color(new_led_color)
socketio.emit('layer_changed', {'layer': li})
except Exception as e:
print(f"Layer switch error: {e}")
else:
press_key(key)
# --- Enhanced press_key with proper modifier key support ---
def press_key(key_string):
if not key_string: return
system_keys = {'VOLU', 'VOLD', 'MUTE', 'VOLM', 'PLAY', 'STOP', 'NEXT', 'PREV'}
if key_string.upper() in system_keys:
send_system_key_by_name(key_string.upper())
return
key_map = {
'CTRL': Key.ctrl, 'CONTROL': Key.ctrl, 'SHIFT': Key.shift, 'SHFT': Key.shift,
'ALT': Key.alt, 'OPTION': Key.alt, 'WIN': Key.cmd, 'WINDOWS': Key.cmd,
'CMD': Key.cmd, 'COMMAND': Key.cmd, 'SUPER': Key.cmd, 'ENTER': Key.enter,
'ENT': Key.enter, 'RETURN': Key.enter, 'BACKSPACE': Key.backspace,
'BKSP': Key.backspace, 'DELETE': Key.delete, 'DEL': Key.delete,
'TAB': Key.tab, 'SPACE': Key.space,
'ESC': Key.esc if hasattr(Key, 'esc') else Key.escape,
'ESCAPE': Key.esc if hasattr(Key, 'esc') else Key.escape,
'UP': Key.up, 'DOWN': Key.down, 'LEFT': Key.left, 'RIGHT': Key.right,
'RGHT': Key.right, 'F1': Key.f1, 'F2': Key.f2, 'F3': Key.f3, 'F4': Key.f4,
'F5': Key.f5, 'F6': Key.f6, 'F7': Key.f7, 'F8': Key.f8, 'F9': Key.f9,
'F10': Key.f10, 'F11': Key.f11, 'F12': Key.f12, 'HOME': Key.home,
'END': Key.end, 'PAGE_UP': Key.page_up, 'PAGEUP': Key.page_up,
'PAGE_DOWN': Key.page_down, 'PAGEDOWN': Key.page_down,
'CAPS_LOCK': Key.caps_lock if hasattr(Key, 'caps_lock') else None,
'CAPSLOCK': Key.caps_lock if hasattr(Key, 'caps_lock') else None,
}
try:
if '+' in key_string:
parts = [p.strip().upper() for p in key_string.split('+')]
modifiers = []
main_key_str = None
for part in parts:
if part in key_map and key_map[part] in {Key.ctrl, Key.shift, Key.alt, Key.cmd}:
modifiers.append(key_map[part])
else:
main_key_str = part
if not main_key_str:
print(f"No main key found in combination: {key_string}")
return
key_to_press = key_map.get(main_key_str, main_key_str.lower())
with keyboard.pressed(*modifiers):
keyboard.press(key_to_press)
keyboard.release(key_to_press)
else:
ks_upper = key_string.upper()
key_obj = key_map.get(ks_upper)
if key_obj:
keyboard.press(key_obj)
keyboard.release(key_obj)
elif len(key_string) == 1:
keyboard.press(key_string)
keyboard.release(key_string)
else:
print(f"Unknown key string: {key_string}")
except Exception as e:
print(f"Error in press_key('{key_string}'): {e}")
# --- Serial write helper ---
def send_command_to_device(command):
if serial_connection and serial_connection.is_open:
try:
serial_connection.write((command + '\n').encode('utf-8'))
print(f"Sent to device: {command}")
except Exception as e:
print(f"Write error: {e}")
# --- MODIFIED: Helper function for smooth LED color transitions ---
def fade_led_color(end_hex):
"""Sends a command to the device to smoothly fade to a new color."""
if end_hex and isinstance(end_hex, str) and end_hex.startswith('#'):
send_command_to_device(f"FADE:{end_hex}")
else:
print(f"Invalid color for fade: {end_hex}. Setting instantly.")
if end_hex and isinstance(end_hex, str) and end_hex.startswith('#'):
send_command_to_device(f"LED:{end_hex}")
# --- Web routes & socket events ---
@app.route('/')
def index():
return render_template('index.html')
@socketio.on('connect')
def on_connect():
print("UI connected")
socketio.emit('full_profile', state)
@socketio.on('update_profile')
def on_update_profile(data):
global state
if isinstance(data, dict):
state = data
save_profile()
try:
led = state['layers'][state['currentLayer']]['ledColor']
if led: send_command_to_device(f"LED:{led}")
except (KeyError, IndexError): pass
# print("Profile updated from UI and saved.") # Can be noisy
else:
print("Ignored update_profile: data not dict")
@socketio.on('scan_ports')
def on_scan_ports():
ports = serial.tools.list_ports.comports()
port_list = [{'device': p.device, 'description': p.description} for p in ports]
socketio.emit('port_list', {'ports': port_list})
@socketio.on('connect_device')
def connect_device(data):
global serial_connection
port = data.get('port') if isinstance(data, dict) else None
if not port:
socketio.emit('device_status', {'status': 'error', 'port': ''})
return
if serial_connection and serial_connection.is_open:
try: serial_connection.close()
except Exception: pass
try:
serial_connection = serial.Serial(port, 9600, timeout=1)
state['last_port'] = port
save_profile()
socketio.emit('device_status', {'status': 'connected', 'port': port})
print(f"Connected to {port}")
try:
led = state['layers'][state['currentLayer']]['ledColor']
if led: send_command_to_device(f"LED:{led}")
except (KeyError, IndexError): pass
except serial.SerialException as e:
print(f"Failed to connect: {e}")
socketio.emit('device_status', {'status': 'error', 'port': port})
# --- NEW: Socket handler for getting running applications ---
@socketio.on('get_running_apps')
def on_get_running_apps():
"""Scans for running processes and sends a list back to the UI."""
try:
app_names = {p.info['name'] for p in psutil.process_iter(['name'])}
sorted_apps = sorted(list(app_names), key=str.lower)
socketio.emit('running_apps_list', {'apps': sorted_apps})
except Exception as e:
print(f"Could not get app list: {e}")
# --- Run server ---
def run_flask():
socketio.run(app, host='127.0.0.1', port=5000, allow_unsafe_werkzeug=True)
# --- Main ---
if __name__ == '__main__':
load_profile()
last_port = state.get('last_port')
if last_port:
try:
print(f"Attempting to auto-connect to last known port: {last_port}")
connect_device({'port': last_port})
except Exception as e:
print(f"Could not auto-connect to {last_port}: {e}")
# Start all background threads
threading.Thread(target=serial_reader, daemon=True).start()
threading.Thread(target=app_monitoring_thread, daemon=True).start()
threading.Thread(target=run_flask, daemon=True).start()
# Create and start the UI window
webview.create_window('Nomi Configurator', 'http://127.0.0.1:5000', width=1280, height=800)
webview.start()
INDEX.HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nomi Configurator</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome for Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<!-- Google Fonts: Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;900&display=swap" rel="stylesheet">
<!-- Socket.IO Client -->
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<style>
/* Custom Styles */
body {
font-family: 'Inter', sans-serif;
background-color: #000000;
color: #E5E7EB;
overflow: hidden;
}
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.spin-animation { animation: spin 8s linear infinite; }
/* General interactive element styling */
.interactive-element {
display: inline-flex; align-items: center; justify-content: center;
border-radius: 0.5rem; background-color: #27272A;
border: 1px solid #3F3F46; border-bottom-width: 3px;
font-weight: 600; transition: all 0.1s ease-in-out;
user-select: none; cursor: pointer;
}
.interactive-element:hover { background-color: #3F3F46; border-color: #52525B; }
.interactive-element.selected { background-color: #3B82F6; border-color: #60A5FA; color: white; }
/* Encoder dial styling */
.encoder-dial {
width: 100px; height: 100px; border-radius: 50%;
background: radial-gradient(circle, #3F3F46 0%, #18181B 100%);
border: 2px solid #52525B;
position: relative;
}
.encoder-dial::after {
content: ''; position: absolute; top: 10px; left: 50%;
transform: translateX(-50%); width: 4px; height: 20px;
background-color: #A1A1AA; border-radius: 2px;
}
/* Layer Tab styling */
.layer-tab {
background-color: #18181B;
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
border: 1px solid #3F3F46;
border-bottom: none;
border-top-width: 3px; /* For color tagging */
}
.layer-tab.active {
background-color: #000000;
border-color: #52525B;
}
/* Hide elements by default */
.view, .modal, #context-menu { display: none; }
.view.active, .modal.active, #context-menu.active { display: flex; }
/* Macro Action Type Button Styling */
.action-type-btn.selected {
background-color: #3B82F6;
border-color: #60A5FA;
}
/* Drag and Drop Styling */
.draggable-action.dragging {
opacity: 0.5;
background-color: #3F3F46;
}
.drop-indicator {
height: 2px;
background-color: #3B82F6;
margin: 4px 0;
}
/* Custom styling for color input to remove default appearance */
input[type="color"] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
padding: 0;
border: none;
background-color: transparent;
}
input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
input[type="color"]::-webkit-color-swatch {
border: 1px solid #52525B;
border-radius: 0.25rem;
}
/* NEW: Solid style ONLY for the app link dropdown */
#link-app-select {
-webkit-appearance: none; -moz-appearance: none; appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%239CA3AF' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
padding-right: 2.5rem;
}
/* Toggle Switch Styling */
.toggle-bg:after {
content: '';
@apply absolute top-0.5 left-0.5 bg-white border border-gray-300 rounded-full h-5 w-5 transition shadow-sm;
}
input:checked + .toggle-bg:after {
@apply transform translate-x-full;
}
input:checked + .toggle-bg {
@apply bg-blue-600;
}
/* Lighting page specific styles */
.lighting-list-item.selected {
background-color: #3B82F6;
}
.led-diode {
transition: fill 0.2s ease-in-out;
}
</style>
</head>
<body class="bg-black text-gray-300">
<div class="flex h-screen">
<!-- Sidebar -->
<aside class="w-20 bg-[#111111] flex flex-col justify-between items-center py-5 z-20">
<div id="nav-icons" class="flex flex-col space-y-4">
<a href="#" data-view="home" class="nav-icon text-gray-900 bg-green-500 p-3 rounded-lg"><i class="fa-solid fa-house fa-lg"></i></a>
<a href="#" data-view="configure" class="nav-icon text-gray-400 hover:text-white hover:bg-gray-700 p-3 rounded-lg"><i class="fa-solid fa-wrench fa-lg"></i></a>
<a href="#" data-view="macros" class="nav-icon text-gray-400 hover:text-white hover:bg-gray-700 p-3 rounded-lg"><i class="fa-solid fa-share-nodes fa-lg"></i></a>
<a href="#" data-view="lighting" class="nav-icon text-gray-400 hover:text-white hover:bg-gray-700 p-3 rounded-lg"><i class="fa-solid fa-lightbulb fa-lg"></i></a>
</div>
<div>
<a href="#" data-view="settings" class="nav-icon text-gray-400 hover:text-white hover:bg-gray-700 p-3 rounded-lg"><i class="fa-solid fa-cog fa-lg"></i></a>
</div>
</aside>
<!-- Main Content Area -->
<div class="flex-1 relative">
<!-- Home/Connect View -->
<main id="home-view" class="view active flex-col h-full">
<div class="absolute top-6 right-8 flex items-center space-x-4">
<span id="connection-text" class="text-sm text-gray-400">NO DEVICE CONNECTED</span>
<div class="relative">
<button id="connect-btn" class="bg-gray-800 text-white text-xs font-semibold px-4 py-2 rounded-full flex items-center space-x-2 hover:bg-gray-700 transition-colors">
<span>SELECT COM PORT</span>
<i class="fa-solid fa-chevron-down text-xs"></i>
</button>
<div id="port-dropdown" class="absolute right-0 mt-2 w-64 bg-[#18181B] rounded-md shadow-lg z-50 hidden">
<!-- Port list will be generated here -->
</div>
</div>
</div>
<div class="flex-grow flex flex-col justify-center items-center text-center">
<!-- SVG Illustration -->
<div class="relative">
<svg width="400" height="400" viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bodyGradient" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" style="stop-color:#4A5568;stop-opacity:1" /><stop offset="100%" style="stop-color:#2D3748;stop-opacity:1" /></linearGradient>
<radialGradient id="dialGradient"><stop offset="0%" stop-color="#A0AEC0" /><stop offset="90%" stop-color="#4A5568" /><stop offset="100%" stop-color="#2D3748" /></radialGradient>
<filter id="innerShadow"><feFlood flood-color="black" flood-opacity="0.5"/><feComposite in2="SourceAlpha" operator="out"/><feGaussianBlur stdDeviation="3"/><feComposite in2="SourceAlpha" operator="in"/><feOffset dx="2" dy="2"/></filter>
</defs>
<rect x="25" y="25" width="450" height="450" rx="40" ry="40" fill="url(#bodyGradient)" stroke="#1A202C" stroke-width="2"/>
<circle cx="300" cy="300" r="140" fill="#1A202C" filter="url(#innerShadow)"/><circle cx="300" cy="300" r="135" fill="none" stroke="url(#dialGradient)" stroke-width="5"/>
<rect x="60" y="60" width="80" height="380" rx="15" ry="15" fill="#1A202C" filter="url(#innerShadow)"/>
<rect x="75" y="75" width="50" height="50" rx="5" ry="5" fill="#2D3748" stroke="#4A5568" stroke-width="2"/><rect x="75" y="165" width="50" height="50" rx="5" ry="5" fill="#2D3748" stroke="#4A5568" stroke-width="2"/><rect x="75" y="255" width="50" height="50" rx="5" ry="5" fill="#2D3748" stroke="#4A5568" stroke-width="2"/><rect x="75" y="345" width="50" height="50" rx="5" ry="5" fill="#2D3748" stroke="#4A5568" stroke-width="2"/>
<rect x="170" y="75" width="50" height="80" rx="15" ry="15" fill="url(#bodyGradient)" stroke="#1A202C" stroke-width="2"/><rect x="175" y="80" width="40" height="70" rx="10" ry="10" fill="#2D3748" stroke="#4A5568" stroke-width="2"/>
</svg>
</div>
<div id="connection-status-indicator" class="mt-8">
<button id="configure-btn" class="mt-6 text-gray-500 tracking-widest text-sm font-semibold hover:text-white transition-colors">CONFIGURE →</button>
</div>
</div>
<div class="absolute bottom-6 right-8 text-right text-xs text-gray-600 font-semibold leading-tight">
<p>DEV VERSION UI V1</p><p>SPARKLAB</p><p>RIGHTS RESERVED</p>
</div>
</main>
<!-- Configure View -->
<main id="configure-view" class="view h-full p-8 space-x-8">
<div class="w-2/3 flex flex-col">
<div id="layer-tabs-container" class="flex items-end -mb-px"></div>
<div class="flex-grow bg-black border border-gray-700 rounded-lg rounded-tl-none p-6 flex flex-col space-y-8">
<div>
<h2 class="text-2xl font-bold mb-4">Macro Pad</h2>
<div id="macropad-layout" class="grid grid-cols-3 gap-4 w-max"></div>
</div>
<div>
<h2 class="text-2xl font-bold mb-4">Encoder</h2>
<div class="flex items-center space-x-8">
<div class="encoder-dial"></div>
<div id="encoder-controls" class="space-y-3"></div>
</div>
</div>
</div>
</div>
<div class="w-1/3 bg-[#111111] rounded-lg p-4 overflow-y-auto">
<h3 class="text-xl font-bold mb-4">Keycodes</h3>
<div id="keycode-palette"></div>
</div>
</main>
<!-- Macros View -->
<main id="macros-view" class="view h-full p-8 space-x-8">
<!-- Left: Macro List -->
<div class="w-1/3 bg-[#111111] rounded-lg p-4 flex flex-col">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-bold">Macros</h3>
<button id="new-macro-btn" class="px-3 py-1 bg-blue-600 rounded hover:bg-blue-500 text-sm font-semibold">New</button>
</div>
<div id="macro-list" class="flex-grow overflow-y-auto space-y-2"></div>
</div>
<!-- Right: Macro Editor -->
<div id="macro-editor" class="w-2/3 flex-col hidden">
<div class="flex justify-between items-center mb-4">
<h3 id="macro-editor-title" class="text-2xl font-bold"></h3>
<div class="flex space-x-2">
<button id="record-macro-btn" class="px-4 py-2 bg-red-600 rounded hover:bg-red-500 font-semibold"><i class="fa-solid fa-circle mr-2"></i>Record</button>
<button id="add-action-btn" class="px-4 py-2 bg-green-600 rounded hover:bg-green-500 font-semibold"><i class="fa-solid fa-plus mr-2"></i>Add Action</button>
</div>
</div>
<div id="macro-action-list" class="flex-grow bg-black border border-gray-700 rounded-lg p-4 space-y-2 overflow-y-auto"></div>
</div>
<div id="macro-editor-placeholder" class="w-2/3 flex justify-center items-center text-gray-500">
<p>Select a macro or create a new one to begin.</p>
</div>
</main>
<!-- Lighting View -->
<main id="lighting-view" class="view h-full p-8 space-x-8">
<div class="w-1/3 bg-[#111111] rounded-lg p-4 flex flex-col">
<h3 class="text-xl font-bold mb-4">Lighting Targets</h3>
<div id="lighting-target-list" class="flex-grow overflow-y-auto space-y-2"></div>
</div>
<div class="w-2/3 flex flex-col items-center justify-center">
<div id="lighting-visualizer" class="mb-8">
<!-- SVG LED Ring will be generated here -->
</div>
<div id="lighting-color-picker-area" class="flex flex-col items-center hidden">
<h4 id="lighting-target-name" class="text-lg font-semibold mb-2"></h4>
<input type="color" id="lighting-color-picker" class="w-48 h-24 cursor-pointer">
</div>
</div>
</main>
<!-- Settings View -->
<main id="settings-view" class="view h-full p-8 flex-col max-w-4xl mx-auto">
<h2 class="text-3xl font-bold mb-6">Settings</h2>
<div class="space-y-8">
<!-- Profile Management -->
<div>
<h3 class="text-xl font-bold mb-4 text-gray-300">Profile Management</h3>
<div class="bg-[#111111] p-4 rounded-lg space-y-4">
<div class="flex justify-between items-center">
<div>
<h4 class="font-semibold">Export Configuration</h4>
<p class="text-sm text-gray-500">Save all your layers, macros, and settings to a JSON file.</p>
</div>
<button id="export-btn" class="px-4 py-2 bg-blue-600 rounded hover:bg-blue-500 font-semibold">Export Profile</button>
</div>
<div class="border-t border-gray-700"></div>
<div class="flex justify-between items-center">
<div>
<h4 class="font-semibold">Import Configuration</h4>
<p class="text-sm text-gray-500">Load a profile from a JSON file. This will overwrite current settings.</p>
</div>
<button id="import-btn" class="px-4 py-2 bg-gray-600 rounded hover:bg-gray-500 font-semibold">Import Profile</button>
</div>
</div>
</div>
<!-- Application Settings -->
<div>
<h3 class="text-xl font-bold mb-4 text-gray-300">Application</h4>
<div class="bg-[#111111] p-4 rounded-lg space-y-4">
<label for="start-with-os" class="flex justify-between items-center cursor-pointer">
<div>
<h4 class="font-semibold">Start with OS</h4>
<p class="text-sm text-gray-500">Automatically launch Nomi when you log in.</p>
</div>
<div class="relative"><input type="checkbox" id="start-with-os" class="sr-only"><div class="toggle-bg bg-gray-700 border-2 border-transparent h-6 w-11 rounded-full"></div></div>
</label>
<label for="check-updates" class="flex justify-between items-center cursor-pointer">
<div>
<h4 class="font-semibold">Check for Updates</h4>
<p class="text-sm text-gray-500">Automatically check for new software versions on startup.</p>
</div>
<div class="relative"><input type="checkbox" id="check-updates" class="sr-only" checked><div class="toggle-bg bg-gray-700 border-2 border-transparent h-6 w-11 rounded-full"></div></div>
</label>
</div>
</div>
<!-- Device Info -->
<div>
<h3 class="text-xl font-bold mb-4 text-gray-300">Device</h4>
<div class="bg-[#111111] p-4 rounded-lg">
<p class="text-sm"><span class="font-semibold text-gray-400">Device:</span> <span id="device-name">Nomi Macro Pad</span></p>
<p class="text-sm"><span class="font-semibold text-gray-400">Firmware Version:</span> <span id="firmware-version">1.0.0 (Placeholder)</span></p>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- Modals -->
<div id="new-macro-modal" class="modal absolute inset-0 bg-black bg-opacity-75 justify-center items-center z-30">
<div class="bg-[#18181B] p-6 rounded-lg shadow-xl w-full max-w-md">
<h3 class="text-xl font-bold mb-4">New Macro</h3>
<input type="text" id="new-macro-name-input" class="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 mb-4" placeholder="Enter macro name...">
<div class="flex justify-end space-x-3"><button id="new-macro-cancel" class="px-4 py-2 bg-gray-600 rounded hover:bg-gray-500">Cancel</button><button id="new-macro-save" class="px-4 py-2 bg-blue-600 rounded hover:bg-blue-500">Create</button></div>
</div>
</div>
<!-- Link App Modal with updated styling for dropdown -->
<div id="link-app-modal" class="modal absolute inset-0 bg-black bg-opacity-75 justify-center items-center z-30">
<div class="bg-[#18181B] p-6 rounded-lg shadow-xl w-full max-w-md">
<h3 class="text-xl font-bold mb-4">Link Layer to Application</h3>
<label class="text-sm font-semibold text-gray-400">Select from running applications</label>
<div class="flex space-x-2 mt-1 mb-4">
<select id="link-app-select" class="w-full bg-[#27272A] border border-gray-600 rounded-md px-3 py-2">
<option value="">-- Select an App --</option>
</select>
<button id="link-app-refresh-btn" class="p-2 bg-gray-600 rounded-md hover:bg-gray-500" title="Refresh List">
<i class="fa-solid fa-sync"></i>
</button>
</div>
<label class="text-sm font-semibold text-gray-400">Or enter executable name manually</label>
<input type="text" id="link-app-input" class="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 mt-1" placeholder="e.g., Photoshop.exe">
<div class="flex justify-end space-x-3 mt-6">
<button id="link-app-cancel" class="px-4 py-2 bg-gray-600 rounded-md hover:bg-gray-500">Cancel</button>
<button id="link-app-save" class="px-4 py-2 bg-blue-600 rounded-md hover:bg-blue-500">Save</button>
</div>
</div>
</div>
<div id="add-action-modal" class="modal absolute inset-0 bg-black bg-opacity-75 justify-center items-center z-30">
<div class="bg-[#18181B] p-6 rounded-lg shadow-xl w-full max-w-md">
<h3 class="text-xl font-bold mb-4">Add Macro Action</h3>
<div class="space-y-4">
<div>
<label class="font-semibold">Action Type</label>
<div id="action-type-selector" class="grid grid-cols-3 gap-2 mt-2">
<button data-action="keystroke" class="action-type-btn interactive-element p-3 flex-col text-xs"><i class="fa-solid fa-keyboard fa-lg mb-1"></i>Keystroke</button>
<button data-action="text" class="action-type-btn interactive-element p-3 flex-col text-xs"><i class="fa-solid fa-font fa-lg mb-1"></i>Text</button>
<button data-action="delay" class="action-type-btn interactive-element p-3 flex-col text-xs"><i class="fa-solid fa-clock fa-lg mb-1"></i>Delay</button>
</div>
</div>
<div id="action-config-area"></div>
</div>
<div class="flex justify-end space-x-3 mt-6"><button id="add-action-cancel" class="px-4 py-2 bg-gray-600 rounded hover:bg-gray-500">Cancel</button><button id="add-action-save" class="px-4 py-2 bg-blue-600 rounded hover:bg-blue-500">Add Action</button></div>
</div>
</div>
<!-- Layer Right-Click Context Menu -->
<div id="context-menu" class="absolute bg-[#18181B] rounded-md shadow-xl z-40 flex-col text-sm py-1">
<button id="ctx-rename" class="w-full text-left px-4 py-2 hover:bg-gray-700">Rename</button>
<div class="px-4 py-2">
<label for="ctx-color-input" class="text-xs text-gray-400 block mb-1">Tag Color</label>
<input type="color" id="ctx-color-input" class="w-full h-8 cursor-pointer">
</div>
<div class="border-t border-gray-700 my-1"></div>
<button id="ctx-clear-color" class="w-full text-left px-4 py-2 hover:bg-gray-700">Clear Color</button>
</div>
<!-- Hidden file input for import -->
<input type="file" id="import-file-input" class="hidden" accept=".json">
<script>
// Establish Socket.IO connection
const socket = io();
document.addEventListener('DOMContentLoaded', () => {
// --- STATE MANAGEMENT ---
const MAX_LAYERS = 6;
const KEY_MAP = { 'Control': 'CTRL', 'Shift': 'SHFT', 'Alt': 'ALT', 'Meta': 'WIN', 'Enter': 'ENT', 'Backspace': 'BKSP', 'Delete': 'DEL', 'Tab': 'TAB', 'Escape': 'ESC', 'ArrowUp': 'UP', 'ArrowDown': 'DOWN', 'ArrowLeft': 'LEFT', 'ArrowRight': 'RGHT', ' ': 'SPACE' };
const DEFAULT_KEYCODES = {
"Basic": ['A', 'B', 'C', '1', '2', '3', 'ENT', 'ESC', 'BKSP', 'TAB', 'SPACE', 'SHFT', 'CTRL', 'ALT', 'WIN'],
"System": ['MUTE', 'VOLU', 'VOLD', 'PLAY', 'STOP', 'NEXT', 'PREV'],
"Navigation": ['UP', 'DOWN', 'LEFT', 'RGHT', 'HOME', 'END'],
"Layers": ['Lyr 0', 'Lyr 1', 'Lyr 2', 'Lyr 3', 'Lyr 4', 'Lyr 5']
};
let state = {
connected: false,
currentView: 'home',
currentLayer: 0,
selectedControl: { type: null, index: null },
linkingLayerIndex: null,
contextMenu: { active: false, targetLayer: null },
selectedMacro: null,
isRecording: false,
newAction: { type: null, value: null },
draggedAction: { fromIndex: null },
selectedLightingTarget: { type: null, index: null },
layers: [],
macros: [],
keycodes: DEFAULT_KEYCODES
};
// --- DOM ELEMENTS ---
const allViews = document.querySelectorAll('.view');
const navIcons = document.querySelectorAll('.nav-icon');
const connectBtn = document.getElementById('connect-btn');
const configureBtn = document.getElementById('configure-btn');
const layerTabsContainer = document.getElementById('layer-tabs-container');
const macropadLayoutContainer = document.getElementById('macropad-layout');
const encoderControlsContainer = document.getElementById('encoder-controls');
const keycodePaletteContainer = document.getElementById('keycode-palette');
const connectionText = document.getElementById('connection-text');
const portDropdown = document.getElementById('port-dropdown');
const connectionStatusIndicator = document.getElementById('connection-status-indicator');
// Modals
const newMacroModal = document.getElementById('new-macro-modal');
const newMacroNameInput = document.getElementById('new-macro-name-input');
const newMacroSaveBtn = document.getElementById('new-macro-save');
const newMacroCancelBtn = document.getElementById('new-macro-cancel');
const linkAppModal = document.getElementById('link-app-modal');
const linkAppInput = document.getElementById('link-app-input');
const linkAppSelect = document.getElementById('link-app-select');
const linkAppRefreshBtn = document.getElementById('link-app-refresh-btn');
const linkAppSaveBtn = document.getElementById('link-app-save');
const linkAppCancelBtn = document.getElementById('link-app-cancel');
const addActionModal = document.getElementById('add-action-modal');
const addActionSaveBtn = document.getElementById('add-action-save');
const addActionCancelBtn = document.getElementById('add-action-cancel');
const actionTypeSelector = document.getElementById('action-type-selector');
const actionConfigArea = document.getElementById('action-config-area');
// Context Menu
const contextMenu = document.getElementById('context-menu');
const ctxRenameBtn = document.getElementById('ctx-rename');
const ctxColorInput = document.getElementById('ctx-color-input');
const ctxClearColorBtn = document.getElementById('ctx-clear-color');
// Macro View
const macroListContainer = document.getElementById('macro-list');
const newMacroBtn = document.getElementById('new-macro-btn');
const macroEditor = document.getElementById('macro-editor');
const macroEditorPlaceholder = document.getElementById('macro-editor-placeholder');
const macroEditorTitle = document.getElementById('macro-editor-title');
const recordMacroBtn = document.getElementById('record-macro-btn');
const addActionBtn = document.getElementById('add-action-btn');
const macroActionListContainer = document.getElementById('macro-action-list');
// Settings & Lighting
const exportBtn = document.getElementById('export-btn');
const importBtn = document.getElementById('import-btn');
const importFileInput = document.getElementById('import-file-input');
const lightingTargetList = document.getElementById('lighting-target-list');
const lightingVisualizer = document.getElementById('lighting-visualizer');
const lightingColorPickerArea = document.getElementById('lighting-color-picker-area');
const lightingColorPicker = document.getElementById('lighting-color-picker');
const lightingTargetName = document.getElementById('lighting-target-name');
// --- SOCKET.IO EVENT LISTENERS ---
socket.on('connect', () => {
console.log('Connected to backend server.');
});
socket.on('full_profile', (data) => {
console.log('Received full profile from backend.');
state.layers = data.layers || [];
state.macros = data.macros || [];
state.keycodes = data.keycodes || DEFAULT_KEYCODES;
state.currentLayer = data.currentLayer || 0;
if (state.layers.length === 0) {
addLayer();
} else {
renderAll();
}
});
socket.on('device_status', (data) => {
state.connected = data.status === 'connected';
if (state.connected) {
connectionText.textContent = `CONNECTED: ${data.port}`;
connectionText.classList.add('text-green-400');
connectionStatusIndicator.innerHTML = `<span class="bg-green-500 text-black text-sm font-bold px-5 py-2 rounded-full inline-flex items-center space-x-2"><i class="fa-solid fa-link"></i><span>CONNECTED</span></span>`;
setTimeout(() => switchView('configure'), 500);
} else {
connectionText.textContent = data.status === 'error' ? `FAILED: ${data.port}` : 'NO DEVICE CONNECTED';
connectionText.classList.remove('text-green-400');
connectionStatusIndicator.innerHTML = `<button id="configure-btn" class="mt-6 text-gray-500 tracking-widest text-sm font-semibold hover:text-white transition-colors">CONFIGURE →</button>`;
}
});
socket.on('port_list', (data) => {
portDropdown.innerHTML = '';
if (data.ports.length > 0) {
data.ports.forEach(port => {
const item = document.createElement('a');
item.href = '#';
item.className = 'block px-4 py-2 text-sm text-gray-300 hover:bg-gray-700';
item.textContent = `${port.device}: ${port.description}`;
item.addEventListener('click', (e) => {
e.preventDefault();
socket.emit('connect_device', { port: port.device });
portDropdown.classList.add('hidden');
});
portDropdown.appendChild(item);
});
} else {
portDropdown.innerHTML = '<span class="block px-4 py-2 text-sm text-gray-500">No devices found</span>';
}
portDropdown.classList.remove('hidden');
});
socket.on('layer_changed', (data) => {
state.currentLayer = data.layer;
renderAll();
});
socket.on('running_apps_list', (data) => {
linkAppSelect.innerHTML = '<option value="">-- Select an App --</option>';
if (data && data.apps) {
data.apps.forEach(app => {
const option = document.createElement('option');
option.value = app;
option.textContent = app;
linkAppSelect.appendChild(option);
});
}
});
// --- RENDER FUNCTIONS ---
const renderAll = () => {
if (state.currentView === 'configure') {
renderLayerTabs();
renderMacroPad();
renderEncoder();
renderKeycodePalette();
} else if (state.currentView === 'macros') {
renderMacroList();
renderMacroEditor();
} else if (state.currentView === 'lighting') {
renderLightingView();
}
};
const renderLayerTabs = () => {
layerTabsContainer.innerHTML = '';
state.layers.forEach((layer, index) => {
const tabEl = document.createElement('div');
tabEl.className = 'layer-tab flex items-center space-x-2 px-4 py-2 text-sm font-semibold text-gray-300 cursor-pointer';
tabEl.dataset.layerIndex = index;
if (index === state.currentLayer) tabEl.classList.add('active');
tabEl.style.borderTopColor = layer.color || '#3F3F46';
const nameEl = document.createElement('span');
nameEl.textContent = layer.name;
nameEl.className = 'layer-name-span';
nameEl.addEventListener('click', () => { state.currentLayer = index; state.selectedControl = { type: null, index: null }; renderAll(); updateProfileOnBackend(); });
nameEl.addEventListener('dblclick', (e) => { e.stopPropagation(); editLayerName(index, nameEl); });
const linkIcon = document.createElement('i');
linkIcon.className = 'fa-solid fa-link text-xs';
linkIcon.classList.toggle('text-gray-500', !layer.linkedApp);
linkIcon.classList.toggle('text-green-500', !!layer.linkedApp);
linkIcon.addEventListener('click', (e) => { e.stopPropagation(); openLinkAppModal(index); });
tabEl.appendChild(nameEl);
tabEl.appendChild(linkIcon);
layerTabsContainer.appendChild(tabEl);
tabEl.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
openContextMenu(e.clientX, e.clientY, index);
});
});
if (state.layers.length < MAX_LAYERS) {
const addLayerBtn = document.createElement('button');
addLayerBtn.className = 'px-3 py-2 text-gray-400 hover:text-white';
addLayerBtn.innerHTML = '<i class="fa-solid fa-plus"></i>';
addLayerBtn.addEventListener('click', addLayer);
layerTabsContainer.appendChild(addLayerBtn);
}
};
const renderMacroPad = () => {
macropadLayoutContainer.innerHTML = '';
const currentKeymap = state.layers[state.currentLayer].keymap;
currentKeymap.forEach((key, index) => {
const keyEl = document.createElement('button');
keyEl.className = 'interactive-element text-lg';
keyEl.textContent = key.k;
keyEl.style.width = '6rem';
keyEl.style.height = '6rem';
if (state.selectedControl.type === 'key' && state.selectedControl.index === index) keyEl.classList.add('selected');
keyEl.addEventListener('click', () => { state.selectedControl = { type: 'key', index: index }; renderAll(); });
macropadLayoutContainer.appendChild(keyEl);
});
};
const renderEncoder = () => {
encoderControlsContainer.innerHTML = '';
const currentEncoderMap = state.layers[state.currentLayer].encoder;
const encoderActions = [ { id: 'cw', label: 'Clockwise' }, { id: 'ccw', label: 'Counter-Clockwise' }, { id: 'press', label: 'Press' } ];
encoderActions.forEach(action => {
const wrapper = document.createElement('div');
wrapper.className = 'flex items-center space-x-4';
const labelEl = document.createElement('span');
labelEl.className = 'w-40 text-gray-400';
labelEl.textContent = action.label;
const btnEl = document.createElement('button');
btnEl.className = 'interactive-element px-4 py-2 w-32';
btnEl.textContent = currentEncoderMap[action.id].k;
if (state.selectedControl.type === 'encoder' && state.selectedControl.index === action.id) btnEl.classList.add('selected');
btnEl.addEventListener('click', () => { state.selectedControl = { type: 'encoder', index: action.id }; renderAll(); });
wrapper.appendChild(labelEl);
wrapper.appendChild(btnEl);
encoderControlsContainer.appendChild(wrapper);
});
};
const renderKeycodePalette = () => {
keycodePaletteContainer.innerHTML = '';
if (!state.keycodes) state.keycodes = DEFAULT_KEYCODES;
const keycodesWithMacros = {...state.keycodes, "Macros": state.macros.map(m => m.name)};
for (const category in keycodesWithMacros) {
const categoryEl = document.createElement('div');
categoryEl.className = 'mb-4';
categoryEl.innerHTML = `<h4 class="font-bold text-gray-400 mb-2">${category}</h4>`;
const gridEl = document.createElement('div');
gridEl.className = 'grid grid-cols-3 gap-2';
keycodesWithMacros[category].forEach(keycode => {
const keycodeBtn = document.createElement('button');
keycodeBtn.className = 'interactive-element p-2 text-xs';
keycodeBtn.textContent = keycode;
keycodeBtn.addEventListener('click', () => assignKeycode(keycode));
gridEl.appendChild(keycodeBtn);
});
categoryEl.appendChild(gridEl);
keycodePaletteContainer.appendChild(categoryEl);
}
};
// --- MACRO RENDER FUNCTIONS ---
const renderMacroList = () => {
macroListContainer.innerHTML = '';
state.macros.forEach((macro, index) => {
const item = document.createElement('div');
item.className = 'flex justify-between items-center p-2 rounded-md cursor-pointer hover:bg-gray-700';
if(state.selectedMacro === index) item.classList.add('bg-blue-600', 'hover:bg-blue-500');
const nameSpan = document.createElement('span');
nameSpan.textContent = macro.name;
item.appendChild(nameSpan);
const deleteBtn = document.createElement('button');
deleteBtn.className = 'text-gray-500 hover:text-red-500 px-2';
deleteBtn.innerHTML = '<i class="fa-solid fa-trash-alt fa-xs"></i>';
deleteBtn.onclick = (e) => { e.stopPropagation(); deleteMacro(index); };
item.appendChild(deleteBtn);
item.onclick = () => { state.selectedMacro = index; renderAll(); };
macroListContainer.appendChild(item);
});
};
const renderMacroEditor = () => {
if (state.selectedMacro === null) {
macroEditor.classList.add('hidden');
macroEditorPlaceholder.classList.remove('hidden');
macroEditorPlaceholder.classList.add('flex');
return;
}
macroEditor.classList.remove('hidden');
macroEditor.classList.add('flex');
macroEditorPlaceholder.classList.add('hidden');
macroEditorPlaceholder.classList.remove('flex');
if (state.isRecording) {
recordMacroBtn.innerHTML = '<i class="fa-solid fa-stop mr-2"></i>Stop';
recordMacroBtn.classList.add('animate-pulse');
} else {
recordMacroBtn.innerHTML = '<i class="fa-solid fa-circle mr-2"></i>Record';
recordMacroBtn.classList.remove('animate-pulse');
}
const macro = state.macros[state.selectedMacro];
macroEditorTitle.textContent = macro.name;
macroActionListContainer.innerHTML = '';
if (macro.actions.length === 0) {
macroActionListContainer.innerHTML = '<p class="text-gray-500 text-center">No actions yet. Click "Add Action" or "Record" to start.</p>';
return;
}
macro.actions.forEach((action, index) => {
const actionEl = document.createElement('div');
actionEl.className = 'draggable-action bg-gray-800 p-3 rounded-md flex items-center justify-between cursor-grab';
actionEl.draggable = true;
actionEl.dataset.actionIndex = index;
let icon, text;
switch(action.type) {
case 'keystroke': icon = 'fa-keyboard'; text = `Key: ${action.value}`; break;
case 'text': icon = 'fa-font'; text = `Text: "${action.value}"`; break;
case 'delay': icon = 'fa-clock'; text = `Delay: ${action.value}ms`; break;
}
actionEl.innerHTML = `
<div class="flex items-center space-x-3 pointer-events-none">
<i class="fa-solid fa-grip-vertical text-gray-500 mr-2"></i>
<i class="fa-solid ${icon} text-gray-400"></i>
<span>${text}</span>
</div>
<button data-action-index="${index}" class="delete-action-btn text-gray-500 hover:text-red-500 px-2">
<i class="fa-solid fa-trash-alt fa-xs"></i>
</button>
`;
macroActionListContainer.appendChild(actionEl);
});
document.querySelectorAll('.delete-action-btn').forEach(btn => {
btn.onclick = () => deleteMacroAction(parseInt(btn.dataset.actionIndex));
});
};
const renderActionConfig = (type) => {
actionConfigArea.innerHTML = '';
let content = '';
switch(type) {
case 'keystroke':
content = `<label class="font-semibold block mb-2">Key</label><input id="action-value-input" type="text" readonly class="w-full bg-gray-700 p-2 rounded cursor-pointer text-center" placeholder="Click here and press a key...">`;
break;
case 'text':
content = `<label class="font-semibold block mb-2">Text to type</label><input id="action-value-input" type="text" class="w-full bg-gray-700 p-2 rounded">`;
break;
case 'delay':
content = `<label class="font-semibold block mb-2">Delay (in milliseconds)</label><input id="action-value-input" type="number" class="w-full bg-gray-700 p-2 rounded" value="100">`;
break;
}
actionConfigArea.innerHTML = content;
if (type === 'keystroke') {
const input = document.getElementById('action-value-input');
input.addEventListener('keydown', handleKeystrokeInput);
}
};
// --- LIGHTING RENDER FUNCTIONS ---
const renderLightingView = () => {
lightingTargetList.innerHTML = '';
const targets = [];
state.layers.forEach((layer, index) => {
targets.push({ type: 'layer', index: index, name: layer.name, subtext: `Layer ${index}` });
if (layer.linkedApp) {
targets.push({ type: 'app', index: index, name: layer.name, subtext: layer.linkedApp });
}
});
targets.forEach(target => {
const item = document.createElement('div');
item.className = 'lighting-list-item flex items-center space-x-3 p-2 rounded-md cursor-pointer hover:bg-gray-700';
item.dataset.targetType = target.type;
item.dataset.targetIndex = target.index;
if (state.selectedLightingTarget.type === target.type && state.selectedLightingTarget.index === target.index) {
item.classList.add('selected');
}
const layer = state.layers[target.index];
item.innerHTML = `
<div class="w-4 h-4 rounded-full" style="background-color: ${layer.color || '#3F3F46'}; border: 1px solid #52525B;"></div>
<div>
<p class="font-semibold">${target.name}</p>
<p class="text-xs text-gray-400">${target.subtext}</p>
</div>
`;
lightingTargetList.appendChild(item);
item.addEventListener('click', () => selectLightingTarget(target.type, target.index));
});
renderLightingVisualizer();
};
const renderLightingVisualizer = () => {
const { type, index } = state.selectedLightingTarget;
let targetLayer = null;
let targetName = "Select a target";
if (type && index !== null) {
targetLayer = state.layers[index];
targetName = targetLayer.linkedApp && type === 'app' ? `${targetLayer.name} (${targetLayer.linkedApp})` : targetLayer.name;
lightingColorPickerArea.classList.remove('hidden');
} else {
lightingColorPickerArea.classList.add('hidden');
}
const ledColor = targetLayer ? targetLayer.ledColor : '#27272A';
lightingTargetName.textContent = targetName;
lightingColorPicker.value = ledColor;
const numDiodes = 12;
const radius = 100;
const center = 120;
let diodesSVG = '';
for (let i = 0; i < numDiodes; i++) {
const angle = (i / numDiodes) * 2 * Math.PI;
const x = center + radius * Math.cos(angle);
const y = center + radius * Math.sin(angle);
diodesSVG += `<circle class="led-diode" cx="${x}" cy="${y}" r="10" fill="${ledColor}" stroke="#18181B" stroke-width="2" />`;
}
lightingVisualizer.innerHTML = `
<svg width="240" height="240" viewbox="0 0 240 240">
<circle cx="${center}" cy="${center}" r="${radius}" fill="none" stroke="#3F3F46" stroke-width="10" />
${diodesSVG}
</svg>
`;
};
// --- LOGIC & EVENT HANDLERS ---
function updateProfileOnBackend() {
socket.emit('update_profile', state);
}
const addLayer = () => {
if (state.layers.length >= MAX_LAYERS) return;
const newLayerIndex = state.layers.length;
state.layers.push({ name: `Layer ${newLayerIndex}`, linkedApp: "", color: null, ledColor: "#FFFFFF", keymap: [ { k: '...' }, { k: '...' }, { k: '...' }, { k: '...' }, { k: '...' }, { k: '...' } ], encoder: { cw: { k: '...' }, ccw: { k: '...' }, press: { k: '...' } } });
state.currentLayer = newLayerIndex;
state.selectedControl = { type: null, index: null };
renderAll();
updateProfileOnBackend();
};
const editLayerName = (index, nameEl) => {
const input = document.createElement('input');
input.type = 'text';
input.value = state.layers[index].name;
input.className = 'bg-gray-800 text-white w-24';
nameEl.replaceWith(input);
input.focus();
input.select();
const save = () => { state.layers[index].name = input.value.trim() || `Layer ${index}`; renderAll(); updateProfileOnBackend(); };
input.addEventListener('blur', save);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') input.blur(); });
};
const openLinkAppModal = (layerIndex) => {
state.linkingLayerIndex = layerIndex;
linkAppInput.value = state.layers[layerIndex].linkedApp;
linkAppModal.classList.add('active');
linkAppInput.focus();
socket.emit('get_running_apps');
};
const closeLinkAppModal = () => linkAppModal.classList.remove('active');
const saveLinkedApp = () => {
if (state.linkingLayerIndex === null) return;
const selectedApp = linkAppSelect.value;
const manualApp = linkAppInput.value.trim();
state.layers[state.linkingLayerIndex].linkedApp = selectedApp || manualApp;
closeLinkAppModal();
renderAll();
updateProfileOnBackend();
};
const openContextMenu = (x, y, layerIndex) => {
closeContextMenu();
state.contextMenu.targetLayer = layerIndex;
contextMenu.style.left = `${x}px`;
contextMenu.style.top = `${y}px`;
ctxColorInput.value = state.layers[layerIndex].color || '#ffffff';
contextMenu.classList.add('active');
state.contextMenu.active = true;
};
const closeContextMenu = () => {
if (!state.contextMenu.active) return;
contextMenu.classList.remove('active');
state.contextMenu.active = false;
state.contextMenu.targetLayer = null;
};
const setLayerColor = (color) => {
if (state.contextMenu.targetLayer !== null) {
state.layers[state.contextMenu.targetLayer].color = color;
renderAll();
updateProfileOnBackend();
}
};
ctxRenameBtn.addEventListener('click', () => {
if (state.contextMenu.targetLayer !== null) {
const tabEl = document.querySelector(`.layer-tab[data-layer-index="${state.contextMenu.targetLayer}"]`);
const nameSpan = tabEl.querySelector('.layer-name-span');
editLayerName(state.contextMenu.targetLayer, nameSpan);
closeContextMenu();
}
});
ctxColorInput.addEventListener('input', (e) => {
if (state.contextMenu.targetLayer !== null) {
const tabEl = document.querySelector(`.layer-tab[data-layer-index="${state.contextMenu.targetLayer}"]`);
if (tabEl) tabEl.style.borderTopColor = e.target.value;
}
});
ctxColorInput.addEventListener('change', (e) => {
setLayerColor(e.target.value);
closeContextMenu();
});
ctxClearColorBtn.addEventListener('click', () => {
setLayerColor(null);
closeContextMenu();
});
const switchView = (viewId) => {
if (state.isRecording) toggleRecording();
state.currentView = viewId;
allViews.forEach(v => v.classList.remove('active'));
document.getElementById(`${viewId}-view`).classList.add('active');
navIcons.forEach(icon => {
icon.classList.toggle('bg-green-500', icon.dataset.view === viewId);
icon.classList.toggle('text-gray-900', icon.dataset.view === viewId);
icon.classList.toggle('text-gray-400', icon.dataset.view !== viewId);
icon.classList.toggle('hover:bg-gray-700', icon.dataset.view !== viewId);
});
renderAll();
};
const assignKeycode = (keycode) => {
const { type, index } = state.selectedControl;
if (!type) { alert("Please select a macro key or encoder action first."); return; }
const currentLayerData = state.layers[state.currentLayer];
if (type === 'key') currentLayerData.keymap[index].k = keycode;
else if (type === 'encoder') currentLayerData.encoder[index].k = keycode;
state.selectedControl = { type: null, index: null };
renderAll();
updateProfileOnBackend();
};
// --- MACRO LOGIC ---
newMacroBtn.onclick = () => {
newMacroNameInput.value = `Macro ${state.macros.length + 1}`;
newMacroModal.classList.add('active');
newMacroNameInput.focus();
newMacroNameInput.select();
};
newMacroCancelBtn.onclick = () => newMacroModal.classList.remove('active');
newMacroSaveBtn.onclick = () => {
const name = newMacroNameInput.value.trim();
if (name) {
state.macros.push({ name, actions: [] });
state.selectedMacro = state.macros.length - 1;
newMacroModal.classList.remove('active');
renderAll();
updateProfileOnBackend();
}
};
const deleteMacro = (index) => {
if (confirm(`Are you sure you want to delete "${state.macros[index].name}"?`)) {
state.macros.splice(index, 1);
if (state.selectedMacro === index) state.selectedMacro = null;
else if (state.selectedMacro > index) state.selectedMacro--;
renderAll();
updateProfileOnBackend();
}
};
addActionBtn.onclick = () => {
state.newAction = { type: 'keystroke', value: null };
renderActionConfig('keystroke');
actionTypeSelector.querySelector('.selected')?.classList.remove('selected');
actionTypeSelector.querySelector('[data-action="keystroke"]').classList.add('selected');
addActionModal.classList.add('active');
};
addActionCancelBtn.onclick = () => addActionModal.classList.remove('active');
actionTypeSelector.onclick = (e) => {
const btn = e.target.closest('.action-type-btn');
if (btn) {
const type = btn.dataset.action;
state.newAction.type = type;
actionTypeSelector.querySelector('.selected')?.classList.remove('selected');
btn.classList.add('selected');
renderActionConfig(type);
}
};
addActionSaveBtn.onclick = () => {
const input = document.getElementById('action-value-input');
if (state.newAction.type === 'keystroke') {
if (!state.newAction.value) { alert("Please press a key first."); return; }
} else {
if (!input.value) { alert("Please enter a value for the action."); return; }
state.newAction.value = input.value;
}
state.macros[state.selectedMacro].actions.push({...state.newAction});
addActionModal.classList.remove('active');
renderAll();
updateProfileOnBackend();
};
const deleteMacroAction = (actionIndex) => {
state.macros[state.selectedMacro].actions.splice(actionIndex, 1);
renderAll();
updateProfileOnBackend();
};
const handleKeystrokeInput = (e) => {
e.preventDefault();
const displayKey = KEY_MAP[e.key] || e.key.toUpperCase();
e.target.value = displayKey;
state.newAction.value = displayKey;
};
// --- MACRO RECORDING LOGIC ---
const handleMacroRecordKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
e.preventDefault();
const displayKey = KEY_MAP[e.key] || e.key.toUpperCase();
state.macros[state.selectedMacro].actions.push({ type: 'keystroke', value: displayKey });
renderMacroEditor();
updateProfileOnBackend();
};
const toggleRecording = () => {
state.isRecording = !state.isRecording;
if (state.isRecording) window.addEventListener('keydown', handleMacroRecordKey, true);
else window.removeEventListener('keydown', handleMacroRecordKey, true);
renderMacroEditor();
};
recordMacroBtn.addEventListener('click', toggleRecording);
// --- DRAG AND DROP LOGIC ---
macroActionListContainer.addEventListener('dragstart', e => {
if (e.target.classList.contains('draggable-action')) {
e.target.classList.add('dragging');
state.draggedAction.fromIndex = parseInt(e.target.dataset.actionIndex);
}
});
macroActionListContainer.addEventListener('dragend', e => {
if (e.target.classList.contains('dragging')) e.target.classList.remove('dragging');
const indicator = macroActionListContainer.querySelector('.drop-indicator');
if (indicator) indicator.remove();
});
macroActionListContainer.addEventListener('dragover', e => {
e.preventDefault();
const indicator = macroActionListContainer.querySelector('.drop-indicator') || document.createElement('div');
indicator.className = 'drop-indicator';
const afterElement = getDragAfterElement(macroActionListContainer, e.clientY);
if (afterElement == null) macroActionListContainer.appendChild(indicator);
else macroActionListContainer.insertBefore(indicator, afterElement);
});
macroActionListContainer.addEventListener('drop', e => {
e.preventDefault();
const indicator = macroActionListContainer.querySelector('.drop-indicator');
if (!indicator) return;
const children = [...macroActionListContainer.querySelectorAll('.draggable-action, .drop-indicator')];
const toIndex = children.indexOf(indicator);
const fromIndex = state.draggedAction.fromIndex;
if (fromIndex === null) return;
const [removed] = state.macros[state.selectedMacro].actions.splice(fromIndex, 1);
state.macros[state.selectedMacro].actions.splice(toIndex, 0, removed);
indicator.remove();
state.draggedAction.fromIndex = null;
renderAll();
updateProfileOnBackend();
});
function getDragAfterElement(container, y) {
const draggableElements = [...container.querySelectorAll('.draggable-action:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) return { offset: offset, element: child };
else return closest;
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
// --- SETTINGS LOGIC ---
exportBtn.addEventListener('click', () => {
const profileData = {
layers: state.layers,
macros: state.macros,
keycodes: state.keycodes
};
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(profileData, null, 2));
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", "Nomi_Profile.json");
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
});
importBtn.addEventListener('click', () => {
importFileInput.click();
});
importFileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
if (!confirm("Importing a profile will overwrite all current settings. Are you sure you want to continue?")) {
e.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (event) => {
try {
const importedState = JSON.parse(event.target.result);
if (Array.isArray(importedState.layers) && Array.isArray(importedState.macros)) {
state.layers = importedState.layers;
state.macros = importedState.macros;
state.keycodes = importedState.keycodes || DEFAULT_KEYCODES;
state.currentLayer = 0;
state.selectedMacro = null;
alert("Profile imported successfully!");
renderAll();
updateProfileOnBackend();
} else {
throw new Error("Invalid profile format.");
}
} catch (error) {
alert("Error: Could not import profile. The file may be corrupt or in the wrong format.");
console.error("Import error:", error);
} finally {
e.target.value = '';
}
};
reader.readAsText(file);
});
// --- LIGHTING LOGIC ---
const selectLightingTarget = (type, index) => {
state.selectedLightingTarget = { type, index };
renderLightingView();
};
lightingColorPicker.addEventListener('input', (e) => {
const { type, index } = state.selectedLightingTarget;
if (type && index !== null) {
state.layers[index].ledColor = e.target.value;
const diodes = lightingVisualizer.querySelectorAll('.led-diode');
diodes.forEach(diode => diode.style.fill = e.target.value);
updateProfileOnBackend();
}
});
// --- GLOBAL LISTENERS ---
navIcons.forEach(icon => icon.addEventListener('click', (e) => { e.preventDefault(); switchView(icon.dataset.view); }));
connectBtn.addEventListener('click', () => socket.emit('scan_ports'));
configureBtn.addEventListener('click', () => {
if (state.connected) switchView('configure');
else alert("Please connect a device first.");
});
linkAppSaveBtn.addEventListener('click', saveLinkedApp);
linkAppCancelBtn.addEventListener('click', closeLinkAppModal);
linkAppRefreshBtn.addEventListener('click', () => socket.emit('get_running_apps'));
linkAppSelect.addEventListener('change', () => {
if (linkAppSelect.value) {
linkAppInput.value = linkAppSelect.value;
}
});
window.addEventListener('click', (e) => {
if (!connectBtn.contains(e.target) && !portDropdown.contains(e.target)) {
portDropdown.classList.add('hidden');
}
if (state.contextMenu.active && !contextMenu.contains(e.target)) {
closeContextMenu();
}
});
window.addEventListener('contextmenu', (e) => {
if (!e.target.closest('.layer-tab')) {
closeContextMenu();
}
});
// --- INITIALIZATION ---
switchView('home');
});
</script>
</body>
</ht