Files
avatar/ui.py
T
lucianoandClaude Fable 5.1 ff79832c30 AvatarPy: assistente personale con volto 3D, voce, memoria e plugin
Riscrittura in Python dell'assistente Avatar con interfaccia HUD (derivata da Mark LIV, CC BY-NC 4.0, vedi NOTICE.md).
Tre motori (Claude API, server locale OpenAI-compatibile, Claude Code), voce Kokoro/macOS, Whisper MLX,
avatar 3D con sincronizzazione labiale, memoria per categorie, allegati con OCR, monitor con avvisi,
plugin per Calendario, Mail, Promemoria, Note, Musica, app, Mac, timer, meteo, contatti, Messaggi,
file, Comandi Rapidi, browser, Telegram, WhatsApp (archivio e tempo reale).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 16:21:39 +02:00

5489 lines
224 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import math
import os
import platform
import random
import subprocess
import sys
import threading
import time
from pathlib import Path
import psutil
if platform.system() == "Windows":
_WIN_HIDE: dict = {"creationflags": subprocess.CREATE_NO_WINDOW}
else:
_WIN_HIDE: dict = {}
from PyQt6.QtCore import (
QEasingCurve, QLineF, QMimeData, QObject, QParallelAnimationGroup, QPointF,
QPropertyAnimation, QRect, QRectF, QSize, Qt, QTimer, QUrl, pyqtSignal,
)
from PyQt6.QtGui import (
QBrush, QColor, QConicalGradient, QDragEnterEvent, QDropEvent, QFont,
QFontDatabase, QKeySequence, QLinearGradient, QPainter, QPainterPath,
QPen, QPixmap, QRadialGradient, QShortcut,
)
from PyQt6.QtWidgets import (
QApplication, QComboBox, QFileDialog, QFrame, QHBoxLayout, QLabel, QLineEdit,
QMainWindow, QPushButton, QScrollArea, QSizePolicy, QSplitter,
QStackedWidget, QTextEdit, QVBoxLayout, QWidget, QProgressBar,
)
try:
from core.avatar import HoloAvatar
except Exception: # pragma: no cover — HUD must never die over cosmetics
HoloAvatar = None
def _base_dir() -> Path:
if getattr(sys, "frozen", False):
return Path(sys.executable).parent
return Path(__file__).resolve().parent
BASE_DIR = _base_dir()
CONFIG_DIR = BASE_DIR / "config"
API_FILE = CONFIG_DIR / "api_keys.json"
def _read_full_config() -> dict:
"""Read api_keys.json config dict. Returns {} on any error."""
try:
return json.loads(API_FILE.read_text(encoding="utf-8"))
except Exception:
return {}
# Single source of truth for the release name — the window title, the header
# badge and the readme must never disagree again.
APP_VERSION = "LuZa"
APP_PROTOCOL = "1.0"
_DEFAULT_W, _DEFAULT_H = 1280, 840
_MIN_W, _MIN_H = 1040, 700
_LEFT_W = 190
_RIGHT_W = 430
_OS = platform.system() # "Windows" | "Darwin" | "Linux"
class C:
BG = "#00060a"
PANEL = "#010d14"
PANEL2 = "#010f18"
BORDER = "#0d3347"
BORDER_B = "#1a5c7a"
BORDER_A = "#0f4060"
PRI = "#00d4ff"
PRI_DIM = "#1aa3c8"
PRI_GHO = "#001f2e"
ACC = "#ff6b00"
ACC2 = "#ffcc00"
GREEN = "#00ff88"
GREEN_D = "#00aa55"
RED = "#ff3355"
MUTED_C = "#ff3366"
TEXT = "#c9fbff"
TEXT_DIM = "#79c0d2"
TEXT_MED = "#a4dcea"
WHITE = "#d8f8ff"
DARK = "#000d14"
BAR_BG = "#011520"
# Keys tied to the accent colour — status colours (ACC, GREEN, RED…) stay fixed
_HUE_LINKED = (
"BG", "PANEL", "PANEL2", "BORDER", "BORDER_B", "BORDER_A",
"PRI", "PRI_DIM", "PRI_GHO", "TEXT", "TEXT_DIM", "TEXT_MED",
"WHITE", "DARK", "BAR_BG",
)
_PALETTE_DEFAULTS: dict[str, str] = {k: getattr(C, k) for k in _HUE_LINKED}
DEFAULT_UI_COLOR = _PALETTE_DEFAULTS["PRI"]
def apply_ui_accent(accent_hex: str) -> bool:
"""
Re-derives the whole teal-family palette from the chosen accent colour
(hue shift — brightness/saturation ratios are preserved, design stays intact).
Painted elements (HUD, waveform, metrics) pick up the new colour on the next
frame; stylesheet-based panels pick it up when they are rebuilt.
"""
import colorsys
accent_hex = (accent_hex or "").strip().lower()
if not (accent_hex.startswith("#") and len(accent_hex) == 7):
return False
try:
int(accent_hex[1:], 16)
except ValueError:
return False
def _hsv(h: str) -> tuple[float, float, float]:
r = int(h[1:3], 16) / 255
g = int(h[3:5], 16) / 255
b = int(h[5:7], 16) / 255
return colorsys.rgb_to_hsv(r, g, b)
base_h = _hsv(_PALETTE_DEFAULTS["PRI"])[0]
acc_h, acc_s, _av = _hsv(accent_hex)
dh = acc_h - base_h
grey = acc_s < 0.08 # near-grey accent → the whole theme is desaturated
for key, hex0 in _PALETTE_DEFAULTS.items():
h, s, v = _hsv(hex0)
if grey:
s *= 0.15
r, g, b = colorsys.hsv_to_rgb((h + dh) % 1.0, s, v)
setattr(C, key, "#{:02x}{:02x}{:02x}".format(
int(r * 255 + 0.5), int(g * 255 + 0.5), int(b * 255 + 0.5)))
return True
def current_palette() -> dict[str, str]:
"""A snapshot of the accent-linked colours currently on class C."""
return {k: getattr(C, k) for k in _HUE_LINKED}
def retheme_all_widgets(old: dict[str, str], new: dict[str, str]) -> None:
"""
LIVE full theme change. Replaces the old palette colours with the new ones
in EVERY widget's stylesheet across the app and repaints them. This way the
colour change applies INSTANTLY across the whole interface — panels, buttons,
borders included — not just the painted elements. No restart needed.
"""
mapping = {old[k].lower(): new[k].lower()
for k in old if old[k].lower() != new.get(k, old[k]).lower()}
if not mapping:
return
app = QApplication.instance()
if app is None:
return
for w in app.allWidgets():
try:
ss = w.styleSheet()
if ss:
s2 = ss
for o, n in mapping.items():
if o in s2:
s2 = s2.replace(o, n)
if s2 != ss:
w.setStyleSheet(s2)
w.update()
except Exception:
pass
def qcol(h: str, a: int = 255) -> QColor:
c = QColor(h); c.setAlpha(a); return c
# ── Windows GPU via NVML DLL (no subprocess, no console window) ──────────────
_nvml_lib: object = None # cached ctypes DLL
_nvml_ok: object = None # None=untested, True=works, False=unavailable
def _nvml_gpu_windows() -> float:
"""Return NVIDIA GPU utilisation % using nvml.dll directly — zero subprocess."""
global _nvml_lib, _nvml_ok
if _nvml_ok is False:
return -1.0
try:
import ctypes
class _Util(ctypes.Structure):
_fields_ = [("gpu", ctypes.c_uint), ("memory", ctypes.c_uint)]
if _nvml_lib is None:
for dll_name in ("nvml", r"C:\Windows\System32\nvml.dll"):
try:
lib = ctypes.WinDLL(dll_name)
lib.nvmlInit_v2()
_nvml_lib = lib
break
except Exception:
continue
if _nvml_lib is None:
import pynvml # type: ignore
pynvml.nvmlInit()
h = pynvml.nvmlDeviceGetHandleByIndex(0)
_nvml_ok = True
return float(pynvml.nvmlDeviceGetUtilizationRates(h).gpu)
dev = ctypes.c_void_p()
_nvml_lib.nvmlDeviceGetHandleByIndex_v2(0, ctypes.byref(dev))
util = _Util()
_nvml_lib.nvmlDeviceGetUtilizationRates(dev, ctypes.byref(util))
_nvml_ok = True
return float(util.gpu)
except Exception:
_nvml_ok = False
return -1.0
class _SysMetrics:
def __init__(self):
self.cpu = 0.0
self.mem = 0.0
self.net = 0.0
self.gpu = -1.0
self.tmp = -1.0
self._lock = threading.Lock()
self._last_net = psutil.net_io_counters()
self._last_net_t = time.time()
self._running = True
# Probe caches — GPU (NVML) and temperature (WMI) are the expensive
# queries; initialise their handles once and reuse them instead of
# rebuilding a connection on every poll.
self._slow_tick = 0 # gpu/temp refreshed every 3rd cycle
self._pynvml = None # cached pynvml module + device handle
self._pynvml_h = None
self._pynvml_ok = None # None=untested, False=unavailable here
self._nv_unix = None # cached (lib, dev) for Linux/macOS NVML
self._wmi_conn = None # cached WMI connection (creating one is slow)
self._wmi_ok = None # None=untested, False=unavailable here
t = threading.Thread(target=self._loop, daemon=True)
t.start()
def _loop(self):
while self._running:
try:
self._update()
except Exception:
pass
time.sleep(2.0)
def _update(self):
cpu = psutil.cpu_percent(interval=None)
mem = psutil.virtual_memory().percent
nc = psutil.net_io_counters()
now = time.time()
dt = now - self._last_net_t
if dt > 0:
sent = (nc.bytes_sent - self._last_net.bytes_sent) / dt
recv = (nc.bytes_recv - self._last_net.bytes_recv) / dt
net = (sent + recv) / (1024 * 1024)
else:
net = 0.0
self._last_net = nc
self._last_net_t = now
# GPU and temperature change slowly and are the most expensive probes
# (NVML / WMI) — refresh them every 3rd cycle (~6 s) instead of every
# cycle, reusing the previous reading in between.
self._slow_tick = (self._slow_tick + 1) % 3
if self._slow_tick == 1:
gpu = self._get_gpu()
tmp = self._get_temp()
else:
gpu = self.gpu
tmp = self.tmp
with self._lock:
self.cpu = cpu
self.mem = mem
self.net = net
self.gpu = gpu
self.tmp = tmp
def _get_gpu(self) -> float:
# pynvml — subprocess-free; initialise once and reuse the handle.
# Re-initialising NVML on every poll is slow, so cache it and stop
# retrying pynvml entirely once it proves unavailable here.
if self._pynvml_ok is not False:
try:
if self._pynvml_h is None:
import pynvml # type: ignore
pynvml.nvmlInit()
self._pynvml = pynvml
self._pynvml_h = pynvml.nvmlDeviceGetHandleByIndex(0)
self._pynvml_ok = True
return float(self._pynvml.nvmlDeviceGetUtilizationRates(self._pynvml_h).gpu)
except Exception:
self._pynvml_ok = False
# Windows: nvml.dll via ctypes (already cached in _nvml_gpu_windows)
if _OS == "Windows":
return _nvml_gpu_windows()
# Linux / macOS: libnvidia-ml shared lib via ctypes — init once, reuse
try:
import ctypes
class _Util(ctypes.Structure):
_fields_ = [("gpu", ctypes.c_uint), ("memory", ctypes.c_uint)]
if self._nv_unix is None:
_lib = "libnvidia-ml.so.1" if _OS == "Linux" else "libnvidia-ml.dylib"
nv = ctypes.CDLL(_lib)
nv.nvmlInit_v2()
dev = ctypes.c_void_p()
nv.nvmlDeviceGetHandleByIndex_v2(0, ctypes.byref(dev))
self._nv_unix = (nv, dev)
nv, dev = self._nv_unix
u = _Util()
nv.nvmlDeviceGetUtilizationRates(dev, ctypes.byref(u))
return float(u.gpu)
except Exception:
pass
return -1.0 # N/A — zero subprocess on all platforms
def _get_temp(self) -> float:
# psutil — works on Linux; occasionally Windows with driver support
try:
temps = psutil.sensors_temperatures()
for name in ["coretemp", "k10temp", "cpu_thermal", "acpitz",
"cpu-thermal", "zenpower", "it8688"]:
if name in temps and temps[name]:
return temps[name][0].current
for entries in temps.values():
if entries:
return entries[0].current
except Exception:
pass
# Windows: wmi module (pure Python COM, zero subprocess). Reuse a single
# connection — building a fresh wmi.WMI() on every poll spins up a COM
# connection each time and is very slow. Give up after one failure.
if _OS == "Windows" and self._wmi_ok is not False:
try:
if self._wmi_conn is None:
import wmi # type: ignore
self._wmi_conn = wmi.WMI(namespace="root/wmi")
tz = self._wmi_conn.MSAcpi_ThermalZoneTemperature()
if tz:
return (tz[0].CurrentTemperature / 10.0) - 273.15
except Exception:
self._wmi_ok = False
self._wmi_conn = None
return -1.0 # N/A — zero subprocess on all platforms
def snapshot(self) -> dict:
with self._lock:
return {
"cpu": self.cpu,
"mem": self.mem,
"net": self.net,
"gpu": self.gpu,
"tmp": self.tmp,
}
_metrics = _SysMetrics()
class HudCanvas(QWidget):
def __init__(self, face_path: str, assistant_name: str = "J.A.R.V.I.S", parent=None):
super().__init__(parent)
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent)
self.setMinimumSize(300, 300)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.muted = False
self.speaking = False
self.state = "INITIALISING"
self._assistant_name = assistant_name
# The holographic head that fills the HUD. If it could not be imported
# we fall back to the old glowing core so the panel is never empty.
self._avatar = None
if HoloAvatar is not None:
try:
self._avatar = HoloAvatar()
except Exception:
self._avatar = None
# Which centrepiece to draw. Read once here and changed live by the
# settings toggle; the avatar object is kept either way so switching
# back is instant and costs no reload.
try:
from memory.config_manager import get_hud_style
self.hud_style = get_hud_style()
except Exception:
self.hud_style = "face"
self._core_phase = 0.0
self._tick = 0
self._scale = 1.0
self._tgt_scale = 1.0
self._halo = 55.0
self._tgt_halo = 55.0
self._last_t = time.time()
self._step_t = time.time()
self._blink = True
self._blink_tick = 0
# Rescaled-face cache: the smooth rescale is expensive, so we keep the
# last result and only rebuild it when the (quantised) size changes.
# Static grid-dot layer, pre-rendered once per size/theme into a pixmap
# so paintEvent blits it in one call instead of thousands of drawPoint()s.
self._grid_cache: QPixmap | None = None
self._grid_key = None
# Repaint throttle counter (idle frames drop to ~20 Hz — see _step()).
self._paint_tick = 0
# Live audio reactivity: _live_amp is written from the audio threads
# (0.0–1.0), _amp_disp is the smoothed value the paint code reads.
self._live_amp = 0.0
self._amp_disp = 0.0
# (frames, start_time, hop) posted by the playback thread — see
# push_visemes(). None means "no schedule; use the plain level".
self._visemes = None
self._vis_i = None # first schedule frame not yet handed to the mouth
self._base_scale = 1.0 # slow "breathing" target; amp is added per-frame
self._base_halo = 55.0
self._tmr = QTimer(self)
self._tmr.timeout.connect(self._step)
self._tmr.start(16)
def glance(self, dx: float, dy: float, hold: float = 1.1) -> None:
"""Ask the avatar to look somewhere for a moment (see HoloAvatar.glance)."""
try:
if self._avatar is not None:
self._avatar.glance(dx, dy, hold)
except Exception:
pass
def push_visemes(self, frames, hop: float, at: float) -> None:
"""Thread-safe: hand over a schedule of (level, openness, width) frames.
The playback thread writes up to 200 ms of audio in one go, so a single
averaged level would only move the mouth five times a second — enough to
flap, nowhere near enough to articulate. It instead posts the whole
slice's worth of 20 ms frames here and `_step()` plays them out against
the wall clock, in step with the audio going to the speakers.
`at` is the wall-clock time this batch will *begin to sound*, which the
caller tracks as a playback cursor. It is not the time of the call, and
the difference is the whole point: `stream.write` returns once the buffer
accepts the samples, so consecutive batches are handed over far faster
than they play. Anchoring each one to "now" made every batch start while
its predecessor was still sounding, so each schedule replaced the last
after a couple of frames and the mouth only ever played the opening
instant of every 200 ms — the reason it did not match the words.
Successive batches are therefore *appended* into one continuous
timeline, not swapped in. A paragraph is one schedule; the mouth stops
falling into a gap at every chunk boundary and having to climb back out.
"""
try:
if not frames:
return
hop = max(1e-3, float(hop))
at = float(at)
new = list(frames)
cur = self._visemes
if cur is not None:
old, t0, ohop = cur
if abs(ohop - hop) < 1e-6:
# Where in the existing timeline does this batch land?
i = int(round((at - t0) / hop))
if 0 <= i <= len(old) + 1:
# Continues (or slightly overlaps) what is already
# queued: extend rather than restart. Drop whatever has
# already been played so the list cannot grow without
# bound over a long reply.
merged = old[:i] + new
played = int((time.time() - t0) / hop) - 2
if played > 60:
merged = merged[played:]
t0 += played * hop
if self._vis_i is not None:
self._vis_i = max(0, self._vis_i - played)
self._visemes = (merged, t0, hop)
return
self._visemes = (new, at, hop)
self._vis_i = None
except Exception:
pass
def set_audio_level(self, level: float) -> None:
"""Thread-safe entry point for the audio threads. Stores the louder of
the incoming level and the current value so brief gaps between chunks
don't make the waveform stutter; _step() decays it back down."""
try:
lv = float(level)
except (TypeError, ValueError):
return
if lv < 0.0:
lv = 0.0
elif lv > 1.0:
lv = 1.0
if lv > self._live_amp:
self._live_amp = lv
def _make_grid(self, W: int, H: int) -> QPixmap:
"""Pre-render the static grid-dot background into a transparent pixmap so
paintEvent can blit it once per frame instead of running a nested
drawPoint() loop across the whole widget every 16 ms."""
pm = QPixmap(max(1, W), max(1, H))
pm.fill(Qt.GlobalColor.transparent)
gp = QPainter(pm)
gp.setPen(QPen(qcol(C.PRI_GHO), 1))
for x in range(0, W, 48):
for y in range(0, H, 48):
gp.drawPoint(x, y)
gp.end()
return pm
def _step(self):
self._tick += 1
now = time.time()
# ── Live audio reactivity ────────────────────────────────────────────
# A viseme schedule, if one is playing, gives both the level and the
# mouth shape for this exact instant; otherwise fall back to the peak
# level the audio threads pushed in.
v_open = v_wide = v_level = None
v_seq = None
sched = self._visemes
if sched is not None:
frames, t0, hop = sched
i = int((now - t0) / hop)
if 0 <= i < len(frames):
# Hand over *every* frame since the last tick, not just the one
# under the cursor. This timer runs at 60 Hz but the paint is
# throttled and the machine may be busy, so a tick can span two
# or three 20 ms frames — and a consonant closure is only two
# frames long. Sampling one and discarding the rest is how the
# closures between words went missing.
j = self._vis_i if self._vis_i is not None else i
v_seq = frames[max(0, j):i + 1]
self._vis_i = max(j, i + 1)
v_level, v_open, v_wide = frames[i]
if v_seq:
peak = max(f[0] for f in v_seq)
if peak > self._live_amp:
self._live_amp = peak
elif i >= len(frames):
self._visemes = None # schedule spent
self._vis_i = None
# Audio threads push peaks into _live_amp; decay it toward silence so
# gaps between chunks fade out instead of freezing, then smooth it.
self._live_amp *= 0.86
self._amp_disp += (self._live_amp - self._amp_disp) * 0.45
amp = self._amp_disp
# The avatar animates off the very same smoothed level the waveform
# uses — one audio source, so the mouth can never drift out of sync.
dt = now - self._step_t
self._step_t = now
# Integrated, not derived from absolute time: multiplying wall-clock by
# a rate that changes with state jumps the rings the instant JARVIS
# starts talking. Same lesson the head's sway taught.
self._core_phase += min(0.10, max(0.0, dt))
if self._avatar is not None and self.hud_style == "face":
self._avatar.step(dt, amp, speaking=self.speaking,
muted=self.muted, state=self.state,
v_open=v_open, v_wide=v_wide or 0.0,
v_level=v_level, v_seq=v_seq,
v_hop=(sched[2] if sched is not None else 0.02))
else:
# Fallback core: slow "breathing" base target, lifted by the level.
if now - self._last_t > (0.12 if self.speaking else 0.5):
if self.speaking:
self._base_scale = 1.03
self._base_halo = 122.0
elif self.muted:
self._base_scale = random.uniform(0.998, 1.002)
self._base_halo = random.uniform(15, 28)
else:
self._base_scale = random.uniform(1.001, 1.008)
self._base_halo = random.uniform(48, 68)
self._last_t = now
if self.muted:
self._tgt_scale, self._tgt_halo = self._base_scale, self._base_halo
elif self.speaking:
self._tgt_scale = self._base_scale + amp * 0.13
self._tgt_halo = self._base_halo + amp * 95.0
else:
self._tgt_scale = self._base_scale + amp * 0.06
self._tgt_halo = self._base_halo + amp * 75.0
sp = 0.38 if self.speaking else (0.30 if amp > 0.02 else 0.15)
self._scale += (self._tgt_scale - self._scale) * sp
self._halo += (self._tgt_halo - self._halo) * sp
self._blink_tick += 1
if self._blink_tick >= 38:
self._blink = not self._blink
self._blink_tick = 0
_blinked = True
else:
_blinked = False
# Repaint throttling — advancing the animation state above is cheap at
# 60 Hz, but the paint is heavy. Active (speaking, audio, thinking) runs
# at ~30 Hz, which is the frame rate animation has used for talking
# characters forever and is indistinguishable here; idle drops to ~20 Hz
# so a sleeping HUD stops pinning a CPU core. The visuals stay smooth
# either way because the animation state keeps stepping at 60 Hz.
self._paint_tick = (self._paint_tick + 1) % 6
active = (self.speaking or amp > 0.02
or self.state in ("THINKING", "PROCESSING"))
if _blinked or (self._paint_tick % 2 == 0 if active
else self._paint_tick % 3 == 0):
# Nothing is on screen when the window is hidden or minimised, so
# rendering the avatar into it is pure waste — and this app is meant
# to sit running all day. The animation state above keeps stepping,
# so it picks up mid-motion instead of snapping when you come back.
if self._on_screen():
self.update()
def _on_screen(self) -> bool:
"""True only when this canvas can actually be seen by the user."""
try:
if not self.isVisible():
return False
win = self.window()
return not (win.isMinimized() or win.isHidden())
except Exception:
return True # never let a visibility check stop the HUD drawing
# ── reactor core ─────────────────────────────────────────────────────────
# The centrepiece for anyone who did not want a face looking back at them.
# Built from the same budget as the head — software QPainter, no GPU — and
# from the same principle: everything on it means something. The rings turn
# at a rate the state sets, the spectrum ring is the real audio level, and
# the core brightens with the voice. Nothing here is decoration that moves
# for its own sake, which is what made the old glowing orb feel dead.
def _core_colours(self):
if self.muted:
return qcol(C.MUTED_C), qcol(C.MUTED_C)
if self.speaking:
return qcol(C.PRI), qcol(C.ACC)
if self.state in ("THINKING", "PROCESSING"):
return qcol(C.PRI), qcol(C.ACC2)
if self.state == "LISTENING":
return qcol(C.PRI), qcol(C.GREEN)
return qcol(C.PRI), qcol(C.PRI_DIM)
def _paint_core(self, p: QPainter, cx: float, cy: float, r: float,
W: float = 0.0, H: float = 0.0):
"""Draw the reactor at (cx, cy) with outer radius r, using the whole
canvas (W x H) for the marks that frame it."""
main, acc = self._core_colours()
bg = qcol(C.BG)
amp = self._amp_disp
t = self._core_phase
live = (self.speaking or amp > 0.04) and not self.muted
def blend(col: QColor, a: float) -> QColor:
"""Pre-mix onto the background instead of asking Qt to composite.
The raster engine's opaque path is several times faster than its
translucent one, and everything here is a line or an arc."""
k = max(0.0, min(1.0, a))
return QColor(int(bg.red() + (col.red() - bg.red()) * k),
int(bg.green() + (col.green() - bg.green()) * k),
int(bg.blue() + (col.blue() - bg.blue()) * k))
p.setBrush(Qt.BrushStyle.NoBrush)
# 1. The atmosphere. One radial gradient doing what a stack of discs did
# badly: a wide, soft body of light that gives the thing presence
# before any detail is read. This single element decides whether the
# HUD looks vast or looks small, so it is drawn first and drawn big.
# Concentrated rather than spread: a gradient reaching the outer rim
# washes the whole disc a flat dim blue and reads as fog. Ending it at
# two thirds leaves it a body of light with somewhere to fall off to,
# which is what makes it look lit rather than tinted.
lift = 1.0 + 0.55 * amp + (0.18 if self.speaking else 0.0)
p.setPen(Qt.PenStyle.NoPen)
for gr, a0, a1 in ((r * 0.70, 0.30, 0.0), (r * 0.34, 0.34, 0.0)):
g = QRadialGradient(cx, cy, gr)
g.setColorAt(0.00, blend(main, min(0.95, a0 * lift)))
g.setColorAt(0.45, blend(main, min(0.95, a0 * lift * 0.52)))
g.setColorAt(0.78, blend(main, min(0.95, a0 * lift * 0.18)))
g.setColorAt(1.00, blend(main, a1))
p.setBrush(QBrush(g))
p.drawEllipse(QRectF(cx - gr, cy - gr, gr * 2, gr * 2))
p.setBrush(Qt.BrushStyle.NoBrush)
# 2. Frame marks at the corners of the whole canvas, not of the circle.
# They are what set the scale: the eye reads the reactor as filling
# the room rather than sitting in the middle of it.
if W > 40 and H > 40:
m, arm = min(W, H) * 0.035, min(W, H) * 0.055
p.setPen(QPen(blend(main, 0.45), 1.4))
for sx, sy in ((1, 1), (-1, 1), (1, -1), (-1, -1)):
x = cx + sx * (W / 2 - m)
y = cy + sy * (H / 2 - m)
p.drawLine(QLineF(x, y, x - sx * arm, y))
p.drawLine(QLineF(x, y, x, y - sy * arm))
# 3. Crosshair across the full canvas, broken around the core so it
# frames the reactor rather than crossing it.
p.setPen(QPen(blend(main, 0.16), 1))
gap = r * 0.62
if W > 40:
p.drawLine(QLineF(cx - W / 2, cy, cx - gap, cy))
p.drawLine(QLineF(cx + gap, cy, cx + W / 2, cy))
if H > 40:
p.drawLine(QLineF(cx, cy - H / 2, cx, cy - gap))
p.drawLine(QLineF(cx, cy + gap, cx, cy + H / 2))
# 4. Two thin outer circles. Sparse on purpose — a dense ring reads as a
# grey band at this size, and restraint is what made the original
# look expensive.
for rr, a in ((1.00, 0.34), (0.93, 0.16)):
rad = r * rr
p.setPen(QPen(blend(main, a), 1))
p.drawEllipse(QRectF(cx - rad, cy - rad, rad * 2, rad * 2))
# 5. Long, sparse graduations: 24 majors reaching well in from the rim,
# with shorter minors between them.
major, minor = [], []
for i in range(72):
a = math.radians(i * 5.0)
ca, sa = math.cos(a), math.sin(a)
if i % 3 == 0:
major.append(QLineF(cx + ca * r * 0.885, cy + sa * r * 0.885,
cx + ca * r * 0.985, cy + sa * r * 0.985))
else:
minor.append(QLineF(cx + ca * r * 0.945, cy + sa * r * 0.945,
cx + ca * r * 0.985, cy + sa * r * 0.985))
p.setPen(QPen(blend(main, 0.42), 1.3))
p.drawLines(major)
p.setPen(QPen(blend(main, 0.18), 1))
p.drawLines(minor)
# 6. Sweeping arcs. Long spans, not dashes — the original's grandeur
# came from a few big strokes. Speed is the state: idle drifts,
# thinking hurries, speaking runs.
rate = 1.0 + (1.9 if self.state in ("THINKING", "PROCESSING") else 0.0) \
+ (1.2 if self.speaking else 0.0)
for k, (rr, span, count, dirn, col, a, wid) in enumerate((
(0.955, 118, 2, +1, acc, 0.75, 2.0),
(0.845, 82, 3, -1, main, 0.38, 1.3),
(0.760, 150, 1, +1, acc, 0.45, 1.6),
(0.660, 64, 4, -1, main, 0.26, 1.1),
(0.545, 128, 2, +1, main, 0.30, 1.2))):
rad = r * rr
p.setPen(QPen(blend(col, a), wid))
box = QRectF(cx - rad, cy - rad, rad * 2, rad * 2)
base = (t * rate * (9 + k * 6) * dirn) % 360.0
for sgm in range(count):
p.drawArc(box, int((base + sgm * (360.0 / count)) * 16),
int(span * 16))
# 7. The voice, as a ring of graduations that grow with it. Kept out at
# a wide radius so it never crowds the middle.
n = 60
ring = r * 0.415
spikes = []
for i in range(n):
a = math.radians(i * (360.0 / n))
ca, sa = math.cos(a), math.sin(a)
wob = 0.5 + 0.5 * math.sin(t * 2.3 + i * 0.42)
idle = 0.018 + 0.012 * math.sin(t * 1.2 + i * 0.7)
h = r * (idle + (amp * 0.20 * wob if live else 0.0))
spikes.append(QLineF(cx + ca * ring, cy + sa * ring,
cx + ca * (ring + h), cy + sa * (ring + h)))
p.setPen(QPen(blend(acc if live else main, 0.25 + 0.5 * amp), 1.6))
p.drawLines(spikes)
# 8. The inner ring the name sits in.
inner = r * 0.355
p.setPen(QPen(blend(acc, 0.30 + 0.45 * amp), 1.5))
p.drawEllipse(QRectF(cx - inner, cy - inner, inner * 2, inner * 2))
# 9. The name, sized from the string rather than from the radius alone:
# "J.A.R.V.I.S" and a name someone renamed to "MAX" are very
# different widths, and a fixed fraction of r spills one of them past
# the ring it is supposed to sit inside.
name = self._assistant_name or ""
if name:
space = max(1.0, r * 0.018)
fsz = max(8, int(min(r * 0.105,
(inner * 1.75) / max(1, len(name)) * 1.6 - space)))
f = QFont("Menlo", fsz, QFont.Weight.Bold)
f.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, space)
p.setFont(f)
p.setPen(QPen(blend(qcol(C.WHITE), 0.6 + 0.4 * min(1.0, amp * 2)), 1))
p.drawText(QRectF(cx - r, cy - fsz, r * 2, fsz * 2),
Qt.AlignmentFlag.AlignCenter, name)
def paintEvent(self, _):
p = QPainter(self)
if not p.isActive(): # device not ready (e.g. 0-size during layout) — skip cleanly
return
p.setRenderHint(QPainter.RenderHint.Antialiasing)
p.fillRect(self.rect(), qcol(C.BG))
W, H = self.width(), self.height()
cx, cy = W / 2, H / 2
fw = min(W, H)
# grid dots — blitted from a cached layer; rebuilt only when the size
# or the theme's ghost colour changes (so live re-theming still works).
_gkey = (W, H, C.PRI_GHO)
if self._grid_cache is None or self._grid_key != _gkey:
self._grid_cache = self._make_grid(W, H)
self._grid_key = _gkey
p.drawPixmap(0, 0, self._grid_cache)
# ── holographic head ────────────────────────────────────────────────
# Sized to the band between the top of the canvas and the status line,
# capped by width, so it fills the HUD at any window size — including
# fullscreen — without ever colliding with the status text below.
_sy_status = cy + fw * 0.40
if self._avatar is not None and self.hud_style == "face":
_band_t = 12.0
_band_h = max(60.0, _sy_status - 12.0 - _band_t)
_r_head = min(fw * 0.355, _band_h / (self._avatar.SPAN + 0.08))
_head_cy = _band_t + (_band_h - self._avatar.SPAN * _r_head) / 2.0 + _r_head
if self.muted:
_main = _acc = qcol(C.MUTED_C)
else:
_main = qcol(C.PRI)
if self.speaking:
_acc = qcol(C.ACC)
elif self.state in ("THINKING", "PROCESSING"):
_acc = qcol(C.ACC2)
elif self.state == "LISTENING":
_acc = qcol(C.GREEN)
else:
_acc = qcol(C.PRI)
self._avatar.paint(p, cx, _head_cy, _r_head, _main, _acc, qcol(C.BG))
# reactor core — the other centrepiece, and the fallback if the head
# could not be built. There is no third path: the old face.png branch
# was unreachable (no such file ships) and the bare orb it fell through
# to is what this replaces.
else:
_band_t = 12.0
_band_h = max(60.0, _sy_status - 12.0 - _band_t)
_r = min(W * 0.46, _band_h / 2.0)
self._paint_core(p, cx, _band_t + _band_h / 2.0, _r, W, _band_h)
# status text
sy = _sy_status
if self.muted:
txt, col = "⊘ MUTED", qcol(C.MUTED_C)
elif self.speaking:
txt, col = "● SPEAKING", qcol(C.ACC)
elif self.state == "THINKING":
sym = "◈" if self._blink else "◇"
txt, col = f"{sym} THINKING", qcol(C.ACC2)
elif self.state == "PROCESSING":
sym = "▷" if self._blink else "▶"
txt, col = f"{sym} PROCESSING", qcol(C.ACC2)
elif self.state == "LISTENING":
sym = "●" if self._blink else "○"
txt, col = f"{sym} LISTENING", qcol(C.GREEN)
else:
sym = "●" if self._blink else "○"
txt, col = f"{sym} {self.state}", qcol(C.PRI)
p.setPen(QPen(col, 1))
p.setFont(QFont("Menlo", 14, QFont.Weight.Bold))
p.drawText(QRectF(0, sy, W, 26), Qt.AlignmentFlag.AlignCenter, txt)
# waveform — reacts to the real audio level (mic while listening,
# JARVIS's own voice while speaking). Falls back to a gentle idle
# ripple when there's no sound. _amp_disp is the smoothed 0–1 level.
wy = sy + 30
N, bw = 36, 8
wx0 = (W - N * bw) / 2
amp = self._amp_disp
mid = (N - 1) / 2.0
for i in range(N):
if self.muted:
hgt, cl = 2, qcol(C.MUTED_C)
else:
env = (1.0 - abs(i - mid) / mid) ** 0.7 # center-weighted hump
shimmer = 0.55 + 0.45 * math.sin(self._tick * 0.18 + i * 0.7)
idle = 3.0 + 2.0 * math.sin(self._tick * 0.09 + i * 0.6)
hgt = int(max(2, min(24, idle + amp * 22.0 * env * shimmer)))
if amp > 0.05:
cl = qcol(C.PRI) if hgt > 12 else qcol(C.PRI_DIM)
else:
cl = qcol(C.BORDER_B)
p.fillRect(QRectF(wx0 + i * bw, wy + 20 - hgt, bw - 1, hgt), cl)
p.end() # end deterministically so the backing store never flushes an active painter
class MetricBar(QWidget):
def __init__(self, label: str, color: str = C.PRI, parent=None):
super().__init__(parent)
self._label = label
self._color = color
self._value = 0.0 # 0–100
self._text = "--"
self.setFixedHeight(38)
self.setMinimumWidth(80)
def set_value(self, pct: float, text: str):
v = max(0.0, min(100.0, pct))
if v == self._value and text == self._text:
return # unchanged — skip the repaint
self._value = v
self._text = text
self.update()
def paintEvent(self, _):
p = QPainter(self)
if not p.isActive():
return
p.setRenderHint(QPainter.RenderHint.Antialiasing)
W, H = self.width(), self.height()
p.setBrush(QBrush(qcol(C.PANEL2)))
p.setPen(QPen(qcol(C.BORDER_A), 1))
p.drawRoundedRect(QRectF(1, 1, W - 2, H - 2), 4, 4)
bar_h = 4
bar_y = H - bar_h - 5
bar_w = W - 12
bar_x = 6
fill_w = int(bar_w * self._value / 100)
p.setBrush(QBrush(qcol(C.BAR_BG)))
p.setPen(Qt.PenStyle.NoPen)
p.drawRoundedRect(QRectF(bar_x, bar_y, bar_w, bar_h), 2, 2)
if self._value > 85:
bar_col = qcol(C.RED)
elif self._value > 65:
bar_col = qcol(C.ACC)
else:
bar_col = qcol(self._color)
if fill_w > 0:
p.setBrush(QBrush(bar_col))
p.drawRoundedRect(QRectF(bar_x, bar_y, fill_w, bar_h), 2, 2)
p.setFont(QFont("Menlo", 10, QFont.Weight.Bold))
p.setPen(QPen(qcol(C.TEXT_DIM), 1))
p.drawText(QRectF(8, 5, 50, 14), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._label)
p.setFont(QFont("Menlo", 12, QFont.Weight.Bold))
p.setPen(QPen(bar_col if self._text != "--" else qcol(C.TEXT_DIM), 1))
p.drawText(QRectF(0, 4, W - 6, 16), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, self._text)
p.end()
class LogWidget(QTextEdit):
_sig = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setReadOnly(True)
# Cap scrollback so an hours-long session can't grow the document
# without bound — keeps memory flat and every insert cheap. Oldest
# lines drop off the top automatically.
self.document().setMaximumBlockCount(600)
self.setFont(QFont("Menlo", 12))
self.setStyleSheet(f"""
QTextEdit {{
background: {C.PANEL};
color: {C.TEXT};
border: 1px solid {C.BORDER};
border-radius: 4px;
padding: 6px;
selection-background-color: {C.PRI_GHO};
}}
QScrollBar:vertical {{
background: {C.BG};
width: 8px;
border: none;
}}
QScrollBar::handle:vertical {{
background: {C.BORDER_B};
border-radius: 4px;
min-height: 20px;
}}
""")
self._queue: list[str] = []
self._typing = False
self._text = ""
self._pos = 0
self._tag = "sys"
self._ai_name_lc = "jarvis" # updated when assistant name changes
self._tmr = QTimer(self)
self._tmr.timeout.connect(self._step)
self._sig.connect(self._enqueue)
def append_log(self, text: str):
self._sig.emit(text)
def _enqueue(self, text: str):
self._queue.append(text)
if not self._typing:
self._next()
def _next(self):
if not self._queue:
self._typing = False
return
self._typing = True
self._text = self._queue.pop(0)
self._pos = 0
tl = self._text.lower()
_ai_pfx = f"{self._ai_name_lc}:"
if tl.startswith("you:"): self._tag = "you"
elif tl.startswith(_ai_pfx) or tl.startswith("jarvis:"): self._tag = "ai"
elif tl.startswith("file:"): self._tag = "file"
elif "err" in tl: self._tag = "err"
else: self._tag = "sys"
self._tmr.start(6)
def _step(self):
if self._pos < len(self._text):
ch = self._text[self._pos]
cur = self.textCursor()
fmt = cur.charFormat()
col = {
"you": qcol(C.WHITE),
"ai": qcol(C.PRI),
"err": qcol(C.RED),
"file": qcol(C.GREEN),
# SYS lines are the bulk of the log. Amber fought the cyan HUD
# and, being a fixed status colour rather than a hue-linked one,
# stayed amber even after the accent picker retinted everything
# else. TEXT_MED follows the theme and drops the contrast to a
# level you can read past.
"sys": qcol(C.TEXT_MED),
}.get(self._tag, qcol(C.TEXT))
fmt.setForeground(QBrush(col))
cur.movePosition(cur.MoveOperation.End)
cur.insertText(ch, fmt)
self.setTextCursor(cur)
self.ensureCursorVisible()
self._pos += 1
else:
self._tmr.stop()
cur = self.textCursor()
cur.movePosition(cur.MoveOperation.End)
cur.insertText("\n")
self.setTextCursor(cur)
self.ensureCursorVisible()
QTimer.singleShot(20, self._next)
_FILE_ICONS = {
"image": ("🖼", "#00d4ff"), "video": ("🎬", "#ff6b00"),
"audio": ("🎵", "#cc44ff"), "pdf": ("📄", "#ff4444"),
"word": ("📝", "#4488ff"), "excel": ("📊", "#44bb44"),
"code": ("💻", "#ffcc00"), "archive": ("📦", "#ff8844"),
"pptx": ("📊", "#ff6622"), "text": ("📃", "#aaaaaa"),
"data": ("🔧", "#88ddff"), "unknown": ("📎", "#888888"),
}
_EXT_TO_CAT = {
**dict.fromkeys(["jpg","jpeg","png","gif","webp","bmp","tiff","svg","ico"], "image"),
**dict.fromkeys(["mp4","avi","mov","mkv","wmv","flv","webm","m4v"], "video"),
**dict.fromkeys(["mp3","wav","ogg","m4a","aac","flac","wma","opus"], "audio"),
**dict.fromkeys(["pdf"], "pdf"),
**dict.fromkeys(["doc","docx"], "word"),
**dict.fromkeys(["xls","xlsx","ods"], "excel"),
**dict.fromkeys(["ppt","pptx"], "pptx"),
**dict.fromkeys(["py","js","ts","jsx","tsx","html","css","java","c","cpp",
"cs","go","rs","rb","php","swift","kt","sh","sql","lua"], "code"),
**dict.fromkeys(["zip","rar","tar","gz","7z","bz2","xz"], "archive"),
**dict.fromkeys(["txt","md","rst","log"], "text"),
**dict.fromkeys(["csv","tsv","json","xml"], "data"),
}
def _file_category(path: Path) -> str:
return _EXT_TO_CAT.get(path.suffix.lower().lstrip("."), "unknown")
def _fmt_size(size: int) -> str:
if size < 1024: return f"{size} B"
elif size < 1024**2: return f"{size/1024:.1f} KB"
elif size < 1024**3: return f"{size/1024**2:.1f} MB"
else: return f"{size/1024**3:.1f} GB"
class FileDropZone(QWidget):
file_selected = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setAcceptDrops(True)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setFixedHeight(100)
self._current_file: str | None = None
self._hovering = False
self._drag_over = False
self._dash_offset = 0.0
self._anim_tmr = QTimer(self)
self._anim_tmr.timeout.connect(self._animate)
self._anim_tmr.start(40)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self._canvas = _DropCanvas(self)
layout.addWidget(self._canvas)
def _animate(self):
# The marching-ants dashed border is only meaningful while the user is
# hovering or dragging a file over the zone. When idle, skip the repaint
# entirely instead of redrawing the whole zone 25×/s forever — that idle
# repaint held the GIL and stole time from the audio/response threads.
if not (self._hovering or self._drag_over):
return
self._dash_offset = (self._dash_offset + 0.8) % 20
self._canvas.update()
def dragEnterEvent(self, e: QDragEnterEvent):
if e.mimeData().hasUrls():
e.acceptProposedAction()
self._drag_over = True; self._canvas.update()
def dragLeaveEvent(self, e):
self._drag_over = False; self._canvas.update()
def dropEvent(self, e: QDropEvent):
self._drag_over = False
urls = e.mimeData().urls()
if urls:
path = urls[0].toLocalFile()
if Path(path).is_file():
self._set_file(path)
self._canvas.update()
def mousePressEvent(self, e):
if e.button() == Qt.MouseButton.LeftButton:
self._browse()
def enterEvent(self, e):
self._hovering = True; self._canvas.update()
def leaveEvent(self, e):
self._hovering = False; self._canvas.update()
def current_file(self) -> str | None:
return self._current_file
def clear_file(self):
self._current_file = None; self._canvas.update()
def _browse(self):
path, _ = QFileDialog.getOpenFileName(
self, "Seleziona un file", str(Path.home()),
"All Files (*.*);;"
"Images (*.jpg *.jpeg *.png *.gif *.webp *.bmp *.svg);;"
"Documents (*.pdf *.docx *.txt *.md *.pptx);;"
"Data (*.csv *.xlsx *.json *.xml);;"
"Code (*.py *.js *.ts *.html *.css *.java *.cpp *.go);;"
"Audio (*.mp3 *.wav *.ogg *.m4a *.aac *.flac);;"
"Video (*.mp4 *.avi *.mov *.mkv *.wmv *.webm);;"
"Archives (*.zip *.rar *.tar *.gz *.7z)",
)
if path:
self._set_file(path)
def _set_file(self, path: str):
self._current_file = path
self._canvas.update()
self.file_selected.emit(path)
class _DropCanvas(QWidget):
def __init__(self, zone: FileDropZone):
super().__init__(zone)
self._z = zone
def paintEvent(self, _):
p = QPainter(self)
if not p.isActive():
return
p.setRenderHint(QPainter.RenderHint.Antialiasing)
z = self._z
W, H = self.width(), self.height()
pad = 6
rect = QRectF(pad, pad, W - pad * 2, H - pad * 2)
bg_col = qcol("#001a24" if z._drag_over else ("#001218" if z._hovering else C.PANEL))
p.setBrush(QBrush(bg_col)); p.setPen(Qt.PenStyle.NoPen)
p.drawRoundedRect(rect, 6, 6)
if z._current_file: border_col = qcol(C.GREEN, 200)
elif z._drag_over: border_col = qcol(C.PRI, 230)
elif z._hovering: border_col = qcol(C.BORDER_B, 200)
else: border_col = qcol(C.BORDER, 160)
pen = QPen(border_col, 1.5, Qt.PenStyle.DashLine)
pen.setDashOffset(z._dash_offset)
p.setPen(pen); p.setBrush(Qt.BrushStyle.NoBrush)
p.drawRoundedRect(rect, 6, 6)
if z._current_file: self._paint_file(p, W, H)
elif z._drag_over: self._paint_drag_over(p, W, H)
else: self._paint_idle(p, W, H, z._hovering)
p.end()
def _paint_idle(self, p, W, H, hover):
cx, cy = W / 2, H / 2
col = qcol(C.PRI_DIM if not hover else C.PRI)
p.setPen(QPen(col, 2)); p.setBrush(Qt.BrushStyle.NoBrush)
p.drawLine(QPointF(cx, cy - 14), QPointF(cx, cy + 4))
p.drawLine(QPointF(cx - 8, cy - 6), QPointF(cx, cy - 14))
p.drawLine(QPointF(cx + 8, cy - 6), QPointF(cx, cy - 14))
p.drawLine(QPointF(cx - 14, cy + 4), QPointF(cx + 14, cy + 4))
p.setFont(QFont("Menlo", 11))
p.setPen(QPen(qcol(C.PRI_DIM if not hover else C.TEXT), 1))
p.drawText(QRectF(0, cy + 8, W, 16), Qt.AlignmentFlag.AlignCenter,
"Drop file here or Click to Browse")
p.setFont(QFont("Menlo", 10))
p.setPen(QPen(qcol("#1a4a5a"), 1))
p.drawText(QRectF(0, cy + 24, W, 14), Qt.AlignmentFlag.AlignCenter,
"Images · Video · Audio · PDF · Docs · Code · Data")
def _paint_drag_over(self, p, W, H):
cx, cy = W / 2, H / 2
p.setFont(QFont("Menlo", 20))
p.setPen(QPen(qcol(C.PRI), 1))
p.drawText(QRectF(0, cy - 24, W, 32), Qt.AlignmentFlag.AlignCenter, "⬇")
p.setFont(QFont("Menlo", 11, QFont.Weight.Bold))
p.setPen(QPen(qcol(C.PRI), 1))
p.drawText(QRectF(0, cy + 12, W, 16), Qt.AlignmentFlag.AlignCenter, "Release to load")
def _paint_file(self, p, W, H):
path = Path(self._z._current_file)
cat = _file_category(path)
icon, icon_col = _FILE_ICONS.get(cat, _FILE_ICONS["unknown"])
size_str = _fmt_size(path.stat().st_size)
ext_str = path.suffix.upper().lstrip(".") or "FILE"
block_x, block_w = 10, 60
p.setFont(QFont("Segoe UI Emoji", 22) if _OS == "Windows" else QFont("Arial", 22))
p.setPen(QPen(qcol(icon_col), 1))
p.drawText(QRectF(block_x, 0, block_w, H), Qt.AlignmentFlag.AlignCenter, icon)
tx = block_x + block_w + 6
tw = W - tx - 38
p.setFont(QFont("Menlo", 11, QFont.Weight.Bold))
p.setPen(QPen(qcol(C.WHITE), 1))
name = path.name if len(path.name) <= 34 else path.name[:31] + "..."
p.drawText(QRectF(tx, H * 0.18, tw, 16),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, name)
p.setFont(QFont("Menlo", 10))
p.setPen(QPen(qcol(C.TEXT_DIM), 1))
p.drawText(QRectF(tx, H * 0.18 + 18, tw, 14),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
f"{ext_str} · {size_str}")
p.setFont(QFont("Menlo", 9))
p.setPen(QPen(qcol("#1e5c6a"), 1))
par = str(path.parent)
if len(par) > 42: par = "…" + par[-41:]
p.drawText(QRectF(tx, H * 0.18 + 34, tw, 12),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, par)
p.setFont(QFont("Menlo", 12, QFont.Weight.Bold))
p.setPen(QPen(qcol(C.RED, 180), 1))
p.drawText(QRectF(W - 34, 0, 28, H), Qt.AlignmentFlag.AlignCenter, "✕")
def mousePressEvent(self, e):
z = self._z
if z._current_file and e.pos().x() > self.width() - 34:
z.clear_file()
else:
z.mousePressEvent(e)
class _CameraPreview(QWidget):
"""Floating overlay that briefly shows what the camera captured."""
_W, _H = 244, 188
def __init__(self, parent=None):
super().__init__(parent)
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(f"""
_CameraPreview {{
background: rgba(0, 6, 10, 242);
border: 1px solid {C.PRI};
border-radius: 6px;
}}
""")
self.setFixedWidth(self._W)
lay = QVBoxLayout(self)
lay.setContentsMargins(6, 5, 6, 6)
lay.setSpacing(4)
hdr = QHBoxLayout()
title = QLabel("◈ VISUAL INPUT")
title.setFont(QFont("Menlo", 10, QFont.Weight.Bold))
title.setStyleSheet(f"color: {C.PRI}; background: transparent;")
hdr.addWidget(title)
hdr.addStretch()
close_btn = QPushButton("✕")
close_btn.setFixedSize(16, 16)
close_btn.setFont(QFont("Menlo", 11))
close_btn.setStyleSheet(
f"color: {C.TEXT_DIM}; background: transparent; border: none;"
)
close_btn.setCursor(Qt.CursorShape.PointingHandCursor)
close_btn.clicked.connect(self.hide)
hdr.addWidget(close_btn)
lay.addLayout(hdr)
self._img_lbl = QLabel()
self._img_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._img_lbl.setStyleSheet("background: transparent;")
lay.addWidget(self._img_lbl)
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self.hide)
self.hide()
def show_frame(self, img_bytes: bytes) -> None:
px = QPixmap()
px.loadFromData(img_bytes)
if not px.isNull():
max_w = self._W - 12
scaled = px.scaled(
max_w, 160,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
self._img_lbl.setPixmap(scaled)
self._img_lbl.setFixedSize(scaled.width(), scaled.height())
self.adjustSize()
self.show()
self.raise_()
self._timer.start(6_000) # auto-dismiss after 6 s
class SetupOverlay(QWidget):
done = pyqtSignal(str, str)
def __init__(self, parent=None):
super().__init__(parent)
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(f"""
SetupOverlay {{
background: rgba(0, 6, 10, 245);
border: 1px solid {C.BORDER_B};
border-radius: 6px;
}}
""")
detected = {"darwin": "mac", "windows": "windows"}.get(
_OS.lower(), "linux"
)
self._sel_os = detected
layout = QVBoxLayout(self)
layout.setContentsMargins(30, 22, 30, 22)
layout.setSpacing(8)
def _lbl(txt, font_size=12, bold=False, color=C.PRI,
align=Qt.AlignmentFlag.AlignCenter):
w = QLabel(txt)
w.setAlignment(align)
w.setFont(QFont("Menlo", font_size,
QFont.Weight.Bold if bold else QFont.Weight.Normal))
w.setStyleSheet(f"color: {color}; background: transparent;")
return w
layout.addWidget(_lbl("◈ INITIALISATION REQUIRED", 16, True))
layout.addWidget(_lbl("Configura l'assistente prima del primo avvio.", 12, color=C.PRI_DIM))
layout.addSpacing(6)
sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet(f"color: {C.BORDER};"); layout.addWidget(sep)
layout.addSpacing(4)
layout.addWidget(_lbl("GEMINI API KEY", 11, color=C.TEXT_DIM,
align=Qt.AlignmentFlag.AlignLeft))
self._key_input = QLineEdit()
self._key_input.setEchoMode(QLineEdit.EchoMode.Password)
self._key_input.setPlaceholderText("AIza…")
self._key_input.setFont(QFont("Menlo", 13))
self._key_input.setFixedHeight(32)
self._key_input.setStyleSheet(f"""
QLineEdit {{
background: #000d12; color: {C.TEXT};
border: 1px solid {C.BORDER}; border-radius: 3px; padding: 4px 8px;
}}
QLineEdit:focus {{ border: 1px solid {C.PRI}; }}
""")
layout.addWidget(self._key_input)
layout.addSpacing(12)
sep2 = QFrame(); sep2.setFrameShape(QFrame.Shape.HLine)
sep2.setStyleSheet(f"color: {C.BORDER};"); layout.addWidget(sep2)
layout.addSpacing(4)
layout.addWidget(_lbl("OPERATING SYSTEM", 11, color=C.TEXT_DIM,
align=Qt.AlignmentFlag.AlignLeft))
det_name = {"windows": "Windows", "mac": "macOS", "linux": "Linux"}[detected]
layout.addWidget(_lbl(f"Auto-detected: {det_name}", 8, color=C.ACC2,
align=Qt.AlignmentFlag.AlignLeft))
os_row = QHBoxLayout(); os_row.setSpacing(6)
self._os_btns: dict[str, QPushButton] = {}
for key, label in [("windows","⊞ Windows"),("mac"," macOS"),("linux","🐧 Linux")]:
btn = QPushButton(label)
btn.setFont(QFont("Menlo", 12, QFont.Weight.Bold))
btn.setFixedHeight(32)
btn.setCursor(Qt.CursorShape.PointingHandCursor)
btn.clicked.connect(lambda _, k=key: self._sel(k))
os_row.addWidget(btn)
self._os_btns[key] = btn
layout.addLayout(os_row)
self._sel(detected)
layout.addSpacing(12)
init_btn = QPushButton("▸ INITIALISE SYSTEMS")
init_btn.setFont(QFont("Menlo", 13, QFont.Weight.Bold))
init_btn.setFixedHeight(36)
init_btn.setCursor(Qt.CursorShape.PointingHandCursor)
init_btn.setStyleSheet(f"""
QPushButton {{
background: transparent; color: {C.PRI};
border: 1px solid {C.PRI_DIM}; border-radius: 3px;
}}
QPushButton:hover {{
background: {C.PRI_GHO}; border: 1px solid {C.PRI};
}}
""")
init_btn.clicked.connect(self._submit)
layout.addWidget(init_btn)
def _sel(self, key: str):
self._sel_os = key
pal = {"windows":(C.PRI,"#001a22"),"mac":(C.ACC2,"#1a1400"),"linux":(C.GREEN,"#001a0d")}
for k, btn in self._os_btns.items():
if k == key:
fg, bg = pal[k]
btn.setStyleSheet(f"""
QPushButton {{
background: {fg}; color: {bg};
border: none; border-radius: 3px; font-weight: bold;
}}
""")
else:
btn.setStyleSheet(f"""
QPushButton {{
background: #000d12; color: {C.TEXT_DIM};
border: 1px solid {C.BORDER}; border-radius: 3px;
}}
QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }}
""")
def _submit(self):
key = self._key_input.text().strip()
if not key:
self._key_input.setStyleSheet(
self._key_input.styleSheet() +
f" QLineEdit {{ border: 1px solid {C.RED}; }}"
)
return
self.done.emit(key, self._sel_os)
class HueWheel(QWidget):
"""
Circular colour picker. The user drags the handle (small white circle)
around the wheel to choose from ALL hues. The filled circle in the centre
is a live preview of the selected colour.
"""
hue_picked = pyqtSignal(str) # while dragging (live)
hue_committed = pyqtSignal(str) # when the handle is released
_RING = 16 # ring thickness (px)
def __init__(self, initial_hex: str = DEFAULT_UI_COLOR, parent=None):
super().__init__(parent)
self.setFixedSize(148, 148)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self._hue = 0.53
self._drag = False
self.set_color(initial_hex)
# ── API ──────────────────────────────────────────────────────────────────
def color(self) -> str:
return QColor.fromHsvF(self._hue, 1.0, 1.0).name()
def set_color(self, hex_str: str):
c = QColor((hex_str or "").strip())
if c.isValid() and c.hsvHueF() >= 0:
self._hue = c.hsvHueF()
self.update()
# ── geometry helpers ─────────────────────────────────────────────────────
def _ring_rect(self) -> QRectF:
m = self._RING / 2 + 3
return QRectF(self.rect()).adjusted(m, m, -m, -m)
def _hue_from_pos(self, pos: QPointF) -> float:
c = QRectF(self.rect()).center()
dx = pos.x() - c.x()
dy = c.y() - pos.y() # screen y goes down — flip to math axis
ang = math.atan2(dy, dx) # [-π, π], counter-clockwise
return (ang / (2 * math.pi)) % 1.0
# ── drawing ──────────────────────────────────────────────────────────────
def paintEvent(self, _):
p = QPainter(self)
if not p.isActive():
return
p.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = self._ring_rect()
center = rect.center()
grad = QConicalGradient(center, 0)
for i in range(0, 361, 20):
grad.setColorAt(i / 360.0, QColor.fromHsvF((i % 360) / 360.0, 1.0, 1.0))
p.setPen(QPen(QBrush(grad), self._RING))
p.setBrush(Qt.BrushStyle.NoBrush)
p.drawEllipse(rect)
# centre preview circle
preview = QColor.fromHsvF(self._hue, 1.0, 1.0)
inner = rect.adjusted(30, 30, -30, -30)
p.setPen(QPen(qcol(C.BORDER_B), 1))
p.setBrush(QBrush(preview))
p.drawEllipse(inner)
# draggable handle
r = rect.width() / 2
ang = self._hue * 2 * math.pi
hx = center.x() + r * math.cos(ang)
hy = center.y() - r * math.sin(ang)
p.setPen(QPen(QColor("#00060a"), 2))
p.setBrush(QBrush(QColor("#ffffff")))
p.drawEllipse(QPointF(hx, hy), 7.5, 7.5)
p.end()
# ── fare ─────────────────────────────────────────────────────────────────
def mousePressEvent(self, e):
self._drag = True
self._hue = self._hue_from_pos(e.position())
self.update()
self.hue_picked.emit(self.color())
def mouseMoveEvent(self, e):
if self._drag:
self._hue = self._hue_from_pos(e.position())
self.update()
self.hue_picked.emit(self.color())
def mouseReleaseEvent(self, e):
if self._drag:
self._drag = False
self.hue_committed.emit(self.color())
class CustomizeOverlay(QWidget):
"""Floating overlay — change assistant name, user name, UI colour and voice."""
saved = pyqtSignal(str, str, str, str) # assistant_name, user_name, ui_color, voice
_OW, _OH = 400, 588
def __init__(self, assistant_name="Ava", user_name="",
ui_color=DEFAULT_UI_COLOR, voice="", parent=None):
super().__init__(parent)
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(f"""
CustomizeOverlay {{
background: rgba(0, 6, 10, 245);
border: 1px solid {C.BORDER_B};
border-radius: 6px;
}}
""")
lay = QVBoxLayout(self)
lay.setContentsMargins(24, 18, 24, 18)
lay.setSpacing(8)
def _lbl(txt, fs=9, bold=False, color=C.PRI, align=Qt.AlignmentFlag.AlignCenter):
w = QLabel(txt); w.setAlignment(align)
w.setFont(QFont("Menlo", fs,
QFont.Weight.Bold if bold else QFont.Weight.Normal))
w.setStyleSheet(f"color: {color}; background: transparent;")
return w
_fs = (f"QLineEdit {{ background: #000d12; color: {C.TEXT}; "
f"border: 1px solid {C.BORDER}; border-radius: 3px; padding: 4px 8px; }}"
f"QLineEdit:focus {{ border: 1px solid {C.PRI}; }}")
lay.addWidget(_lbl("⚙ CUSTOMISE ASSISTANT", 15, True))
sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet(f"color: {C.BORDER}; margin: 2px 0;")
lay.addWidget(sep)
lay.addWidget(_lbl("ASSISTANT NAME", 11, color=C.TEXT_DIM,
align=Qt.AlignmentFlag.AlignLeft))
self._name_input = QLineEdit(assistant_name)
self._name_input.setFont(QFont("Menlo", 13))
self._name_input.setFixedHeight(32)
self._name_input.setStyleSheet(_fs)
lay.addWidget(self._name_input)
lay.addSpacing(4)
lay.addWidget(_lbl("YOUR NAME (leave blank for default sir / efendim)", 11,
color=C.TEXT_DIM, align=Qt.AlignmentFlag.AlignLeft))
self._user_input = QLineEdit(user_name)
self._user_input.setPlaceholderText("e.g. Tony (leave blank for auto)")
self._user_input.setFont(QFont("Menlo", 13))
self._user_input.setFixedHeight(32)
self._user_input.setStyleSheet(_fs)
lay.addWidget(self._user_input)
# ── Assistant voice — Gemini prebuilt voices ─────────────────────────
# Names are language-neutral proper nouns, so the row reads the same in
# every locale. Selecting one and applying rebuilds the Live session.
from memory.config_manager import AVAILABLE_VOICES, DEFAULT_VOICE
lay.addSpacing(4)
lay.addWidget(_lbl("ASSISTANT VOICE", 11, color=C.TEXT_DIM,
align=Qt.AlignmentFlag.AlignLeft))
self._sel_voice = (voice or DEFAULT_VOICE)
if self._sel_voice not in AVAILABLE_VOICES:
self._sel_voice = DEFAULT_VOICE
self._voice_btns: dict[str, QPushButton] = {}
voice_row = QHBoxLayout(); voice_row.setSpacing(4)
for _v in AVAILABLE_VOICES:
b = QPushButton(_v)
b.setCheckable(True)
b.setFixedHeight(28)
b.setFont(QFont("Menlo", 11, QFont.Weight.Bold))
b.setCursor(Qt.CursorShape.PointingHandCursor)
b.clicked.connect(lambda _=False, name=_v: self._on_voice_pick(name))
self._voice_btns[_v] = b
voice_row.addWidget(b)
lay.addLayout(voice_row)
self._refresh_voice_btns()
# ── UI colour — colour wheel ─────────────────────────────────────────
lay.addSpacing(4)
clr_hdr = QHBoxLayout()
clr_hdr.addWidget(_lbl("UI COLOUR — drag the handle", 11,
color=C.TEXT_DIM, align=Qt.AlignmentFlag.AlignLeft))
clr_hdr.addStretch()
df_btn = QPushButton("DEFAULT")
df_btn.setFixedSize(64, 20)
df_btn.setFont(QFont("Menlo", 10, QFont.Weight.Bold))
df_btn.setCursor(Qt.CursorShape.PointingHandCursor)
df_btn.setStyleSheet(f"""
QPushButton {{
background: transparent; color: {C.TEXT_MED};
border: 1px solid {C.BORDER}; border-radius: 3px;
}}
QPushButton:hover {{ color: {C.TEXT}; border-color: {C.BORDER_B}; }}
""")
df_btn.clicked.connect(lambda: self._set_color(DEFAULT_UI_COLOR))
clr_hdr.addWidget(df_btn)
lay.addLayout(clr_hdr)
self._initial_color = (ui_color or DEFAULT_UI_COLOR).strip().lower()
self._sel_color = self._initial_color
self.on_preview = None # callable(hex) — live preview; MainWindow wires it
self._wheel = HueWheel(self._sel_color)
wheel_row = QHBoxLayout()
wheel_row.addStretch(); wheel_row.addWidget(self._wheel); wheel_row.addStretch()
lay.addLayout(wheel_row)
self._wheel.hue_picked.connect(self._on_wheel_pick)
self._wheel.hue_committed.connect(self._on_wheel_commit)
self._hex_input = QLineEdit(self._sel_color)
self._hex_input.setPlaceholderText("#00d4ff (custom hex colour)")
self._hex_input.setFont(QFont("Menlo", 13))
self._hex_input.setFixedHeight(28)
self._hex_input.setStyleSheet(_fs)
self._hex_input.textEdited.connect(self._on_hex_edited)
lay.addWidget(self._hex_input)
lay.addSpacing(6)
btn_row = QHBoxLayout(); btn_row.setSpacing(8)
save_btn = QPushButton("▸ APPLY CHANGES")
save_btn.setFixedHeight(34)
save_btn.setFont(QFont("Menlo", 12, QFont.Weight.Bold))
save_btn.setCursor(Qt.CursorShape.PointingHandCursor)
save_btn.setStyleSheet(f"""
QPushButton {{
background: transparent; color: {C.PRI};
border: 1px solid {C.PRI_DIM}; border-radius: 3px;
}}
QPushButton:hover {{ background: {C.PRI_GHO}; border: 1px solid {C.PRI}; }}
""")
save_btn.clicked.connect(self._save)
btn_row.addWidget(save_btn)
cancel_btn = QPushButton("CANCEL")
cancel_btn.setFixedHeight(34)
cancel_btn.setFont(QFont("Menlo", 12))
cancel_btn.setCursor(Qt.CursorShape.PointingHandCursor)
cancel_btn.setStyleSheet(f"""
QPushButton {{
background: transparent; color: {C.TEXT_MED};
border: 1px solid {C.BORDER}; border-radius: 3px;
}}
QPushButton:hover {{ color: {C.TEXT}; border-color: {C.BORDER_B}; }}
""")
cancel_btn.clicked.connect(self._cancel)
btn_row.addWidget(cancel_btn)
lay.addLayout(btn_row)
# ── voice selection ──────────────────────────────────────────────────────
def _on_voice_pick(self, name: str):
self._sel_voice = name
self._refresh_voice_btns()
def _refresh_voice_btns(self):
"""Highlight the selected voice pill; dim the rest."""
for name, b in self._voice_btns.items():
on = (name == self._sel_voice)
b.setChecked(on)
if on:
b.setStyleSheet(f"""
QPushButton {{ background: {C.PRI_GHO}; color: {C.PRI};
border: 1px solid {C.PRI}; border-radius: 3px; }}
""")
else:
b.setStyleSheet(f"""
QPushButton {{ background: transparent; color: {C.TEXT_MED};
border: 1px solid {C.BORDER}; border-radius: 3px; }}
QPushButton:hover {{ color: {C.TEXT}; border-color: {C.BORDER_B}; }}
""")
# ── colour flow ──────────────────────────────────────────────────────────
def _set_color(self, hx: str, update_wheel: bool = True, preview: bool = True):
"""Updates the selected colour; hex box + wheel stay in sync, theme is live-previewed."""
self._sel_color = hx.strip().lower()
self._hex_input.blockSignals(True)
self._hex_input.setText(self._sel_color)
self._hex_input.blockSignals(False)
if update_wheel:
self._wheel.set_color(self._sel_color)
if preview and self.on_preview:
self.on_preview(self._sel_color)
def _on_wheel_pick(self, hx: str):
# While dragging: update the hex box, don't apply the theme yet
self._sel_color = hx
self._hex_input.blockSignals(True)
self._hex_input.setText(hx)
self._hex_input.blockSignals(False)
def _on_wheel_commit(self, hx: str):
# Handle released → live-preview the whole interface
self._set_color(hx, update_wheel=False)
def _on_hex_edited(self, text: str):
t = text.strip().lower()
if t.startswith("#") and len(t) == 7:
try:
int(t[1:], 16)
except ValueError:
return
self._set_color(t, update_wheel=True, preview=True)
def _cancel(self):
# If a preview was applied, revert to the colour from launch
if self.on_preview and self._sel_color != self._initial_color:
self.on_preview(self._initial_color)
self.hide()
def _save(self):
name = self._name_input.text().strip() or "Ava"
user = self._user_input.text().strip()
self.saved.emit(name, user, self._sel_color or DEFAULT_UI_COLOR, self._sel_voice)
self.hide()
class PluginManagerOverlay(QWidget):
"""Floating overlay — lists discovered plugins with per-plugin ON/OFF toggles."""
_OW = 420
def __init__(self, plugins: list[dict], parent=None):
super().__init__(parent)
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(f"""
PluginManagerOverlay {{
background: rgba(0, 6, 10, 245);
border: 1px solid {C.BORDER_B};
border-radius: 6px;
}}
""")
self.setFixedWidth(self._OW)
lay = QVBoxLayout(self)
lay.setContentsMargins(20, 16, 20, 16)
lay.setSpacing(6)
hdr = QLabel("🧩 PLUGIN MANAGER")
hdr.setFont(QFont("Menlo", 15, QFont.Weight.Bold))
hdr.setStyleSheet(f"color: {C.PRI}; background: transparent;")
lay.addWidget(hdr)
sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet(f"color: {C.BORDER}; margin: 2px 0;")
lay.addWidget(sep)
if not plugins:
empty = QLabel("No plugins found in /plugins.")
empty.setFont(QFont("Menlo", 11))
empty.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;")
lay.addWidget(empty)
for p in plugins:
lay.addLayout(self._build_row(p))
lay.addSpacing(4)
close_btn = QPushButton("CLOSE")
close_btn.setFixedHeight(30)
close_btn.setFont(QFont("Menlo", 12))
close_btn.setCursor(Qt.CursorShape.PointingHandCursor)
close_btn.setStyleSheet(f"""
QPushButton {{
background: transparent; color: {C.TEXT_MED};
border: 1px solid {C.BORDER}; border-radius: 3px;
}}
QPushButton:hover {{ color: {C.TEXT}; border-color: {C.BORDER_B}; }}
""")
close_btn.clicked.connect(self.hide)
lay.addWidget(close_btn)
self.adjustSize()
def _build_row(self, p: dict) -> QHBoxLayout:
row = QHBoxLayout(); row.setSpacing(6)
label_text = p["name"] if p["valid"] else f"{p['name']} (⚠ {p['file']})"
lbl = QLabel(label_text)
lbl.setFont(QFont("Menlo", 11))
lbl.setStyleSheet(f"color: {C.TEXT if p['valid'] else C.TEXT_DIM}; background: transparent;")
lbl.setToolTip(p["description"] if p["valid"] else p["error"])
lbl.setWordWrap(False)
row.addWidget(lbl, stretch=1)
btn = QPushButton()
btn.setFixedSize(72, 24)
btn.setFont(QFont("Menlo", 10, QFont.Weight.Bold))
if not p["valid"]:
btn.setText("BROKEN")
btn.setEnabled(False)
btn.setStyleSheet(f"""
QPushButton {{
background: transparent; color: {C.TEXT_DIM};
border: 1px solid {C.BORDER}; border-radius: 3px;
}}
""")
else:
btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._style_toggle(btn, p["enabled"])
btn.clicked.connect(lambda _, name=p["name"], b=btn: self._toggle(name, b))
row.addWidget(btn)
return row
def _style_toggle(self, btn: QPushButton, enabled: bool):
if enabled:
btn.setText("ON")
btn.setStyleSheet(f"""
QPushButton {{
background: #001a08; color: {C.GREEN};
border: 1px solid {C.GREEN_D}; border-radius: 3px;
}}
QPushButton:hover {{ background: #002010; }}
""")
else:
btn.setText("OFF")
btn.setStyleSheet(f"""
QPushButton {{
background: transparent; color: {C.TEXT_DIM};
border: 1px solid {C.BORDER}; border-radius: 3px;
}}
QPushButton:hover {{ color: {C.TEXT}; border-color: {C.BORDER_B}; }}
""")
def _toggle(self, name: str, btn: QPushButton):
from memory.config_manager import get_plugin_enabled, save_plugin_enabled
new_val = not get_plugin_enabled(name)
save_plugin_enabled(name, new_val)
self._style_toggle(btn, new_val)
class _HudOverlay(QWidget):
"""Base for the floating panels placed by hand over the HUD.
They are children of the central widget but sit in no layout, so Qt never
invalidates the region they occupy when they hide or shrink: the HUD keeps
painting around them and their last frame stays on screen as a ghost. Any
overlay positioned with _centre_overlay needs this."""
def hideEvent(self, e):
p = self.parentWidget()
if p is not None:
# Repaint exactly what we were covering, before we stop covering it.
p.update(self.geometry())
super().hideEvent(e)
def closeEvent(self, e):
p = self.parentWidget()
if p is not None:
p.update(self.geometry())
super().closeEvent(e)
class ConfirmBanner(_HudOverlay):
"""The gate in front of an action that cannot be taken back.
The old confirmation was a tool parameter the model filled in itself, which
means it confirmed its own shutdown requests. This is the interface asking,
and the answer travels from a human finger to core/confirm.py without the
model in the loop. Nothing blocks while it is up: the assistant keeps
talking, so this costs no latency — unlike the old gate, which spent two
tool round trips on every power command."""
answered = pyqtSignal(bool)
_OW = 430
def __init__(self, title: str, detail: str, parent=None):
super().__init__(parent)
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(f"""
ConfirmBanner {{
background: rgba(14, 3, 0, 250);
border: 1px solid {C.ACC};
border-radius: 6px;
}}
""")
self.setFixedWidth(self._OW)
lay = QVBoxLayout(self)
lay.setContentsMargins(20, 16, 20, 16)
lay.setSpacing(8)
hdr = QLabel("⚠ CONFIRM")
hdr.setFont(QFont("Menlo", 14, QFont.Weight.Bold))
hdr.setStyleSheet(f"color: {C.ACC}; background: transparent;")
lay.addWidget(hdr)
ttl = QLabel(title)
ttl.setWordWrap(True)
ttl.setFont(QFont("Menlo", 13, QFont.Weight.Bold))
ttl.setStyleSheet(f"color: {C.TEXT}; background: transparent;")
lay.addWidget(ttl)
if detail:
dtl = QLabel(detail)
dtl.setWordWrap(True)
dtl.setFont(QFont("Menlo", 11))
dtl.setStyleSheet(f"color: {C.TEXT_MED}; background: transparent;")
lay.addWidget(dtl)
row = QHBoxLayout(); row.setSpacing(8)
yes = QPushButton("▸ CONFIRM")
yes.setFixedHeight(32)
yes.setFont(QFont("Menlo", 12, QFont.Weight.Bold))
yes.setCursor(Qt.CursorShape.PointingHandCursor)
yes.setStyleSheet(f"""
QPushButton {{ background: transparent; color: {C.ACC};
border: 1px solid {C.ACC}; border-radius: 3px; }}
QPushButton:hover {{ background: rgba(255,107,0,40); }}
""")
yes.clicked.connect(lambda: self.answered.emit(True))
row.addWidget(yes)
no = QPushButton("CANCEL")
no.setFixedHeight(32)
no.setFont(QFont("Menlo", 12))
no.setCursor(Qt.CursorShape.PointingHandCursor)
no.setStyleSheet(f"""
QPushButton {{ background: transparent; color: {C.TEXT_MED};
border: 1px solid {C.BORDER}; border-radius: 3px; }}
QPushButton:hover {{ color: {C.TEXT}; border-color: {C.BORDER_B}; }}
""")
no.clicked.connect(lambda: self.answered.emit(False))
row.addWidget(no)
lay.addLayout(row)
# Default focus on CANCEL: if someone hits Enter without reading, the
# safe answer wins.
no.setDefault(True)
no.setFocus()
class AudioDeviceOverlay(_HudOverlay):
"""Choose which microphone JARVIS listens to and which speakers it uses.
Both audio streams used to open with no `device=` at all, so they always
took the OS default — which on Windows moves by itself the moment a headset
is plugged in. 'JARVIS can't hear me' is usually 'JARVIS is listening to the
webcam'."""
picked = pyqtSignal() # emitted after Apply, when something changed
_OW = 460
def __init__(self, parent=None):
super().__init__(parent)
from core.audio_devices import list_devices, DEFAULT_LABEL
from memory.config_manager import get_input_device, get_output_device
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(f"""
AudioDeviceOverlay {{
background: rgba(0, 6, 10, 245);
border: 1px solid {C.BORDER_B};
border-radius: 6px;
}}
""")
self.setFixedWidth(self._OW)
lay = QVBoxLayout(self)
lay.setContentsMargins(20, 16, 20, 16)
lay.setSpacing(6)
hdr = QLabel("🎧 AUDIO DEVICES")
hdr.setFont(QFont("Menlo", 15, QFont.Weight.Bold))
hdr.setStyleSheet(f"color: {C.PRI}; background: transparent;")
lay.addWidget(hdr)
sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet(f"color: {C.BORDER}; margin: 2px 0;")
lay.addWidget(sep)
_combo_css = (
f"QComboBox {{ background: #000d12; color: {C.TEXT}; "
f"border: 1px solid {C.BORDER}; border-radius: 3px; padding: 4px 8px; }}"
f"QComboBox:hover {{ border-color: {C.BORDER_B}; }}"
f"QComboBox QAbstractItemView {{ background: #000d12; color: {C.TEXT}; "
f"selection-background-color: {C.PRI_GHO}; border: 1px solid {C.BORDER}; }}"
)
def _row(label: str, kind: str, current: str) -> QComboBox:
cap = QLabel(label)
cap.setFont(QFont("Menlo", 11))
cap.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;")
lay.addWidget(cap)
box = QComboBox()
box.setFont(QFont("Menlo", 12))
box.setFixedHeight(30)
box.setStyleSheet(_combo_css)
# The list is served from a cache warmed on a background thread at
# startup, so opening this panel never blocks the Qt thread on the
# host audio API.
box.addItem(DEFAULT_LABEL, "")
for name in list_devices(kind):
box.addItem(name, name)
idx = box.findData(current) if current else 0
box.setCurrentIndex(idx if idx >= 0 else 0)
if current and idx < 0:
# Saved device is not plugged in right now. Show it rather than
# silently resetting the user's choice to default.
box.addItem(f"{current} (not connected)", current)
box.setCurrentIndex(box.count() - 1)
lay.addWidget(box)
return box
self._in_box = _row("MICROFONO — con cui ti ascolta",
"input", get_input_device())
lay.addSpacing(4)
self._out_box = _row("ALTOPARLANTI — con cui ti parla",
"output", get_output_device())
note = QLabel("Applying reconnects the session. Your conversation is kept.")
note.setWordWrap(True)
note.setFont(QFont("Menlo", 10))
note.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;")
lay.addSpacing(6)
lay.addWidget(note)
row = QHBoxLayout(); row.setSpacing(8)
ok = QPushButton("▸ APPLY")
ok.setFixedHeight(32)
ok.setFont(QFont("Menlo", 12, QFont.Weight.Bold))
ok.setCursor(Qt.CursorShape.PointingHandCursor)
ok.setStyleSheet(f"""
QPushButton {{ background: transparent; color: {C.PRI};
border: 1px solid {C.PRI_DIM}; border-radius: 3px; }}
QPushButton:hover {{ background: {C.PRI_GHO}; border-color: {C.PRI}; }}
""")
ok.clicked.connect(self._apply)
row.addWidget(ok)
cancel = QPushButton("CLOSE")
cancel.setFixedHeight(32)
cancel.setFont(QFont("Menlo", 12))
cancel.setCursor(Qt.CursorShape.PointingHandCursor)
cancel.setStyleSheet(f"""
QPushButton {{ background: transparent; color: {C.TEXT_MED};
border: 1px solid {C.BORDER}; border-radius: 3px; }}
QPushButton:hover {{ color: {C.TEXT}; border-color: {C.BORDER_B}; }}
""")
cancel.clicked.connect(self.hide)
row.addWidget(cancel)
lay.addLayout(row)
def _apply(self):
from memory.config_manager import (
get_input_device, get_output_device,
save_input_device, save_output_device,
)
new_in = self._in_box.currentData() or ""
new_out = self._out_box.currentData() or ""
changed = (new_in != get_input_device()) or (new_out != get_output_device())
save_input_device(new_in)
save_output_device(new_out)
self.hide()
# Only rebuild the session if something actually moved — a no-op Apply
# should not cost a reconnect.
if changed:
self.picked.emit()
class MemoryOverlay(_HudOverlay):
"""Everything JARVIS has stored about you, and when it learned it.
Memory used to be a 2200-character store that deleted its oldest entries
when full and mentioned it only on stdout. The cap is gone; this panel is
the other half of that change — a memory you cannot inspect is a memory you
cannot trust, and 'delete' has to be something the person can do."""
_OW = 520
def __init__(self, parent=None):
super().__init__(parent)
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(f"""
MemoryOverlay {{
background: rgba(0, 6, 10, 246);
border: 1px solid {C.BORDER_B};
border-radius: 6px;
}}
""")
self.setFixedWidth(self._OW)
self._lay = QVBoxLayout(self)
self._lay.setContentsMargins(20, 16, 20, 16)
self._lay.setSpacing(5)
self._rebuild()
def _clear_layout(self):
"""Take every item out of the layout and detach it from the widget tree
in this call.
deleteLater() on its own is not enough: it queues destruction for the
next event-loop pass, and until then the old rows are still children of
this widget and still paint — which is what drew half of the previous
panel over the new one. setParent(None) removes them from the tree now;
deleteLater() then frees them safely."""
while self._lay.count():
item = self._lay.takeAt(0)
w = item.widget()
if w is not None:
# hide() stops it painting in this frame; deleteLater() frees it
# safely afterwards. setParent(None) would also stop the paint,
# but it turns the widget into a top-level window for the moment
# between the two calls, which is not something to leave lying
# around inside a click handler.
w.hide()
w.deleteLater()
continue
sub = item.layout()
if sub is not None:
while sub.count():
si = sub.takeAt(0)
sw = si.widget()
if sw is not None:
sw.hide()
sw.deleteLater()
sub.deleteLater()
def _settle(self, before):
"""Size the panel to its content, re-centre it, and repaint what the old
size covered.
The re-size has to happen here rather than at the end of _rebuild
because Qt has not polished the freshly-created children at that point,
so the size hint it would read is the empty-layout one. Measured: a
first adjustSize() returned 32 px for a panel whose content needed 155,
and a second call — after the same widgets had been through the event
loop — returned 155. So this runs twice: once now, once on the next
turn, from _rebuild.
The re-centre and the repaint are needed because the overlay is placed
by hand and is in no layout: shrinking it leaves it off-centre and
leaves its former pixels on screen, since nothing tells the parent that
region changed. The repaint has to cover the union of the old and new
rectangles."""
self._lay.invalidate()
self._lay.activate()
self.updateGeometry()
self.adjustSize()
p = self.parentWidget()
if p is None:
self.update()
return
self.move(max(0, (p.width() - self.width()) // 2),
max(0, (p.height() - self.height()) // 2))
p.update(before.united(self.geometry()))
self.update()
def _rebuild(self):
before = self.geometry()
self._clear_layout()
from memory.memory_manager import all_entries_for_ui
hdr = QLabel("🧠 COSA RICORDO DI TE")
hdr.setFont(QFont("Menlo", 15, QFont.Weight.Bold))
hdr.setStyleSheet(f"color: {C.PRI}; background: transparent;")
self._lay.addWidget(hdr)
sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet(f"color: {C.BORDER}; margin: 2px 0;")
self._lay.addWidget(sep)
rows = all_entries_for_ui()
cap = QLabel(f"{len(rows)} stored facts — newest first. "
f"Nothing here is sent anywhere; it lives in "
f"memory/long_term.json on this machine.")
cap.setWordWrap(True)
cap.setFont(QFont("Menlo", 10))
cap.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;")
self._lay.addWidget(cap)
if not rows:
empty = QLabel("Nothing stored yet.")
empty.setFont(QFont("Menlo", 12))
empty.setStyleSheet(f"color: {C.TEXT_MED}; background: transparent;")
self._lay.addWidget(empty)
else:
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFixedHeight(min(420, 34 * len(rows) + 10))
scroll.setStyleSheet(
f"QScrollArea {{ border: 1px solid {C.BORDER}; border-radius: 3px; "
f"background: transparent; }}"
)
inner = QWidget()
ilay = QVBoxLayout(inner)
ilay.setContentsMargins(6, 6, 6, 6)
ilay.setSpacing(3)
for r in rows:
line = QHBoxLayout(); line.setSpacing(6)
txt = QLabel(f"<b>{r['key'].replace('_', ' ')}</b> "
f"<span style='color:{C.TEXT_MED}'>— {r['value']}</span>")
txt.setWordWrap(True)
txt.setFont(QFont("Menlo", 11))
txt.setStyleSheet(f"color: {C.TEXT}; background: transparent;")
line.addWidget(txt, 1)
meta = QLabel(f"{r['category'][:4]} · {r['updated'] or '—'}")
meta.setFont(QFont("Menlo", 10))
meta.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;")
line.addWidget(meta)
rm = QPushButton("✕")
rm.setFixedSize(20, 20)
rm.setFont(QFont("Menlo", 11, QFont.Weight.Bold))
rm.setCursor(Qt.CursorShape.PointingHandCursor)
rm.setToolTip("Forget this")
rm.setStyleSheet(f"""
QPushButton {{ background: transparent; color: {C.TEXT_DIM};
border: 1px solid {C.BORDER}; border-radius: 3px; }}
QPushButton:hover {{ color: {C.RED}; border-color: {C.RED}; }}
""")
rm.clicked.connect(
lambda _=False, c=r["category"], k=r["key"]: self._forget(c, k))
line.addWidget(rm)
holder = QWidget()
holder.setLayout(line)
ilay.addWidget(holder)
ilay.addStretch()
scroll.setWidget(inner)
self._lay.addWidget(scroll)
close = QPushButton("CLOSE")
close.setFixedHeight(30)
close.setFont(QFont("Menlo", 12))
close.setCursor(Qt.CursorShape.PointingHandCursor)
close.setStyleSheet(f"""
QPushButton {{ background: transparent; color: {C.TEXT_MED};
border: 1px solid {C.BORDER}; border-radius: 3px; }}
QPushButton:hover {{ color: {C.TEXT}; border-color: {C.BORDER_B}; }}
""")
close.clicked.connect(self.hide)
self._lay.addWidget(close)
self._settle(before)
# …and again once Qt has polished the new children, because the size
# hint is not final until then. Harmless when the first pass already
# got it right: _settle is idempotent.
QTimer.singleShot(0, lambda g=before: self._settle(g))
def _forget(self, category: str, key: str):
from memory.memory_manager import forget
forget(key, category)
# Rebuild on the NEXT event-loop turn, not inside this click handler.
# The rebuild destroys the very ✕ button that emitted this signal, and
# Qt is entitled to touch the sender after a slot returns; tearing it
# down mid-emission is how a widget ends up half-alive on screen.
QTimer.singleShot(0, self._rebuild)
class ClipboardPanel(QWidget):
"""Floating panel shown when text is copied — offers quick Jarvis actions."""
action_requested = pyqtSignal(str)
_W, _H = 326, 112
def __init__(self, parent=None):
super().__init__(parent)
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(f"""
ClipboardPanel {{
background: rgba(0, 8, 14, 248);
border: 1px solid {C.BORDER_B};
border-radius: 6px;
}}
""")
self.setFixedWidth(self._W)
self._clip_text = ""
lay = QVBoxLayout(self)
lay.setContentsMargins(8, 6, 8, 7)
lay.setSpacing(4)
hdr = QHBoxLayout(); hdr.setSpacing(4)
icon_lbl = QLabel("◈ CLIPBOARD DETECTED")
icon_lbl.setFont(QFont("Menlo", 10, QFont.Weight.Bold))
icon_lbl.setStyleSheet(f"color: {C.ACC2}; background: transparent;")
hdr.addWidget(icon_lbl); hdr.addStretch()
x_btn = QPushButton("✕")
x_btn.setFixedSize(16, 16)
x_btn.setFont(QFont("Menlo", 11))
x_btn.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent; border: none;")
x_btn.setCursor(Qt.CursorShape.PointingHandCursor)
x_btn.clicked.connect(self.hide)
hdr.addWidget(x_btn)
lay.addLayout(hdr)
self._preview = QLabel()
self._preview.setFont(QFont("Menlo", 11))
self._preview.setStyleSheet(f"""
color: {C.TEXT}; background: {C.PANEL2};
border: 1px solid {C.BORDER}; border-radius: 3px; padding: 4px 6px;
""")
self._preview.setWordWrap(False)
self._preview.setFixedHeight(28)
lay.addWidget(self._preview)
btn_row = QHBoxLayout(); btn_row.setSpacing(4)
_bs = (f"QPushButton {{ background: {C.PANEL2}; color: {C.TEXT_MED}; "
f"border: 1px solid {C.BORDER}; border-radius: 2px; }}"
f"QPushButton:hover {{ color: {C.PRI}; border-color: {C.BORDER_B}; }}")
for label, cmd_fmt in [
("TRANSLATE", "Translate this text to English: {text}"),
("SUMMARISE", "Summarise this: {text}"),
("EXPLAIN", "Explain this: {text}"),
("FIX", "Fix grammar and spelling: {text}"),
]:
b = QPushButton(label)
b.setFixedHeight(22)
b.setFont(QFont("Menlo", 10, QFont.Weight.Bold))
b.setCursor(Qt.CursorShape.PointingHandCursor)
b.setStyleSheet(_bs)
b.clicked.connect(lambda _, c=cmd_fmt: self._trigger(c))
btn_row.addWidget(b)
lay.addLayout(btn_row)
self._dismiss_timer = QTimer(self)
self._dismiss_timer.setSingleShot(True)
self._dismiss_timer.timeout.connect(self.hide)
self.hide()
def _trigger(self, cmd_fmt: str):
if self._clip_text:
self.action_requested.emit(cmd_fmt.format(text=self._clip_text[:800]))
self.hide()
def show_clipboard(self, text: str):
self._clip_text = text
preview = text[:58].replace('\n', ' ')
if len(text) > 58:
preview += "…"
self._preview.setText(f'"{preview}"')
self.show(); self.raise_()
self._dismiss_timer.start(8000)
class PluginSettingsOverlay(QWidget):
"""Floating overlay — renders per-plugin settings forms.
Fully generic: it iterates the settings schemas a plugin declared via its
PLUGIN_SETTINGS constant (delivered by PluginRegistry.settings_schemas) and
builds a form for each. It knows NOTHING about any specific plugin, so the
core stays clean and plugins remain pure drop-in — install a plugin that
declares fields (e.g. the 3D-printer suite) and its section appears here;
install none and this panel simply says there's nothing to configure.
"""
_test_done = pyqtSignal(str, bool, str) # namespace, ok, message
_OW = 460
def __init__(self, sections: list[dict], parent=None):
super().__init__(parent)
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(f"""
PluginSettingsOverlay {{
background: rgba(0, 6, 10, 245);
border: 1px solid {C.BORDER_B};
border-radius: 6px;
}}
""")
self._sections = sections or []
self._widgets: dict[tuple, object] = {} # (namespace, key) -> input widget
self._types: dict[tuple, str] = {} # (namespace, key) -> field type
self._status_labels: dict[str, QLabel] = {} # namespace -> status QLabel
self._test_done.connect(self._on_test_done)
self._fs = (f"QLineEdit {{ background: #000d12; color: {C.TEXT}; "
f"border: 1px solid {C.BORDER}; border-radius: 3px; padding: 4px 8px; }}"
f"QLineEdit:focus {{ border: 1px solid {C.PRI}; }}")
root = QVBoxLayout(self)
root.setContentsMargins(22, 16, 22, 16)
root.setSpacing(8)
root.addWidget(self._lbl("⚙ PLUGIN SETTINGS", 15, True))
sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet(f"color: {C.BORDER}; margin: 2px 0;")
root.addWidget(sep)
if not self._sections:
root.addWidget(self._lbl(
"No configurable plugins are installed.\nDrop a plugin that needs "
"settings (like the 3D-printer suite) into the plugins folder and "
"it will show up here.", 9, color=C.TEXT_DIM))
else:
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setStyleSheet("QScrollArea { background: transparent; }")
inner = QWidget()
inner.setStyleSheet("background: transparent;")
form = QVBoxLayout(inner)
form.setContentsMargins(0, 0, 6, 0)
form.setSpacing(6)
for sec in self._sections:
self._build_section(form, sec)
form.addStretch(1)
scroll.setWidget(inner)
root.addWidget(scroll, 1)
# ── bottom buttons ───────────────────────────────────────────────────
btn_row = QHBoxLayout(); btn_row.setSpacing(8)
if self._sections:
save_btn = QPushButton("▸ SAVE")
save_btn.setFixedHeight(34)
save_btn.setFont(QFont("Menlo", 12, QFont.Weight.Bold))
save_btn.setCursor(Qt.CursorShape.PointingHandCursor)
save_btn.setStyleSheet(f"""
QPushButton {{ background: transparent; color: {C.PRI};
border: 1px solid {C.PRI_DIM}; border-radius: 3px; }}
QPushButton:hover {{ background: {C.PRI_GHO}; border: 1px solid {C.PRI}; }}
""")
save_btn.clicked.connect(self._save_all)
btn_row.addWidget(save_btn)
close_btn = QPushButton("CLOSE")
close_btn.setFixedHeight(34)
close_btn.setFont(QFont("Menlo", 12))
close_btn.setCursor(Qt.CursorShape.PointingHandCursor)
close_btn.setStyleSheet(f"""
QPushButton {{ background: transparent; color: {C.TEXT_MED};
border: 1px solid {C.BORDER}; border-radius: 3px; }}
QPushButton:hover {{ color: {C.TEXT}; border-color: {C.BORDER_B}; }}
""")
close_btn.clicked.connect(self.hide)
btn_row.addWidget(close_btn)
root.addLayout(btn_row)
# ── helpers ───────────────────────────────────────────────────────────────
def _lbl(self, txt, fs=9, bold=False, color=C.PRI,
align=Qt.AlignmentFlag.AlignLeft):
w = QLabel(txt); w.setAlignment(align); w.setWordWrap(True)
w.setFont(QFont("Menlo", fs,
QFont.Weight.Bold if bold else QFont.Weight.Normal))
w.setStyleSheet(f"color: {color}; background: transparent;")
return w
def _build_section(self, form: QVBoxLayout, sec: dict):
ns = sec.get("namespace") or sec.get("plugin") or "plugin"
title = sec.get("title") or ns
fields = sec.get("fields") or []
values = sec.get("values") or {}
form.addSpacing(4)
form.addWidget(self._lbl(title, 10, True, C.PRI))
for field in fields:
if not isinstance(field, dict) or not field.get("key"):
continue
key = field["key"]
ftype = (field.get("type") or "text").lower()
label = field.get("label") or key
default = field.get("default")
stored = values.get(key, default)
form.addWidget(self._lbl(label.upper(), 8, color=C.TEXT_DIM))
if ftype == "choice":
w = QComboBox()
w.addItems([str(o) for o in field.get("options", [])])
w.setFont(QFont("Menlo", 12))
w.setFixedHeight(30)
w.setStyleSheet(
f"QComboBox {{ background: #000d12; color: {C.TEXT}; "
f"border: 1px solid {C.BORDER}; border-radius: 3px; padding: 2px 8px; }}"
f"QComboBox QAbstractItemView {{ background: #000d12; color: {C.TEXT}; "
f"selection-background-color: {C.PRI_GHO}; }}")
if stored is not None:
w.setCurrentText(str(stored))
elif ftype == "toggle":
w = QPushButton()
w.setCheckable(True)
w.setChecked(bool(stored))
w.setFixedHeight(28)
w.setFont(QFont("Menlo", 11, QFont.Weight.Bold))
w.setCursor(Qt.CursorShape.PointingHandCursor)
self._style_toggle(w)
w.toggled.connect(lambda _=False, b=w: self._style_toggle(b))
else: # text / password
w = QLineEdit("" if stored is None else str(stored))
w.setFont(QFont("Menlo", 13))
w.setFixedHeight(30)
w.setStyleSheet(self._fs)
if field.get("placeholder"):
w.setPlaceholderText(str(field["placeholder"]))
if ftype == "password":
w.setEchoMode(QLineEdit.EchoMode.Password)
self._widgets[(ns, key)] = w
self._types[(ns, key)] = ftype
form.addWidget(w)
# optional test/connect action button + status line
action = sec.get("action")
if isinstance(action, dict) and callable(action.get("run")):
form.addSpacing(2)
ab = QPushButton(str(action.get("label") or "TEST"))
ab.setFixedHeight(30)
ab.setFont(QFont("Menlo", 11, QFont.Weight.Bold))
ab.setCursor(Qt.CursorShape.PointingHandCursor)
ab.setStyleSheet(f"""
QPushButton {{ background: #00091a; color: {C.PRI};
border: 1px solid {C.PRI_DIM}; border-radius: 3px; }}
QPushButton:hover {{ background: {C.PRI_GHO}; border-color: {C.PRI}; }}
""")
ab.clicked.connect(lambda _=False, n=ns: self._run_action(n))
form.addWidget(ab)
status = self._lbl("", 11, color=C.TEXT_DIM)
self._status_labels[ns] = status
form.addWidget(status)
line = QFrame(); line.setFrameShape(QFrame.Shape.HLine)
line.setStyleSheet(f"color: {C.BORDER}; margin: 4px 0;")
form.addWidget(line)
def _style_toggle(self, btn: QPushButton):
on = btn.isChecked()
btn.setText("ON" if on else "OFF")
if on:
btn.setStyleSheet(f"QPushButton {{ background: {C.PRI_GHO}; color: {C.PRI}; "
f"border: 1px solid {C.PRI}; border-radius: 3px; }}")
else:
btn.setStyleSheet(f"QPushButton {{ background: transparent; color: {C.TEXT_MED}; "
f"border: 1px solid {C.BORDER}; border-radius: 3px; }}")
# ── data ──────────────────────────────────────────────────────────────────
def _gather(self, ns: str) -> dict:
out = {}
for (n, key), w in self._widgets.items():
if n != ns:
continue
t = self._types.get((n, key), "text")
if t == "choice":
out[key] = w.currentText()
elif t == "toggle":
out[key] = w.isChecked()
else:
out[key] = w.text().strip()
return out
def _save_ns(self, ns: str):
from memory.config_manager import save_plugin_config
save_plugin_config(ns, self._gather(ns))
def _save_all(self):
for sec in self._sections:
ns = sec.get("namespace") or sec.get("plugin")
if ns:
self._save_ns(ns)
lbl = self._status_labels.get(ns)
if lbl:
lbl.setText("Saved ✓")
lbl.setStyleSheet(f"color: {C.PRI}; background: transparent;")
def _run_action(self, ns: str):
sec = next((s for s in self._sections
if (s.get("namespace") or s.get("plugin")) == ns), None)
if not sec:
return
run_fn = (sec.get("action") or {}).get("run")
if not callable(run_fn):
return
self._save_ns(ns) # persist what the user typed before testing
values = self._gather(ns)
lbl = self._status_labels.get(ns)
if lbl:
lbl.setText("Testing…")
lbl.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;")
def worker():
try:
res = run_fn(values)
if isinstance(res, tuple) and len(res) == 2:
ok, msg = bool(res[0]), str(res[1])
else:
ok, msg = bool(res), str(res)
except Exception as e:
ok, msg = False, str(e)
self._test_done.emit(ns, ok, msg)
threading.Thread(target=worker, daemon=True).start()
def _on_test_done(self, ns: str, ok: bool, msg: str):
lbl = self._status_labels.get(ns)
if not lbl:
return
lbl.setText(msg)
color = C.PRI if ok else "#ff6b6b"
lbl.setStyleSheet(f"color: {color}; background: transparent;")
class RemoteKeyOverlay(QWidget):
"""Floating overlay — QR code for instant phone pairing + manual key fallback."""
closed = pyqtSignal()
_OW, _OH = 400, 465
def __init__(self, url: str, key: str, auto_login_url: str = "",
manual_url: str = "", expiry_secs: int = 600, parent=None):
super().__init__(parent)
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(f"""
RemoteKeyOverlay {{
background: rgba(0, 4, 12, 0.95);
border: 1px solid {C.BORDER_B};
border-radius: 14px;
}}
""")
self._expiry = time.time() + expiry_secs
self._on_new_key = None
self._auto_login_url = auto_login_url
self._manual_url = manual_url or url
lay = QVBoxLayout(self)
lay.setContentsMargins(24, 16, 24, 16)
lay.setSpacing(5)
def _lbl(txt, fs=9, bold=False, color=C.PRI,
align=Qt.AlignmentFlag.AlignCenter):
w = QLabel(txt)
w.setAlignment(align)
w.setFont(QFont("Menlo", fs,
QFont.Weight.Bold if bold else QFont.Weight.Normal))
w.setStyleSheet(f"color: {color}; background: transparent;")
w.setWordWrap(True)
return w
lay.addWidget(_lbl("◈ REMOTE ACCESS", 15, True))
sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet(f"color: {C.BORDER}; margin: 1px 0;")
lay.addWidget(sep)
# ── QR code ───────────────────────────────────────────────────────────
self._qr_label = QLabel()
self._qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._qr_label.setFixedSize(176, 176)
self._qr_label.setStyleSheet(
"background: white; border-radius: 10px; padding: 4px;"
)
qr_row = QHBoxLayout()
qr_row.addStretch()
qr_row.addWidget(self._qr_label)
qr_row.addStretch()
lay.addLayout(qr_row)
self._update_qr(auto_login_url)
lay.addWidget(_lbl("Scan with phone camera to connect instantly", 11, color=C.TEXT_DIM))
sep2 = QFrame(); sep2.setFrameShape(QFrame.Shape.HLine)
sep2.setStyleSheet(f"color: {C.BORDER}; margin: 1px 0;")
lay.addWidget(sep2)
lay.addWidget(_lbl("Or enter manually:", 10, color=C.TEXT_DIM,
align=Qt.AlignmentFlag.AlignLeft))
self._url_lbl = QLabel(self._manual_url)
self._url_lbl.setFont(QFont("Menlo", 11))
self._url_lbl.setStyleSheet(f"color: {C.PRI_DIM}; background: transparent;")
self._url_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._url_lbl.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse)
lay.addWidget(self._url_lbl)
self._key_lbl = QLabel(key)
self._key_lbl.setFont(QFont("Menlo", 28, QFont.Weight.Bold))
self._key_lbl.setStyleSheet(f"""
color: {C.ACC};
background: {C.PANEL2};
border: 1px solid {C.BORDER_B};
border-radius: 8px;
padding: 6px 4px;
letter-spacing: 10px;
""")
self._key_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
lay.addWidget(self._key_lbl)
self._timer_lbl = QLabel()
self._timer_lbl.setFont(QFont("Menlo", 11))
self._timer_lbl.setStyleSheet(f"color: {C.TEXT_MED}; background: transparent;")
self._timer_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
lay.addWidget(self._timer_lbl)
btn_row = QHBoxLayout(); btn_row.setSpacing(8)
new_btn = QPushButton("NEW KEY")
new_btn.setFixedHeight(32)
new_btn.setFont(QFont("Menlo", 11, QFont.Weight.Bold))
new_btn.setCursor(Qt.CursorShape.PointingHandCursor)
new_btn.setStyleSheet(f"""
QPushButton {{
background: {C.PANEL}; color: {C.PRI};
border: 1px solid {C.PRI_DIM}; border-radius: 5px;
}}
QPushButton:hover {{ background: {C.PRI_GHO}; border: 1px solid {C.PRI}; }}
""")
new_btn.clicked.connect(self._refresh_key)
btn_row.addWidget(new_btn)
close_btn = QPushButton("DISMISS")
close_btn.setFixedHeight(32)
close_btn.setFont(QFont("Menlo", 11, QFont.Weight.Bold))
close_btn.setCursor(Qt.CursorShape.PointingHandCursor)
close_btn.setStyleSheet(f"""
QPushButton {{
background: transparent; color: {C.TEXT_MED};
border: 1px solid {C.BORDER}; border-radius: 5px;
}}
QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }}
""")
close_btn.clicked.connect(self._do_close)
btn_row.addWidget(close_btn)
lay.addLayout(btn_row)
self._ctimer = QTimer(self)
self._ctimer.timeout.connect(self._tick)
self._ctimer.start(1000)
self._tick()
def set_new_key_callback(self, fn) -> None:
self._on_new_key = fn
def _update_qr(self, url: str) -> None:
if not url:
self._qr_label.setText("—")
return
try:
import qrcode as _qrmod
from io import BytesIO
qr = _qrmod.QRCode(
box_size=5, border=2,
error_correction=_qrmod.constants.ERROR_CORRECT_M,
)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = BytesIO()
img.save(buf, format="PNG")
px = QPixmap()
px.loadFromData(buf.getvalue())
self._qr_label.setPixmap(
px.scaled(170, 170,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
)
except ImportError:
self._qr_label.setText("pip install\nqrcode[pil]")
self._qr_label.setFont(QFont("Menlo", 11))
self._qr_label.setStyleSheet(
"color: #888; background: white; border-radius: 10px; padding: 4px;"
)
except Exception:
self._qr_label.setText(url[:28])
self._qr_label.setFont(QFont("Menlo", 10))
self._qr_label.setStyleSheet(
f"color: {C.PRI}; background: white; border-radius: 10px; padding: 4px;"
)
def _tick(self):
remaining = max(0, int(self._expiry - time.time()))
m, s = divmod(remaining, 60)
self._timer_lbl.setText(f"Key expires in {m:02d}:{s:02d}")
if remaining == 0:
self._do_close()
def mark_connected(self) -> None:
"""Call from any thread when a phone successfully connects."""
self._ctimer.stop()
self._key_lbl.setText("CONNECTED")
self._key_lbl.setStyleSheet(f"""
color: {C.GREEN};
background: rgba(34,197,94,0.08);
border: 2px solid rgba(34,197,94,0.4);
border-radius: 8px;
padding: 6px 4px;
letter-spacing: 4px;
""")
self._qr_label.setText("✓")
self._qr_label.setFont(QFont("Menlo", 54, QFont.Weight.Bold))
self._qr_label.setStyleSheet(
"color: #00ff88; background: #001a0d; border-radius: 10px;"
)
self._timer_lbl.setText("Telefono collegato — pronta")
self._timer_lbl.setStyleSheet(f"color: {C.GREEN}; background: transparent;")
def _refresh_key(self):
if self._on_new_key:
result = self._on_new_key()
if result:
url = result[0]
key = result[1]
auto = result[2] if len(result) >= 3 else ""
manual = result[3] if len(result) >= 4 else url
self._manual_url = manual or url
self._url_lbl.setText(self._manual_url)
self._key_lbl.setText(key)
self._auto_login_url = auto
self._update_qr(auto or url)
self._expiry = time.time() + 600
self._key_lbl.setStyleSheet(f"""
color: {C.ACC};
background: {C.PANEL2};
border: 1px solid {C.BORDER_B};
border-radius: 8px;
padding: 6px 4px;
letter-spacing: 10px;
""")
self._timer_lbl.setStyleSheet(
f"color: {C.TEXT_MED}; background: transparent;"
)
self._ctimer.start(1000)
self._tick()
def _do_close(self):
self._ctimer.stop()
self.hide()
self.closed.emit()
class MainWindow(QMainWindow):
_log_sig = pyqtSignal(str)
_state_sig = pyqtSignal(str)
_content_sig = pyqtSignal(str, str) # (title, text) — thread-safe content display
_reconfig_sig = pyqtSignal() # trigger setup overlay from any thread
_camera_sig = pyqtSignal(bytes) # show camera frame preview (small overlay)
_cam_stream_sig = pyqtSignal(bool) # True=start live stream, False=stop
_cam_frame_sig = pyqtSignal(bytes) # live camera frame → HUD area
_clipboard_sig = pyqtSignal(str) # clipboard text changed (thread-safe)
_confirm_sig = pyqtSignal(str, str) # (title, detail) — irreversible-action gate
_confirm_hide_sig = pyqtSignal()
_wake_dl_sig = pyqtSignal(bool, str) # wake-word install finished (ok, message)
_quiz_sig = pyqtSignal(str, object, object) # (topic, questions, grader)
_quiz_hide_sig = pyqtSignal()
_review_sig = pyqtSignal(str, str, object, object) # document review payload
def __init__(self, face_path: str):
super().__init__()
self._face_path = face_path
# Load customization from config
_cfg = _read_full_config()
self._assistant_name: str = (_cfg.get("assistant_name") or "Ava").strip()
_display = self._assistant_name.upper()
# Apply the saved UI colour BEFORE panels/stylesheets are built
_ui_color = (_cfg.get("ui_color") or "").strip()
if _ui_color and _ui_color.lower() != DEFAULT_UI_COLOR:
apply_ui_accent(_ui_color)
self.setWindowTitle(_display if _display.lower() == APP_VERSION.lower() else f"{_display} — {APP_VERSION}")
self.setMinimumSize(_MIN_W, _MIN_H)
self.resize(_DEFAULT_W, _DEFAULT_H)
screen = QApplication.primaryScreen().availableGeometry()
self.move(
(screen.width() - _DEFAULT_W) // 2,
(screen.height() - _DEFAULT_H) // 2,
)
self.on_text_command = None
self.on_remote_clicked = None # callable: () -> (url, key) | None
self.on_interrupt = None # callable: () -> None — stop JARVIS mid-speech
self.on_voice_change = None # callable: () -> None — rebuild session with new voice
self.on_audio_device_change = None # callable: () -> None — reopen audio streams
self._confirm_overlay = None # live ConfirmBanner, if one is on screen
self.get_plugins = None # callable: () -> list[dict], set by JarvisLive
self.get_plugin_settings = None # callable: () -> list[dict] settings schemas, set by JarvisLive
self.on_wake_toggle = None # callable: (enable: bool) -> str, set by JarvisLive
self.on_wake_manual = None # callable: () -> None — manual sleep/wake
self.on_push_to_talk = None # callable: (enable: bool) -> str scope
self.ptt_hold = None # callable: (held: bool) -> None — windowed chord
self.wake_get_state = None # callable: () -> dict {enabled, awake, ready}
self.on_engine_settings = None # AvatarPy: apre la finestra Motore & Voce
self.on_new_conversation = None # AvatarPy: azzera la conversazione
self._muted = False
self._current_file: str | None = None
self._remote_overlay: RemoteKeyOverlay | None = None
self._customize_overlay: CustomizeOverlay | None = None
central = QWidget()
central.setStyleSheet(f"background: {C.BG};")
self.setCentralWidget(central)
root = QVBoxLayout(central)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
root.addWidget(self._build_header())
body = QHBoxLayout()
body.setContentsMargins(0, 0, 0, 0)
body.setSpacing(0)
self._left_panel = self._build_left_panel()
body.addWidget(self._left_panel, stretch=0)
# Center column: HUD + resizable content panel via QSplitter
self.hud = HudCanvas(face_path, _display)
self.hud.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._content_panel = self._build_content_panel()
self._quiz_panel = self._build_quiz_panel()
# Live camera container — replaces HUD when camera stream is active
_cam_cont = QWidget()
_cam_cont.setStyleSheet("background: #000308;")
_cam_v = QVBoxLayout(_cam_cont)
_cam_v.setContentsMargins(0, 0, 0, 0)
_cam_v.setSpacing(0)
_cam_hdr = QHBoxLayout()
_cam_hdr.setContentsMargins(8, 5, 8, 5)
_cam_title = QLabel("◈ CAMERA FEED")
_cam_title.setFont(QFont("Menlo", 11, QFont.Weight.Bold))
_cam_title.setStyleSheet(f"color: {C.PRI}; background: transparent;")
_cam_hdr.addWidget(_cam_title)
_cam_hdr.addStretch()
_cam_x = QPushButton("✕ CLOSE")
_cam_x.setFont(QFont("Menlo", 11, QFont.Weight.Bold))
_cam_x.setCursor(Qt.CursorShape.PointingHandCursor)
_cam_x.setStyleSheet(f"""
QPushButton {{
color: {C.TEXT_DIM}; background: transparent;
border: none; padding: 2px 6px;
}}
QPushButton:hover {{ color: {C.PRI}; }}
""")
_cam_x.clicked.connect(self.stop_camera_stream)
_cam_hdr.addWidget(_cam_x)
_cam_v.addLayout(_cam_hdr)
self._cam_live_lbl = QLabel()
self._cam_live_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._cam_live_lbl.setStyleSheet("background: transparent;")
self._cam_live_lbl.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
_cam_v.addWidget(self._cam_live_lbl, stretch=1)
# Stack: 0 = animated HUD, 1 = live camera
self._hud_cam_stack = QStackedWidget()
self._hud_cam_stack.addWidget(self.hud)
self._hud_cam_stack.addWidget(_cam_cont)
self.avatar3d = None # AvatarPy: vista 3D, creata al bisogno
self._center_split = QSplitter(Qt.Orientation.Vertical)
self._center_split.setStyleSheet(f"""
QSplitter::handle {{
background: {C.BORDER};
height: 4px;
}}
QSplitter::handle:hover {{
background: {C.PRI_DIM};
}}
""")
self._center_split.addWidget(self._hud_cam_stack)
self._center_split.addWidget(self._content_panel)
self._center_split.addWidget(self._quiz_panel)
self._center_split.setStretchFactor(0, 3)
self._center_split.setStretchFactor(1, 1)
self._center_split.setCollapsible(0, False)
body.addWidget(self._center_split, stretch=5)
self._right_panel = self._build_right_panel()
body.addWidget(self._right_panel, stretch=0)
root.addLayout(body, stretch=1)
root.addWidget(self._build_footer())
# Quick-access drawer (floating overlay, built after central widget layout is done)
self._quick_drawer = self._build_quick_drawer()
self._update_autostart_btn(self._check_autostart())
from memory.config_manager import get_brief_enabled as _gbe
self._update_brief_btn(_gbe())
self._clock_tmr = QTimer(self)
self._clock_tmr.timeout.connect(self._tick_clock)
self._clock_tmr.start(1000)
self._tick_clock()
# Metric update timer
self._metric_tmr = QTimer(self)
self._metric_tmr.timeout.connect(self._update_metrics)
self._metric_tmr.start(2000)
self._update_metrics()
self._log_sig.connect(self._log.append_log)
self._state_sig.connect(self._apply_state)
self._content_sig.connect(self._show_content)
self._reconfig_sig.connect(self._show_setup)
self._camera_sig.connect(self._show_camera_frame)
self._confirm_sig.connect(self._show_confirm_banner)
self._confirm_hide_sig.connect(self._hide_confirm_banner)
self._cam_stream_sig.connect(self._on_cam_stream)
self._cam_frame_sig.connect(self._on_cam_frame)
self._clipboard_sig.connect(self._show_clipboard_panel)
self._wake_dl_sig.connect(self._on_wake_install_done)
self._quiz_sig.connect(self._show_quiz)
self._quiz_hide_sig.connect(self._hide_quiz)
self._review_sig.connect(self._show_review)
self._cam_stop = threading.Event()
# Camera preview overlay (child of central widget, positioned in resizeEvent)
self._cam_preview = _CameraPreview(self.centralWidget())
# Clipboard panel (child of central widget, bottom-center)
self._clipboard_panel = ClipboardPanel(self.centralWidget())
self._clipboard_panel.action_requested.connect(self._on_clipboard_action)
QApplication.clipboard().dataChanged.connect(self._on_clipboard_changed)
self._overlay: SetupOverlay | None = None
self._ready = self._check_config()
if not self._ready:
self._show_setup()
try:
from memory.config_manager import get_hud_style as _ghs3
if _ghs3() == "3d":
self._ensure_avatar3d()
except Exception as _e:
print(f"[avatar3d] non disponibile: {_e}")
sc_mute = QShortcut(QKeySequence("F4"), self)
sc_mute.activated.connect(self._toggle_mute)
sc_full = QShortcut(QKeySequence("F11"), self)
sc_full.activated.connect(self._toggle_fullscreen)
sc_intr = QShortcut(QKeySequence("Escape"), self)
sc_intr.activated.connect(self._do_interrupt)
def _show_camera_frame(self, img_bytes: bytes):
"""Slot — display camera preview overlay (main thread)."""
self._cam_preview.show_frame(img_bytes)
cw = self.centralWidget()
pw = _CameraPreview._W
ph = self._cam_preview.height()
self._cam_preview.setGeometry(
cw.width() - _RIGHT_W - pw - 12,
cw.height() - ph - 28,
pw, ph,
)
# --- Live camera stream in HUD area ------------------------------------
def _on_cam_stream(self, start: bool) -> None:
if start:
self._hud_cam_stack.setCurrentIndex(1)
else:
self._restore_hud_view()
self._cam_live_lbl.clear()
# ── AvatarPy: avatar 3D ─────────────────────────────────────────────────
def _ensure_avatar3d(self):
if self.avatar3d is None:
from avatar.avatar3d import Avatar3DView
self.avatar3d = Avatar3DView(self)
self._hud_cam_stack.addWidget(self.avatar3d)
self._hud_cam_stack.setCurrentWidget(self.avatar3d)
self.avatar3d.set_state(self.hud.state)
def _restore_hud_view(self):
from memory.config_manager import get_hud_style
if get_hud_style() == "3d" and self.avatar3d is not None:
self._hud_cam_stack.setCurrentWidget(self.avatar3d)
else:
self._hud_cam_stack.setCurrentIndex(0)
def _on_cam_frame(self, data: bytes) -> None:
px = QPixmap()
px.loadFromData(data)
if not px.isNull():
w, h = self._cam_live_lbl.width(), self._cam_live_lbl.height()
if w > 1 and h > 1:
self._cam_live_lbl.setPixmap(
px.scaled(w, h,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
)
def start_camera_stream(self) -> None:
self._cam_stop.clear()
self._cam_stream_sig.emit(True)
t = threading.Thread(target=self._cam_loop, daemon=True, name="cam-stream")
t.start()
def _cam_loop(self) -> None:
try:
import cv2
# Reuse camera index detected by screen_processor (cached in api_keys.json)
cam_idx = 0
try:
import json as _j
cfg = _j.loads((CONFIG_DIR / "api_keys.json").read_text())
cam_idx = int(cfg.get("camera_index", 0))
except Exception:
pass
try:
backend = cv2.CAP_DSHOW if _OS == "Windows" else cv2.CAP_ANY
except AttributeError:
backend = 0
cap = cv2.VideoCapture(cam_idx, backend)
if not cap.isOpened():
cap = cv2.VideoCapture(0)
if not cap.isOpened():
return
# warm-up frames
for _ in range(5):
cap.read()
while not self._cam_stop.wait(0.033) and cap.isOpened():
ret, frame = cap.read()
if ret and frame is not None:
_, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 65])
self._cam_frame_sig.emit(buf.tobytes())
cap.release()
except Exception as e:
print(f"[Camera] Stream error: {e}")
finally:
self._cam_stream_sig.emit(False)
def stop_camera_stream(self) -> None:
self._cam_stop.set()
# ------------------------------------------------------------------
# Icon generation — arc-reactor style, rendered with Pillow
# ------------------------------------------------------------------
@staticmethod
def _build_jarvis_icon(out_path: Path) -> bool:
"""
Render a JARVIS arc-reactor icon at 4× resolution and downsample
for crisp results at all sizes. Saves a multi-res .ico to out_path.
Returns True on success.
"""
try:
import math
import PIL.Image
import PIL.ImageDraw
import PIL.ImageFilter
except ImportError:
return False
CYAN = (0, 212, 255)
DIM = (0, 100, 140)
DARK = (0, 6, 10)
GLOW = (0, 160, 200)
WHITE = (220, 240, 255)
def _render(sz: int) -> PIL.Image.Image:
S = sz * 4 # draw at 4× then downscale
img = PIL.Image.new("RGBA", (S, S), (0, 0, 0, 0))
d = PIL.ImageDraw.Draw(img)
cx = cy = S // 2
# ── filled background circle ──────────────────────────────────
R = S // 2 - 2
d.ellipse([cx-R, cy-R, cx+R, cy+R], fill=(*DARK, 255))
# ── outer border ring ─────────────────────────────────────────
lw = max(2, S // 40)
d.ellipse([cx-R, cy-R, cx+R, cy+R],
outline=(*CYAN, 220), width=lw)
# ── mid decorative ring ───────────────────────────────────────
R2 = int(R * 0.72)
d.ellipse([cx-R2, cy-R2, cx+R2, cy+R2],
outline=(*DIM, 180), width=max(1, lw // 2))
# ── 6 radial spokes (hex bolt) ────────────────────────────────
R_inner = int(R * 0.30)
R_outer = int(R * 0.62)
spoke_w = max(1, S // 80)
for i in range(6):
angle = math.radians(i * 60 - 30)
x1 = cx + int(R_inner * math.cos(angle))
y1 = cy + int(R_inner * math.sin(angle))
x2 = cx + int(R_outer * math.cos(angle))
y2 = cy + int(R_outer * math.sin(angle))
d.line([x1, y1, x2, y2], fill=(*GLOW, 200), width=spoke_w)
# ── 6 tick marks on outer ring ────────────────────────────────
for i in range(6):
angle = math.radians(i * 60)
for dr in range(lw * 2):
rx = (R - lw - dr)
d.point(
[cx + int(rx * math.cos(angle)),
cy + int(rx * math.sin(angle))],
fill=(*WHITE, 220),
)
# ── inner glowing ring ────────────────────────────────────────
Ri = int(R * 0.26)
d.ellipse([cx-Ri, cy-Ri, cx+Ri, cy+Ri],
outline=(*CYAN, 255), width=max(2, lw))
# ── bright glow soft blur applied before core ─────────────────
# (draw a slightly larger cyan circle on a separate layer)
glow_layer = PIL.Image.new("RGBA", (S, S), (0, 0, 0, 0))
gd = PIL.ImageDraw.Draw(glow_layer)
Rc = int(R * 0.13)
gd.ellipse([cx-Rc*2, cy-Rc*2, cx+Rc*2, cy+Rc*2],
fill=(*CYAN, 110))
glow_layer = glow_layer.filter(PIL.ImageFilter.GaussianBlur(S // 14))
img = PIL.Image.alpha_composite(img, glow_layer)
d = PIL.ImageDraw.Draw(img)
# ── core dot ──────────────────────────────────────────────────
d.ellipse([cx-Rc, cy-Rc, cx+Rc, cy+Rc], fill=(*WHITE, 255))
# ── downscale to target size ──────────────────────────────────
return img.resize((sz, sz), PIL.Image.LANCZOS)
try:
sizes = [256, 128, 64, 48, 32, 16]
frames = [_render(s) for s in sizes]
frames[0].save(
out_path,
format="ICO",
append_images=frames[1:],
sizes=[(s, s) for s in sizes],
)
return True
except Exception as e:
print(f"[Shortcut] ⚠️ Icon generation failed: {e}")
return False
@staticmethod
def _create_lnk_windows(lnk: str, target: str, args: str,
work_dir: str, icon_loc: str) -> None:
"""
Create a Windows .lnk shortcut WITHOUT launching PowerShell or cmd.
Tries win32com (pywin32) first; falls back to wscript.exe + VBScript.
wscript.exe is a GUI-mode host — it never opens a console window.
"""
# ── Option 1: pywin32 (pure Python COM, zero subprocess) ──────────
try:
from win32com.client import Dispatch # type: ignore
sh = Dispatch("WScript.Shell")
sc = sh.CreateShortCut(lnk)
sc.TargetPath = target
sc.Arguments = f'"{args}"'
sc.WorkingDirectory = work_dir
sc.Description = "LuZa Assistente"
sc.IconLocation = icon_loc
sc.save()
return
except ImportError:
pass
# ── Option 2: wscript.exe + VBScript (always available on Windows,
# GUI-mode executable — never opens a console window) ────────────
vbs = "\n".join([
'Set ws = CreateObject("WScript.Shell")',
f'Set sc = ws.CreateShortcut("{lnk}")',
f'sc.TargetPath = "{target}"',
f'sc.Arguments = Chr(34) & "{args}" & Chr(34)',
f'sc.WorkingDirectory = "{work_dir}"',
'sc.Description = "LuZa Assistente"',
f'sc.IconLocation = "{icon_loc}"',
'sc.Save',
])
import tempfile
fd, tmp = tempfile.mkstemp(suffix=".vbs")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(vbs)
proc = subprocess.Popen(
["wscript.exe", "/nologo", tmp],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW,
)
proc.wait(timeout=10)
finally:
try:
os.unlink(tmp)
except Exception:
pass
@staticmethod
def _get_desktop_dir() -> Path:
"""
Resolve the user's REAL desktop directory instead of assuming
~/Desktop, which breaks when:
• OneDrive "Known Folder Move" relocates the desktop
(C:/Users/x/OneDrive/Desktop) — very common on Win 10/11;
• the XDG desktop is localized on Linux (~/Masaüstü,
~/Schreibtisch, ~/Bureau, …).
Falls back to ~/Desktop only as a last resort.
"""
home = Path.home()
_os = platform.system()
if _os == "Windows":
# ── 1) SHGetKnownFolderPath(FOLDERID_Desktop) — the canonical
# answer; follows OneDrive redirection. No dependencies. ──
try:
import ctypes
from ctypes import wintypes
class _GUID(ctypes.Structure):
_fields_ = [("Data1", wintypes.DWORD),
("Data2", wintypes.WORD),
("Data3", wintypes.WORD),
("Data4", ctypes.c_ubyte * 8)]
# FOLDERID_Desktop {B4BFCC3A-DB2C-424C-B029-7FE99A87C641}
fid = _GUID(0xB4BFCC3A, 0xDB2C, 0x424C,
(ctypes.c_ubyte * 8)(0xB0, 0x29, 0x7F, 0xE9,
0x9A, 0x87, 0xC6, 0x41))
buf = ctypes.c_wchar_p()
if ctypes.windll.shell32.SHGetKnownFolderPath(
ctypes.byref(fid), 0, None, ctypes.byref(buf)) == 0:
p = Path(buf.value)
ctypes.windll.ole32.CoTaskMemFree(buf)
if p.is_dir():
return p
except Exception:
pass
# ── 2) Registry: User Shell Folders (may contain %VARS%) ──────
try:
import winreg
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion"
r"\Explorer\User Shell Folders") as key:
val, _t = winreg.QueryValueEx(key, "Desktop")
p = Path(os.path.expandvars(val))
if p.is_dir():
return p
except Exception:
pass
elif _os == "Linux":
# ── xdg-user-dir honours localized names (~/Masaüstü, …) ──────
try:
out = subprocess.run(["xdg-user-dir", "DESKTOP"],
capture_output=True, text=True, timeout=5)
p = Path(out.stdout.strip())
if out.stdout.strip() and p != home and p.is_dir():
return p
except Exception:
pass
try:
cfg = home / ".config" / "user-dirs.dirs"
for line in cfg.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith("XDG_DESKTOP_DIR"):
val = line.split("=", 1)[1].strip().strip('"')
p = Path(val.replace("$HOME", str(home)))
if p != home and p.is_dir():
return p
except Exception:
pass
# macOS: ~/Desktop is always the real path (localization is
# display-only). Everything else lands here as a last resort.
return home / "Desktop"
def _create_desktop_shortcut(self):
"""
Create a desktop shortcut on Windows / macOS / Linux.
Never opens a terminal, console, or PowerShell window on any platform.
"""
import stat as _stat
script = Path(__file__).resolve().parent / "main.py"
python = Path(sys.executable)
desktop = self._get_desktop_dir()
# Arc-reactor icon (.ico — also exported as .png for Linux/macOS)
ico_path = Path(__file__).resolve().parent / "config" / "jarvis.ico"
if not ico_path.exists():
self._build_jarvis_icon(ico_path)
try:
_os = platform.system()
# ── Windows ───────────────────────────────────────────────────────
if _os == "Windows":
pythonw = python.parent / "pythonw.exe"
target = str(pythonw if pythonw.exists() else python)
lnk = str(desktop / "LuZa.lnk")
icon_loc = str(ico_path) if ico_path.exists() else f"{target},0"
self._create_lnk_windows(lnk, target, str(script),
str(script.parent), icon_loc)
# ── macOS — proper .app bundle (no Terminal window) ───────────────
elif _os == "Darwin":
app = desktop / "LuZa.app"
mac_dir = app / "Contents" / "MacOS"
res_dir = app / "Contents" / "Resources"
mac_dir.mkdir(parents=True, exist_ok=True)
res_dir.mkdir(exist_ok=True)
# Launcher executable (bash — runs as background process,
# macOS does NOT open Terminal for executables inside .app bundles)
launcher = mac_dir / "LuZa"
launcher.write_text(
"#!/usr/bin/env bash\n"
f'cd "{script.parent}"\n'
f'exec "{python}" "{script}"\n'
)
launcher.chmod(launcher.stat().st_mode
| _stat.S_IEXEC | _stat.S_IXGRP | _stat.S_IXOTH)
# Minimal Info.plist (required for .app recognition)
(app / "Contents" / "Info.plist").write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
'<plist version="1.0"><dict>\n'
' <key>CFBundleExecutable</key><string>LuZa</string>\n'
' <key>CFBundleIdentifier</key>'
'<string>it.luza.assistente</string>\n'
' <key>CFBundleName</key><string>LuZa</string>\n'
' <key>CFBundlePackageType</key><string>APPL</string>\n'
' <key>CFBundleVersion</key><string>1.0</string>\n'
'</dict></plist>\n'
)
# Optional: copy icon as .icns (skip silently if Pillow is missing)
try:
import PIL.Image
icns = res_dir / "AppIcon.icns"
PIL.Image.open(ico_path).save(icns, format="ICNS")
# Inject icon reference into plist
plist = app / "Contents" / "Info.plist"
txt = plist.read_text()
plist.write_text(
txt.replace(
'</dict></plist>',
' <key>CFBundleIconFile</key>'
'<string>AppIcon</string>\n</dict></plist>\n',
)
)
except Exception:
pass # icon is optional
# ── Linux — .desktop file (Terminal=false, no console) ────────────
else:
# Export .ico → .png for better desktop integration
png_path = ico_path.with_suffix(".png")
if not png_path.exists() and ico_path.exists():
try:
import PIL.Image
PIL.Image.open(ico_path).resize(
(256, 256), PIL.Image.LANCZOS
).save(png_path, format="PNG")
except Exception:
png_path = ico_path # fallback to .ico
icon_line = f"Icon={png_path}\n" if png_path.exists() else ""
desk = desktop / "LuZa.desktop"
desk.write_text(
"[Desktop Entry]\n"
"Name=LuZa\n"
f"Exec={python} {script}\n"
f"Path={script.parent}\n"
"Type=Application\n"
"Terminal=false\n"
"Categories=Utility;\n"
+ icon_line
)
desk.chmod(desk.stat().st_mode | 0o755)
self._log.append_log("SYS: Desktop shortcut created.")
except Exception as e:
self._log.append_log(f"ERR: Shortcut failed — {e}")
def _toggle_fullscreen(self):
if self.isFullScreen():
self.showNormal()
else:
self.showFullScreen()
def resizeEvent(self, event):
super().resizeEvent(event)
cw = self.centralWidget()
if self._overlay and self._overlay.isVisible():
ow, oh = 460, 390
self._overlay.setGeometry(
(cw.width() - ow) // 2,
(cw.height() - oh) // 2,
ow, oh,
)
if self._remote_overlay and self._remote_overlay.isVisible():
ow, oh = RemoteKeyOverlay._OW, RemoteKeyOverlay._OH
self._remote_overlay.setGeometry(
(cw.width() - ow) // 2,
(cw.height() - oh) // 2,
ow, oh,
)
if self._customize_overlay and self._customize_overlay.isVisible():
ow, oh = CustomizeOverlay._OW, CustomizeOverlay._OH
self._customize_overlay.setGeometry(
(cw.width() - ow) // 2,
(cw.height() - oh) // 2,
ow, oh,
)
# Camera preview — bottom-right corner of the center/HUD area
pw = _CameraPreview._W
ph = self._cam_preview.height() or _CameraPreview._H
self._cam_preview.setGeometry(
cw.width() - _RIGHT_W - pw - 12,
cw.height() - ph - 28,
pw, ph,
)
# Clipboard panel — bottom-center
if hasattr(self, '_clipboard_panel') and self._clipboard_panel.isVisible():
self._position_clipboard_panel()
# Quick drawer — reposition if open
if hasattr(self, '_quick_drawer') and self._quick_drawer.isVisible():
self._position_quick_drawer()
def _update_metrics(self):
snap = _metrics.snapshot()
# CPU
cpu = snap["cpu"]
self._bar_cpu.set_value(cpu, f"{cpu:.0f}%")
# MEM
mem = snap["mem"]
self._bar_mem.set_value(mem, f"{mem:.0f}%")
# NET
net = snap["net"]
if net < 1.0:
net_str = f"{net*1024:.0f}KB/s"
else:
net_str = f"{net:.1f}MB/s"
net_pct = min(100, net * 10) # 10 MB/s = %100
self._bar_net.set_value(net_pct, net_str)
# GPU
gpu = snap["gpu"]
if gpu >= 0:
self._bar_gpu.set_value(gpu, f"{gpu:.0f}%")
else:
self._bar_gpu.set_value(0, "N/A")
# TMP
tmp = snap["tmp"]
if tmp >= 0:
tmp_pct = min(100, (tmp / 100) * 100)
self._bar_tmp.set_value(tmp_pct, f"{tmp:.0f}°C")
else:
self._bar_tmp.set_value(0, "N/A")
try:
boot_t = psutil.boot_time()
elapsed = time.time() - boot_t
h = int(elapsed // 3600)
m = int((elapsed % 3600) // 60)
self._uptime_lbl.setText(f"UP {h:02d}:{m:02d}")
except Exception:
self._uptime_lbl.setText("UP --:--")
try:
proc_count = len(psutil.pids())
self._proc_lbl.setText(f"PROC {proc_count}")
except Exception:
self._proc_lbl.setText("PROC --")
def _build_header(self) -> QWidget:
w = QWidget()
w.setFixedHeight(54)
w.setStyleSheet(f"background: {C.DARK}; border-bottom: 1px solid {C.BORDER_B};")
lay = QHBoxLayout(w)
lay.setContentsMargins(16, 0, 16, 0)
def _badge(txt, color=C.TEXT_MED):
l = QLabel(txt)
l.setFont(QFont("Menlo", 11))
l.setStyleSheet(f"color: {color}; background: transparent;")
return l
lay.addWidget(_badge(APP_VERSION, C.PRI_DIM))
lay.addSpacing(8)
self._drawer_btn = QPushButton("⚙")
self._drawer_btn.setFixedSize(26, 26)
self._drawer_btn.setFont(QFont("Menlo", 14))
self._drawer_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._drawer_btn.setToolTip("Settings & Controls")
self._drawer_btn.setStyleSheet(f"""
QPushButton {{
background: transparent; color: {C.TEXT_DIM};
border: 1px solid {C.BORDER}; border-radius: 4px;
}}
QPushButton:hover {{ color: {C.PRI}; border-color: {C.PRI_DIM}; }}
QPushButton:checked {{ color: {C.PRI}; border-color: {C.PRI}; background: {C.PRI_GHO}; }}
""")
self._drawer_btn.setCheckable(True)
self._drawer_btn.clicked.connect(self._toggle_drawer)
lay.addWidget(self._drawer_btn)
lay.addStretch()
mid = QVBoxLayout(); mid.setSpacing(1)
_disp = self._assistant_name.upper()
self._title_lbl = QLabel(_disp)
self._title_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._title_lbl.setFont(QFont("Menlo", 17, QFont.Weight.Bold))
self._title_lbl.setStyleSheet(f"color: {C.PRI}; background: transparent;")
mid.addWidget(self._title_lbl)
_sub_text = ("A Friendly Assistant"
if _disp in ("JARVIS", "J.A.R.V.I.S")
else "Assistente personale")
self._sub_lbl = QLabel(_sub_text)
self._sub_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._sub_lbl.setFont(QFont("Menlo", 10))
self._sub_lbl.setStyleSheet(f"color: {C.PRI_DIM}; background: transparent;")
mid.addWidget(self._sub_lbl)
lay.addLayout(mid)
lay.addStretch()
right_col = QVBoxLayout(); right_col.setSpacing(2)
self._clock_lbl = QLabel("00:00:00")
self._clock_lbl.setFont(QFont("Menlo", 17, QFont.Weight.Bold))
self._clock_lbl.setStyleSheet(f"color: {C.PRI}; background: transparent;")
self._clock_lbl.setAlignment(Qt.AlignmentFlag.AlignRight)
right_col.addWidget(self._clock_lbl)
self._date_lbl = QLabel("")
self._date_lbl.setFont(QFont("Menlo", 10))
self._date_lbl.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;")
self._date_lbl.setAlignment(Qt.AlignmentFlag.AlignRight)
right_col.addWidget(self._date_lbl)
lay.addLayout(right_col)
return w
def _tick_clock(self):
self._clock_lbl.setText(time.strftime("%H:%M:%S"))
self._date_lbl.setText(time.strftime("%a %d %b %Y"))
def _build_left_panel(self) -> QWidget:
w = QWidget()
w.setFixedWidth(_LEFT_W)
w.setStyleSheet(f"background: {C.DARK}; border-right: 1px solid {C.BORDER};")
lay = QVBoxLayout(w)
lay.setContentsMargins(8, 10, 8, 10)
lay.setSpacing(6)
hdr = QLabel("◈ SYS MONITOR")
hdr.setFont(QFont("Menlo", 10, QFont.Weight.Bold))
hdr.setStyleSheet(f"color: {C.PRI}; background: transparent; "
f"border-bottom: 1px solid {C.BORDER}; padding-bottom: 4px;")
lay.addWidget(hdr)
lay.addSpacing(2)
self._bar_cpu = MetricBar("CPU", C.PRI)
self._bar_mem = MetricBar("MEM", C.ACC2)
self._bar_net = MetricBar("NET", C.GREEN)
self._bar_gpu = MetricBar("GPU", C.ACC)
self._bar_tmp = MetricBar("TMP", "#ff6688")
for bar in [self._bar_cpu, self._bar_mem, self._bar_net,
self._bar_gpu, self._bar_tmp]:
lay.addWidget(bar)
lay.addSpacing(4)
info_panel = QWidget()
info_panel.setStyleSheet(
f"background: {C.PANEL2}; border: 1px solid {C.BORDER}; border-radius: 4px;"
)
ip_lay = QVBoxLayout(info_panel)
ip_lay.setContentsMargins(6, 5, 6, 5)
ip_lay.setSpacing(3)
self._uptime_lbl = QLabel("UP --:--")
self._uptime_lbl.setFont(QFont("Menlo", 11, QFont.Weight.Bold))
self._uptime_lbl.setStyleSheet(f"color: {C.GREEN}; background: transparent; border: none;")
ip_lay.addWidget(self._uptime_lbl)
self._proc_lbl = QLabel("PROC --")
self._proc_lbl.setFont(QFont("Menlo", 11))
self._proc_lbl.setStyleSheet(f"color: {C.TEXT_MED}; background: transparent; border: none;")
ip_lay.addWidget(self._proc_lbl)
os_name = {"Windows": "WIN", "Darwin": "macOS", "Linux": "LINUX"}.get(_OS, _OS.upper())
os_lbl = QLabel(f"OS {os_name}")
os_lbl.setFont(QFont("Menlo", 11))
os_lbl.setStyleSheet(f"color: {C.ACC2}; background: transparent; border: none;")
ip_lay.addWidget(os_lbl)
lay.addWidget(info_panel)
lay.addSpacing(4)
lay.addStretch()
for txt, col in [
("AI CORE\nACTIVE", C.GREEN),
("SEC\nCLEARED", C.PRI),
("PROTOCOL\n" + APP_PROTOCOL, C.TEXT_DIM),
]:
lbl = QLabel(txt)
lbl.setFont(QFont("Menlo", 10, QFont.Weight.Bold))
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
lbl.setStyleSheet(
f"color: {col}; background: {C.PANEL2};"
f"border: 1px solid {C.BORDER_A}; border-radius: 3px; padding: 4px;"
)
lay.addWidget(lbl)
return w
def _build_right_panel(self) -> QWidget:
w = QWidget()
w.setFixedWidth(_RIGHT_W)
w.setStyleSheet(f"background: {C.DARK}; border-left: 1px solid {C.BORDER};")
lay = QVBoxLayout(w)
lay.setContentsMargins(8, 8, 8, 8)
lay.setSpacing(6)
def _sec(txt):
l = QLabel(f"▸ {txt}")
l.setFont(QFont("Menlo", 10, QFont.Weight.Bold))
l.setStyleSheet(f"color: {C.TEXT_MED}; background: transparent;")
return l
lay.addWidget(_sec("ACTIVITY LOG"))
self._log = LogWidget()
lay.addWidget(self._log, stretch=1)
sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet(f"color: {C.BORDER}; margin: 2px 0;")
lay.addWidget(sep)
lay.addWidget(_sec("FILE UPLOAD"))
self._drop_zone = FileDropZone()
self._drop_zone.file_selected.connect(self._on_file_selected)
lay.addWidget(self._drop_zone)
self._file_hint = QLabel("No file loaded — drop or click above to upload")
self._file_hint.setFont(QFont("Menlo", 10))
self._file_hint.setStyleSheet(f"color: {C.TEXT_MED}; background: transparent;")
self._file_hint.setWordWrap(True)
lay.addWidget(self._file_hint)
sep2 = QFrame(); sep2.setFrameShape(QFrame.Shape.HLine)
sep2.setStyleSheet(f"color: {C.BORDER}; margin: 2px 0;")
lay.addWidget(sep2)
lay.addWidget(_sec("COMMAND INPUT"))
lay.addLayout(self._build_input_row())
self._interrupt_btn = QPushButton("✋ INTERRUPT [ESC]")
self._interrupt_btn.setFixedHeight(34)
self._interrupt_btn.setFont(QFont("Menlo", 11, QFont.Weight.Bold))
self._interrupt_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._interrupt_btn.setStyleSheet(f"""
QPushButton {{
background: #140008; color: {C.MUTED_C};
border: 1px solid {C.MUTED_C}; border-radius: 3px;
}}
QPushButton:hover {{
background: #200010; border: 1px solid #ff6688;
}}
QPushButton:pressed {{
background: #300018;
}}
""")
self._interrupt_btn.clicked.connect(self._do_interrupt)
lay.addWidget(self._interrupt_btn)
self._mute_btn = QPushButton("🎙 MICROPHONE ACTIVE")
self._mute_btn.setFixedHeight(30)
self._mute_btn.setFont(QFont("Menlo", 11, QFont.Weight.Bold))
self._mute_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._mute_btn.clicked.connect(self._toggle_mute)
self._style_mute_btn()
lay.addWidget(self._mute_btn)
return w
def _build_quick_drawer(self) -> QWidget:
"""Floating overlay panel shown when the ⚙ header button is toggled."""
_BTN_STYLE_PRI = f"""
QPushButton {{
background: #00091a; color: {C.PRI};
border: 1px solid {C.PRI_DIM}; border-radius: 3px;
text-align: left; padding: 0 8px;
}}
QPushButton:hover {{ background: {C.PRI_GHO}; border-color: {C.PRI}; }}
"""
_BTN_STYLE_DIM = f"""
QPushButton {{
background: transparent; color: {C.TEXT_MED};
border: 1px solid {C.BORDER}; border-radius: 3px;
text-align: left; padding: 0 8px;
}}
QPushButton:hover {{ color: {C.PRI}; border-color: {C.BORDER_B}; }}
"""
w = QWidget(self.centralWidget())
w.setObjectName("QuickDrawer")
w.setStyleSheet(f"""
QWidget#QuickDrawer {{
background: {C.DARK};
border: 1px solid {C.BORDER_B};
border-top: none;
border-radius: 0 0 6px 6px;
}}
""")
w.hide()
lay = QVBoxLayout(w)
lay.setContentsMargins(10, 8, 10, 10)
lay.setSpacing(5)
hdr = QLabel("◈ CONTROLS")
hdr.setFont(QFont("Menlo", 10, QFont.Weight.Bold))
hdr.setStyleSheet(f"color: {C.PRI_DIM}; background: transparent; "
f"border-bottom: 1px solid {C.BORDER}; padding-bottom: 4px;")
lay.addWidget(hdr)
remote_btn = QPushButton("◉ REMOTE CONTROL")
remote_btn.setFixedHeight(30)
remote_btn.setFont(QFont("Menlo", 11, QFont.Weight.Bold))
remote_btn.setCursor(Qt.CursorShape.PointingHandCursor)
remote_btn.setStyleSheet(_BTN_STYLE_PRI)
remote_btn.clicked.connect(self._open_remote)
lay.addWidget(remote_btn)
fs_btn = QPushButton("⛶ FULLSCREEN [F11]")
fs_btn.setFixedHeight(34)
fs_btn.setFont(QFont("Menlo", 10))
fs_btn.setCursor(Qt.CursorShape.PointingHandCursor)
fs_btn.setStyleSheet(_BTN_STYLE_DIM)
fs_btn.clicked.connect(self._toggle_fullscreen)
lay.addWidget(fs_btn)
sc_btn = QPushButton("⊞ CREATE DESKTOP SHORTCUT")
sc_btn.setFixedHeight(34)
sc_btn.setFont(QFont("Menlo", 10))
sc_btn.setCursor(Qt.CursorShape.PointingHandCursor)
sc_btn.setStyleSheet(_BTN_STYLE_DIM)
sc_btn.clicked.connect(self._create_desktop_shortcut)
lay.addWidget(sc_btn)
self._autostart_btn = QPushButton("◉ AUTO-START: OFF")
self._autostart_btn.setFixedHeight(34)
self._autostart_btn.setFont(QFont("Menlo", 10))
self._autostart_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._autostart_btn.clicked.connect(self._toggle_autostart)
lay.addWidget(self._autostart_btn)
cust_btn = QPushButton("⚙ CUSTOMISE ASSISTANT")
cust_btn.setFixedHeight(34)
cust_btn.setFont(QFont("Menlo", 10))
cust_btn.setCursor(Qt.CursorShape.PointingHandCursor)
cust_btn.setStyleSheet(_BTN_STYLE_DIM)
cust_btn.clicked.connect(self._open_customize)
lay.addWidget(cust_btn)
# AvatarPy: motore di conversazione, chiavi e voce
engine_btn = QPushButton("🧠 MOTORE E VOCE")
engine_btn.setFixedHeight(34)
engine_btn.setFont(QFont("Menlo", 10))
engine_btn.setCursor(Qt.CursorShape.PointingHandCursor)
engine_btn.setStyleSheet(_BTN_STYLE_DIM)
engine_btn.clicked.connect(lambda: self.on_engine_settings() if self.on_engine_settings else None)
lay.addWidget(engine_btn)
newconv_btn = QPushButton("🗑 NUOVA CONVERSAZIONE")
newconv_btn.setFixedHeight(34)
newconv_btn.setFont(QFont("Menlo", 10))
newconv_btn.setCursor(Qt.CursorShape.PointingHandCursor)
newconv_btn.setStyleSheet(_BTN_STYLE_DIM)
newconv_btn.clicked.connect(lambda: self.on_new_conversation() if self.on_new_conversation else None)
lay.addWidget(newconv_btn)
self._brief_btn = QPushButton()
self._brief_btn.setFixedHeight(34)
self._brief_btn.setFont(QFont("Menlo", 10))
self._brief_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._brief_btn.clicked.connect(self._toggle_brief)
lay.addWidget(self._brief_btn)
# ── Wake word ──────────────────────────────────────────────────────────
self._wake_btn = QPushButton()
self._wake_btn.setFixedHeight(34)
self._wake_btn.setFont(QFont("Menlo", 10))
self._wake_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._wake_btn.clicked.connect(self._toggle_wake_word)
lay.addWidget(self._wake_btn)
self._wake_sleep_btn = QPushButton()
self._wake_sleep_btn.setFixedHeight(34)
self._wake_sleep_btn.setFont(QFont("Menlo", 10))
self._wake_sleep_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._wake_sleep_btn.clicked.connect(self._tap_wake_manual)
lay.addWidget(self._wake_sleep_btn)
# Neutral placeholder now; the real state (which may load the model to
# check readiness) is resolved lazily the first time the drawer opens.
self._wake_btn.setText("🎙 WAKE WORD")
self._wake_btn.setStyleSheet(_BTN_STYLE_DIM)
self._wake_sleep_btn.hide()
self._ptt_btn = QPushButton()
self._ptt_btn.setFixedHeight(34)
self._ptt_btn.setFont(QFont("Menlo", 10))
self._ptt_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._ptt_btn.clicked.connect(self._toggle_ptt)
lay.addWidget(self._ptt_btn)
self._refresh_talk_btns()
self._hud_btn = QPushButton()
self._hud_btn.setFixedHeight(34)
self._hud_btn.setFont(QFont("Menlo", 10))
self._hud_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._hud_btn.clicked.connect(self._toggle_hud_style)
lay.addWidget(self._hud_btn)
self._refresh_hud_btn()
audio_btn = QPushButton("🎧 AUDIO DEVICES")
audio_btn.setFixedHeight(34)
audio_btn.setFont(QFont("Menlo", 10))
audio_btn.setCursor(Qt.CursorShape.PointingHandCursor)
audio_btn.setStyleSheet(_BTN_STYLE_DIM)
audio_btn.clicked.connect(self._open_audio_devices)
lay.addWidget(audio_btn)
mem_btn = QPushButton("🧠 MEMORY")
mem_btn.setFixedHeight(34)
mem_btn.setFont(QFont("Menlo", 10))
mem_btn.setCursor(Qt.CursorShape.PointingHandCursor)
mem_btn.setStyleSheet(_BTN_STYLE_DIM)
mem_btn.clicked.connect(self._open_memory_panel)
lay.addWidget(mem_btn)
plugin_btn = QPushButton("🧩 PLUGINS")
plugin_btn.setFixedHeight(34)
plugin_btn.setFont(QFont("Menlo", 10))
plugin_btn.setCursor(Qt.CursorShape.PointingHandCursor)
plugin_btn.setStyleSheet(_BTN_STYLE_DIM)
plugin_btn.clicked.connect(self._open_plugin_manager)
lay.addWidget(plugin_btn)
settings_btn = QPushButton("⚙ PLUGIN SETTINGS")
settings_btn.setFixedHeight(34)
settings_btn.setFont(QFont("Menlo", 10))
settings_btn.setCursor(Qt.CursorShape.PointingHandCursor)
settings_btn.setStyleSheet(_BTN_STYLE_DIM)
settings_btn.clicked.connect(self._open_plugin_settings)
lay.addWidget(settings_btn)
w.adjustSize()
return w
def _toggle_drawer(self, checked: bool):
if checked:
self._refresh_wake_btns() # resolve wake state on open (lazy)
self._position_quick_drawer()
self._quick_drawer.show()
self._quick_drawer.raise_()
else:
self._quick_drawer.hide()
def _position_quick_drawer(self):
if not hasattr(self, '_quick_drawer'):
return
_W = 220
self._quick_drawer.setFixedWidth(_W)
self._quick_drawer.adjustSize()
self._quick_drawer.setGeometry(12, 54, _W, self._quick_drawer.sizeHint().height())
def _build_input_row(self) -> QHBoxLayout:
row = QHBoxLayout(); row.setSpacing(5)
self._input = QLineEdit()
self._input.setPlaceholderText("Type a command or question…")
self._input.setFont(QFont("Menlo", 12))
self._input.setFixedHeight(30)
self._input.setStyleSheet(f"""
QLineEdit {{
background: #000d14; color: {C.WHITE};
border: 1px solid {C.BORDER}; border-radius: 3px; padding: 3px 7px;
}}
QLineEdit:focus {{ border: 1px solid {C.PRI}; }}
""")
self._input.returnPressed.connect(self._send)
row.addWidget(self._input)
send = QPushButton("▸")
send.setFixedSize(30, 30)
send.setFont(QFont("Menlo", 14, QFont.Weight.Bold))
send.setCursor(Qt.CursorShape.PointingHandCursor)
send.setStyleSheet(f"""
QPushButton {{
background: {C.PANEL}; color: {C.PRI};
border: 1px solid {C.PRI_DIM}; border-radius: 3px;
}}
QPushButton:hover {{ background: {C.PRI_GHO}; border: 1px solid {C.PRI}; }}
""")
send.clicked.connect(self._send)
row.addWidget(send)
return row
def _build_content_panel(self) -> QWidget:
"""
Collapsible panel below the HUD — shows search results, news, briefings.
Hidden by default; appears when show_content() is called.
"""
w = QWidget()
w.setObjectName("ContentPanel")
w.setStyleSheet(f"""
QWidget#ContentPanel {{
background: {C.PANEL};
border-top: 1px solid {C.BORDER_B};
}}
""")
w.hide()
lay = QVBoxLayout(w)
lay.setContentsMargins(12, 7, 12, 8)
lay.setSpacing(5)
# ── header row ───────────────────────────────────────────────────────
hdr = QHBoxLayout(); hdr.setSpacing(6)
dot = QLabel("◈")
dot.setFont(QFont("Menlo", 12, QFont.Weight.Bold))
dot.setStyleSheet(f"color: {C.PRI}; background: transparent;")
hdr.addWidget(dot)
self._content_title_lbl = QLabel("BRIEFING")
self._content_title_lbl.setFont(QFont("Menlo", 11, QFont.Weight.Bold))
self._content_title_lbl.setStyleSheet(
f"color: {C.PRI}; background: transparent; letter-spacing: 1px;"
)
hdr.addWidget(self._content_title_lbl)
hdr.addStretch()
self._content_ts_lbl = QLabel("")
self._content_ts_lbl.setFont(QFont("Menlo", 10))
self._content_ts_lbl.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;")
hdr.addWidget(self._content_ts_lbl)
dismiss = QPushButton("DISMISS ✕")
dismiss.setFont(QFont("Menlo", 10))
dismiss.setFixedHeight(18)
dismiss.setCursor(Qt.CursorShape.PointingHandCursor)
dismiss.setStyleSheet(f"""
QPushButton {{
background: transparent; color: {C.TEXT_DIM};
border: 1px solid {C.BORDER}; border-radius: 2px; padding: 0 5px;
}}
QPushButton:hover {{ color: {C.TEXT}; border-color: {C.BORDER_B}; }}
""")
dismiss.clicked.connect(w.hide)
hdr.addWidget(dismiss)
lay.addLayout(hdr)
# ── separator ─────────────────────────────────────────────────────────
sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet(f"color: {C.BORDER};"); lay.addWidget(sep)
# ── text display ──────────────────────────────────────────────────────
self._content_display = QTextEdit()
self._content_display.setReadOnly(True)
self._content_display.setFont(QFont("Menlo", 11))
self._content_display.setMinimumHeight(60)
self._content_display.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
self._content_display.setStyleSheet(f"""
QTextEdit {{
background: {C.DARK};
color: {C.TEXT};
border: 1px solid {C.BORDER};
border-radius: 3px;
padding: 6px 8px;
selection-background-color: {C.PRI_GHO};
}}
QScrollBar:vertical {{
background: {C.BG}; width: 6px; border: none;
}}
QScrollBar::handle:vertical {{
background: {C.BORDER_B}; border-radius: 3px; min-height: 16px;
}}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
height: 0; border: none;
}}
""")
lay.addWidget(self._content_display)
return w
def _show_content(self, title: str, text: str):
"""Slot — runs on Qt main thread. Updates and shows the content panel."""
import time as _time
# The panel opens below the head, so the head looks down at it. It is a
# tiny thing that answers "did that land?" before you read a word.
self.hud.glance(0.0, -0.85, hold=1.3)
self._content_title_lbl.setText(title.upper()[:48])
self._content_ts_lbl.setText(_time.strftime("%H:%M:%S"))
self._content_display.setPlainText(text)
self._content_display.moveCursor(
self._content_display.textCursor().MoveOperation.Start
)
first_show = not self._content_panel.isVisible()
self._content_panel.show()
if first_show:
total = self._center_split.height()
self._center_split.setSizes([max(total - 220, 120), 220])
# ── document review ──────────────────────────────────────────────────────
# Rendered as rich text into the content panel that already exists, rather
# than into a panel of its own. A review is read, not clicked, so QTextEdit
# gives scrolling, selection and copy for nothing, and the HUD gains no
# widget it has to lay out. Severity decides colour and order here because
# that is presentation; the plugin supplies no styling and knows no palette,
# which is also what lets a re-theme repaint a review correctly.
# Severity is marked by a symbol and a colour, not by a word. The findings
# themselves are in the user's language, and "[SERIOUS]" sitting inside a
# Turkish sentence is the kind of seam this project tries not to have —
# while translating the tag would mean a table per language, which is worse.
# A shape carries it in every language, and shape plus colour still reads
# for someone who cannot separate red from amber. What the marks mean
# arrives the way everything else does: JARVIS says it out loud.
_REVIEW_MARKS = {"serious": ("RED", "▲"), "caution": ("ACC2", "●"), "note": ("PRI_DIM", "·")}
@staticmethod
def _esc(s) -> str:
return (str(s or "").replace("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace("\n", "<br>"))
def _show_review(self, title: str, summary: str, findings, unclear):
"""Slot — Qt main thread. Lays a document review into the content panel."""
e = self._esc
parts = [f'<div style="color:{C.TEXT}; font-family:Courier New;">']
if summary:
parts.append(
f'<div style="color:{C.WHITE}; border-left:2px solid {C.PRI};'
f' padding-left:8px; margin-bottom:10px;">{e(summary)}</div>')
for f in (findings or []):
key, mark = self._REVIEW_MARKS.get(f.get("severity"), ("PRI_DIM", "·"))
colour = getattr(C, key)
parts.append(f'<div style="margin-bottom:11px;">')
parts.append(
f'<span style="color:{colour}; font-weight:bold;">{mark}</span> '
f'<span style="color:{C.WHITE}; font-weight:bold;">'
f'{e(f.get("heading"))}</span>')
if f.get("detail"):
parts.append(f'<div style="margin-left:12px;">{e(f["detail"])}</div>')
if f.get("quote"):
# The document's own wording, visually separated from the
# explanation so the two are never mistaken for each other.
parts.append(
f'<div style="margin-left:12px; color:{C.TEXT_DIM};'
f' border-left:1px solid {C.BORDER}; padding-left:7px;">'
f'&ldquo;{e(f["quote"])}&rdquo;</div>')
if f.get("suggestion"):
parts.append(
f'<div style="margin-left:12px; color:{C.PRI};">'
f'&rarr; {e(f["suggestion"])}</div>')
parts.append('</div>')
if unclear:
parts.append(
f'<div style="margin-top:6px; border-top:1px solid {C.BORDER};'
f' padding-top:7px; color:{C.TEXT_MED};">'
'The document does not settle:</div>')
for u in unclear:
parts.append(
f'<div style="margin-left:12px; color:{C.TEXT_MED};">'
f'&middot; {e(u)}</div>')
parts.append('</div>')
import time as _time
self.hud.glance(0.0, -0.85, hold=1.3)
# Left as written, not upper-cased. The other content-panel titles are
# the app's own English labels, but this one is the document's name in
# the user's language, and str.upper() applies English casing rules to
# it: Turkish "Sözleşmesi" comes back "SÖZLEŞMESI", having lost the
# dotted capital İ. Python has no locale-aware upper to reach for, and
# imposing one language's rules on all of them is the bug, not the fix.
self._content_title_lbl.setText((title or "Document")[:48])
self._content_ts_lbl.setText(_time.strftime("%H:%M:%S"))
self._content_display.setHtml("".join(parts))
self._content_display.moveCursor(
self._content_display.textCursor().MoveOperation.Start)
first_show = not self._content_panel.isVisible()
self._content_panel.show()
if first_show:
total = self._center_split.height()
self._center_split.setSizes([max(total - 260, 120), 260, 0])
# ── quiz panel ───────────────────────────────────────────────────────────
# An interactive twin of the content panel. The plugin only ever hands over
# questions; everything about asking, marking and reporting happens here,
# and the finished result is pushed back into the conversation the same way
# a dropped file is — as a message JARVIS reads and responds to. That keeps
# the tool call short (it returns the moment the board is up) and leaves the
# talking to the assistant, in the user's own language.
def _quiz_btn(self, text: str, primary: bool = False) -> QPushButton:
b = QPushButton(text)
b.setFont(QFont("Menlo", 11))
b.setCursor(Qt.CursorShape.PointingHandCursor)
b.setMinimumHeight(24)
edge = C.BORDER_B if primary else C.BORDER
col = C.PRI if primary else C.TEXT_MED
b.setStyleSheet(f"""
QPushButton {{
background: {C.PANEL2}; color: {col};
border: 1px solid {edge}; border-radius: 2px;
padding: 3px 9px; text-align: left;
}}
QPushButton:hover {{ color: {C.WHITE}; border-color: {C.PRI_DIM}; }}
QPushButton:disabled {{ color: {C.TEXT_DIM}; border-color: {C.BORDER}; }}
""")
return b
def _build_quiz_panel(self) -> QWidget:
w = QWidget()
w.setObjectName("QuizPanel")
w.setStyleSheet(f"""
QWidget#QuizPanel {{
background: {C.PANEL};
border-top: 1px solid {C.BORDER_B};
}}
""")
w.hide()
lay = QVBoxLayout(w)
lay.setContentsMargins(12, 7, 12, 8)
lay.setSpacing(6)
hdr = QHBoxLayout(); hdr.setSpacing(6)
dot = QLabel("◈")
dot.setFont(QFont("Menlo", 12, QFont.Weight.Bold))
dot.setStyleSheet(f"color: {C.PRI}; background: transparent;")
hdr.addWidget(dot)
self._quiz_title_lbl = QLabel("QUIZ")
self._quiz_title_lbl.setFont(QFont("Menlo", 11, QFont.Weight.Bold))
self._quiz_title_lbl.setStyleSheet(
f"color: {C.PRI}; background: transparent; letter-spacing: 1px;")
hdr.addWidget(self._quiz_title_lbl)
hdr.addStretch()
self._quiz_count_lbl = QLabel("")
self._quiz_count_lbl.setFont(QFont("Menlo", 10))
self._quiz_count_lbl.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;")
hdr.addWidget(self._quiz_count_lbl)
quit_btn = QPushButton("DISMISS ✕")
quit_btn.setFont(QFont("Menlo", 10))
quit_btn.setFixedHeight(18)
quit_btn.setCursor(Qt.CursorShape.PointingHandCursor)
quit_btn.setStyleSheet(f"""
QPushButton {{
background: transparent; color: {C.TEXT_DIM};
border: 1px solid {C.BORDER}; border-radius: 2px; padding: 0 5px;
}}
QPushButton:hover {{ color: {C.TEXT}; border-color: {C.BORDER_B}; }}
""")
quit_btn.clicked.connect(self._hide_quiz)
hdr.addWidget(quit_btn)
lay.addLayout(hdr)
rule = QFrame(); rule.setFixedHeight(1)
rule.setStyleSheet(f"background: {C.BORDER};")
lay.addWidget(rule)
self._quiz_q_lbl = QLabel("")
self._quiz_q_lbl.setWordWrap(True)
self._quiz_q_lbl.setFont(QFont("Menlo", 12))
self._quiz_q_lbl.setStyleSheet(f"color: {C.WHITE}; background: transparent;")
lay.addWidget(self._quiz_q_lbl)
self._quiz_answers = QWidget()
self._quiz_answers.setStyleSheet("background: transparent;")
self._quiz_answers_lay = QVBoxLayout(self._quiz_answers)
self._quiz_answers_lay.setContentsMargins(0, 2, 0, 0)
self._quiz_answers_lay.setSpacing(4)
lay.addWidget(self._quiz_answers)
self._quiz_note_lbl = QLabel("")
self._quiz_note_lbl.setWordWrap(True)
self._quiz_note_lbl.setFont(QFont("Menlo", 11))
self._quiz_note_lbl.setStyleSheet(f"color: {C.TEXT_MED}; background: transparent;")
self._quiz_note_lbl.hide()
lay.addWidget(self._quiz_note_lbl)
foot = QHBoxLayout()
foot.addStretch()
self._quiz_next_btn = self._quiz_btn("NEXT →", primary=True)
self._quiz_next_btn.setFixedWidth(110)
self._quiz_next_btn.clicked.connect(self._quiz_next)
self._quiz_next_btn.hide()
foot.addWidget(self._quiz_next_btn)
lay.addLayout(foot)
self._quiz = None
return w
def _show_quiz(self, topic: str, questions, grader=None):
"""Slot — Qt main thread. Puts a fresh quiz on the board."""
if not questions:
return
self._quiz = {
"topic": topic or "",
"questions": list(questions),
"grader": grader,
"i": 0,
"results": [],
"answered": False,
}
self._quiz_title_lbl.setText((topic or "quiz").upper()[:48])
self.hud.glance(0.0, -0.85, hold=1.3)
first_show = not self._quiz_panel.isVisible()
self._quiz_panel.show()
if first_show:
total = self._center_split.height()
self._center_split.setSizes([max(total - 250, 120), 0, 250])
self._quiz_render()
def _hide_quiz(self):
self._quiz = None
self._quiz_panel.hide()
def _quiz_clear_answers(self):
while self._quiz_answers_lay.count():
item = self._quiz_answers_lay.takeAt(0)
child = item.widget()
if child is not None:
child.setParent(None)
child.deleteLater()
def _quiz_render(self):
q = self._quiz["questions"][self._quiz["i"]]
n, total = self._quiz["i"] + 1, len(self._quiz["questions"])
self._quiz_count_lbl.setText(f"{n} / {total}")
self._quiz_q_lbl.setText(q.get("question", ""))
self._quiz_note_lbl.hide()
self._quiz_next_btn.hide()
self._quiz["answered"] = False
self._quiz_clear_answers()
opts = q.get("options") or []
if opts:
for text in opts:
b = self._quiz_btn(" " + text)
b.clicked.connect(lambda _=False, t=text: self._quiz_submit(t))
self._quiz_answers_lay.addWidget(b)
else:
row = QWidget(); row.setStyleSheet("background: transparent;")
h = QHBoxLayout(row); h.setContentsMargins(0, 0, 0, 0); h.setSpacing(6)
field = QLineEdit()
field.setFont(QFont("Menlo", 12))
field.setPlaceholderText("your answer")
field.setStyleSheet(f"""
QLineEdit {{
background: {C.PANEL2}; color: {C.WHITE};
border: 1px solid {C.BORDER}; border-radius: 2px; padding: 4px 7px;
}}
QLineEdit:focus {{ border-color: {C.PRI_DIM}; }}
""")
send = self._quiz_btn("ANSWER", primary=True)
send.setFixedWidth(90)
field.returnPressed.connect(lambda: self._quiz_submit(field.text()))
send.clicked.connect(lambda: self._quiz_submit(field.text()))
h.addWidget(field, stretch=1)
h.addWidget(send)
self._quiz_answers_lay.addWidget(row)
field.setFocus()
def _quiz_submit(self, given: str):
if self._quiz is None or self._quiz["answered"]:
return
self._quiz["answered"] = True
q = self._quiz["questions"][self._quiz["i"]]
grader = self._quiz.get("grader")
verdict = None
if callable(grader):
try:
verdict = grader(q, given)
except Exception:
verdict = None
self._quiz["results"].append({
"question": q.get("question", ""),
"type": q.get("type", ""),
"given": str(given or "").strip(),
"answer": q.get("answer", ""),
"correct": verdict,
})
for i in range(self._quiz_answers_lay.count()):
wdg = self._quiz_answers_lay.itemAt(i).widget()
if wdg is not None:
wdg.setEnabled(False)
if verdict is True:
mark, colour = "✓ correct", C.GREEN
elif verdict is False:
mark, colour = "✕ " + str(q.get("answer", "")), C.RED
else:
# Open answers and near-miss gap-fills are JARVIS's to judge. Saying
# so is honest; marking it wrong here would be a guess.
mark, colour = "… noted — I'll go over this one with you", C.ACC2
note = q.get("note") or ""
self._quiz_note_lbl.setText(mark + (("\n" + note) if note else ""))
self._quiz_note_lbl.setStyleSheet(f"color: {colour}; background: transparent;")
self._quiz_note_lbl.show()
last = self._quiz["i"] >= len(self._quiz["questions"]) - 1
self._quiz_next_btn.setText("FINISH →" if last else "NEXT →")
self._quiz_next_btn.show()
self._quiz_next_btn.setFocus()
def _quiz_next(self):
if self._quiz is None:
return
if self._quiz["i"] >= len(self._quiz["questions"]) - 1:
self._quiz_finish()
else:
self._quiz["i"] += 1
self._quiz_render()
def _quiz_finish(self):
if self._quiz is None:
return
topic = self._quiz["topic"]
results = self._quiz["results"]
right = sum(1 for r in results if r["correct"] is True)
unsure = sum(1 for r in results if r["correct"] is None)
total = len(results)
self._quiz_panel.hide()
self._quiz = None
self._log.append_log(f"QUIZ: {topic or 'quiz'} — {right}/{total} correct")
# Hand it back to JARVIS as a message, not as a tool return: the tool
# call ended minutes ago. This is the same channel a dropped file uses.
lines = [f"[QUIZ_DONE] topic={topic or 'general'} | "
f"auto-marked {right}/{total} correct"
+ (f", {unsure} still need your marking" if unsure else "")]
for i, r in enumerate(results, 1):
state = ("correct" if r["correct"] is True
else "wrong" if r["correct"] is False else "NEEDS MARKING")
lines.append(
f"{i}. [{r['type']}] {r['question']} | they answered: "
f"{r['given'] or '(blank)'} | expected: {r['answer']} | {state}")
lines.append(
"Mark every question flagged NEEDS MARKING yourself — accept an answer "
"that means the same thing. Then tell them how they did in their own "
"language: the score, what they got wrong and why, in a couple of "
"sentences. Offer another round only if it fits. "
"Remember something only if it would still matter next week — that they "
"are working through a subject, or keep missing the same thing. A score "
"from one session is not worth a memory, and a memory per quiz would "
"bury the things that are.")
msg = "\n".join(lines)
if self.on_text_command:
threading.Thread(target=self.on_text_command, args=(msg,), daemon=True).start()
def _build_footer(self) -> QWidget:
w = QWidget()
w.setFixedHeight(22)
w.setStyleSheet(f"background: {C.DARK}; border-top: 1px solid {C.BORDER};")
lay = QHBoxLayout(w); lay.setContentsMargins(14, 0, 14, 0)
def _fl(txt, color=C.TEXT_MED):
l = QLabel(txt); l.setFont(QFont("Menlo", 10))
l.setStyleSheet(f"color: {color}; background: transparent;")
return l
lay.addWidget(_fl("[F4] Mute · [F11] Fullscreen"))
lay.addStretch()
lay.addWidget(_fl("By LuZa", C.PRI_DIM))
return w
def _on_file_selected(self, path: str):
self._current_file = path
p = Path(path)
cat = _file_category(p)
icon, _ = _FILE_ICONS.get(cat, _FILE_ICONS["unknown"])
size = _fmt_size(p.stat().st_size)
self._file_hint.setText(f"{icon} {p.name} · {size} · Tell {self._assistant_name} what to do with it")
self._log.append_log(f"FILE: {p.name} ({size}) loaded")
if self.on_text_command:
msg = (
f"[FILE_UPLOADED] path={path} | name={p.name} | "
f"type={p.suffix.lstrip('.')} | size={size} | "
f"Briefly tell the user you can see the file '{p.name}' "
f"({size}) has been uploaded and ask what they'd like to do with it."
)
threading.Thread(target=self.on_text_command, args=(msg,), daemon=True).start()
def notify_phone_connected(self) -> None:
if self._remote_overlay and self._remote_overlay.isVisible():
self._remote_overlay.mark_connected()
def _open_remote(self):
if not self.on_remote_clicked:
self._log.append_log("SYS: Dashboard not running — remote unavailable.")
return
result = self.on_remote_clicked()
if not result:
self._log.append_log("SYS: Could not generate remote key.")
return
url = result[0]
key = result[1]
auto = result[2] if len(result) >= 3 else ""
manual = result[3] if len(result) >= 4 else url
if self._remote_overlay:
self._remote_overlay._do_close()
cw = self.centralWidget()
ow, oh = RemoteKeyOverlay._OW, RemoteKeyOverlay._OH
ov = RemoteKeyOverlay(url, key, auto_login_url=auto, manual_url=manual,
expiry_secs=600, parent=cw)
ov.set_new_key_callback(self.on_remote_clicked)
ov.setGeometry(
(cw.width() - ow) // 2,
(cw.height() - oh) // 2,
ow, oh,
)
ov.closed.connect(lambda: setattr(self, '_remote_overlay', None))
ov.show()
self._remote_overlay = ov
self._log.append_log(f"SYS: Remote key generated — manual: {manual or url}")
# ── Auto-start ──────────────────────────────────────────────────────────────
def _check_autostart(self) -> bool:
"""Returns True if auto-start is currently registered on this OS."""
try:
if _OS == "Windows":
import winreg
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run", 0, winreg.KEY_READ)
try:
winreg.QueryValueEx(key, "JARVIS_AI")
return True
except FileNotFoundError:
return False
finally:
winreg.CloseKey(key)
elif _OS == "Darwin":
return (Path.home() / "Library" / "LaunchAgents"
/ "com.jarvis.assistant.plist").exists()
else:
return (Path.home() / ".config" / "autostart" / "jarvis.desktop").exists()
except Exception:
return False
def _toggle_autostart(self):
currently_on = self._check_autostart()
try:
script = str(Path(__file__).resolve().parent / "main.py")
if _OS == "Windows":
import winreg
reg = winreg.OpenKey(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run", 0, winreg.KEY_ALL_ACCESS)
if currently_on:
winreg.DeleteValue(reg, "JARVIS_AI")
else:
pythonw = Path(sys.executable).parent / "pythonw.exe"
exe = str(pythonw if pythonw.exists() else sys.executable)
winreg.SetValueEx(reg, "JARVIS_AI", 0, winreg.REG_SZ,
f'"{exe}" "{script}"')
winreg.CloseKey(reg)
elif _OS == "Darwin":
plist_dir = Path.home() / "Library" / "LaunchAgents"
plist_dir.mkdir(parents=True, exist_ok=True)
plist = plist_dir / "com.jarvis.assistant.plist"
if currently_on:
plist.unlink(missing_ok=True)
else:
plist.write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
'<plist version="1.0"><dict>\n'
' <key>Label</key><string>it.luza.assistente</string>\n'
' <key>ProgramArguments</key><array>\n'
f' <string>{sys.executable}</string>\n'
f' <string>{script}</string>\n'
' </array>\n'
' <key>RunAtLoad</key><true/>\n'
'</dict></plist>\n'
)
else:
desk_dir = Path.home() / ".config" / "autostart"
desk_dir.mkdir(parents=True, exist_ok=True)
desk = desk_dir / "jarvis.desktop"
if currently_on:
desk.unlink(missing_ok=True)
else:
desk.write_text(
"[Desktop Entry]\n"
f"Name={self._assistant_name}\n"
f"Exec={sys.executable} {script}\n"
"Type=Application\nTerminal=false\n"
"X-GNOME-Autostart-enabled=true\n"
)
enabled = not currently_on
self._update_autostart_btn(enabled)
self._log.append_log(
f"SYS: Auto-start {'enabled' if enabled else 'disabled'}.")
except Exception as e:
self._log.append_log(f"ERR: Auto-start failed — {e}")
def _update_autostart_btn(self, enabled: bool):
if not hasattr(self, '_autostart_btn'):
return
if enabled:
self._autostart_btn.setText("◉ AUTO-START: ON")
self._autostart_btn.setStyleSheet(f"""
QPushButton {{
background: #001a08; color: {C.GREEN};
border: 1px solid {C.GREEN_D}; border-radius: 3px;
}}
QPushButton:hover {{ background: #002010; }}
""")
else:
self._autostart_btn.setText("◉ AUTO-START: OFF")
self._autostart_btn.setStyleSheet(f"""
QPushButton {{
background: transparent; color: {C.TEXT_DIM};
border: 1px solid {C.BORDER}; border-radius: 3px;
}}
QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }}
""")
def _toggle_brief(self):
from memory.config_manager import get_brief_enabled, save_brief_enabled
new_val = not get_brief_enabled()
save_brief_enabled(new_val)
self._update_brief_btn(new_val)
# ── Wake word settings ───────────────────────────────────────────────────
def _wake_state(self) -> dict:
"""Combined state for the two wake-word buttons. Readiness is a cheap,
deterministic on-disk check now (see core.wake_word.is_ready), so there
is nothing to cache — the button never flickers to a stale value."""
if self.wake_get_state:
try:
s = self.wake_get_state()
return {"ready": bool(s.get("ready")),
"enabled": bool(s.get("enabled")),
"awake": bool(s.get("awake"))}
except Exception:
pass
# Before JarvisLive has wired its callback (drawer built at startup).
ready, enabled = False, False
try:
from core.wake_word import is_ready
from memory.config_manager import get_wake_word_enabled
ready, enabled = is_ready(), get_wake_word_enabled()
except Exception:
pass
return {"ready": ready, "enabled": enabled, "awake": True}
def _refresh_wake_btns(self):
if not hasattr(self, '_wake_btn'):
return
st = self._wake_state()
_on = f"""
QPushButton {{ background: #001a08; color: {C.GREEN};
border: 1px solid {C.GREEN_D}; border-radius: 3px;
text-align: left; padding: 0 8px; }}
QPushButton:hover {{ background: #002010; }}"""
_off = f"""
QPushButton {{ background: transparent; color: {C.TEXT_DIM};
border: 1px solid {C.BORDER}; border-radius: 3px;
text-align: left; padding: 0 8px; }}
QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }}"""
self._wake_btn.setEnabled(True)
if not st["ready"]:
self._wake_btn.setText("⬇ WAKE WORD: DOWNLOAD")
self._wake_btn.setStyleSheet(_off)
self._wake_sleep_btn.hide()
elif st["enabled"]:
self._wake_btn.setText("🎙 WAKE WORD: ON")
self._wake_btn.setStyleSheet(_on)
self._wake_sleep_btn.show()
self._wake_sleep_btn.setText("😴 SLEEP NOW" if st["awake"] else "👂 WAKE NOW")
self._wake_sleep_btn.setStyleSheet(_off)
else:
self._wake_btn.setText("🎙 WAKE WORD: OFF")
self._wake_btn.setStyleSheet(_off)
self._wake_sleep_btn.hide()
def _refresh_talk_btns(self):
"""Repaint the push-to-talk row from the saved setting."""
if not hasattr(self, "_ptt_btn"):
return
from core.hotkey import chord_label
from memory.config_manager import get_push_to_talk_enabled
_on = f"""
QPushButton {{ background: #001a08; color: {C.GREEN};
border: 1px solid {C.GREEN_D}; border-radius: 3px;
text-align: left; padding: 0 8px; }}
QPushButton:hover {{ background: #002010; }}"""
_off = f"""
QPushButton {{ background: transparent; color: {C.TEXT_DIM};
border: 1px solid {C.BORDER}; border-radius: 3px;
text-align: left; padding: 0 8px; }}
QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }}"""
ptt = get_push_to_talk_enabled()
self._ptt_btn.setText(f"🎚 PUSH-TO-TALK: {chord_label()}" if ptt
else "🎚 PUSH-TO-TALK: OFF")
self._ptt_btn.setStyleSheet(_on if ptt else _off)
self._ptt_btn.setToolTip(
"Microphone stays closed until you hold the key — nothing is sent "
"while you are not holding it." if ptt
else "Hold a key to talk instead of streaming the mic continuously.")
def _refresh_hud_btn(self):
from memory.config_manager import get_hud_style
cur = get_hud_style()
face = cur == "face"
# Neither state is "off", so both read as active — this is a choice
# between two things, not a switch with a disabled side.
style = f"""
QPushButton {{ background: {C.PANEL2}; color: {C.PRI};
border: 1px solid {C.BORDER_A}; border-radius: 3px;
text-align: left; padding: 0 8px; }}
QPushButton:hover {{ color: {C.WHITE}; border: 1px solid {C.BORDER_B}; }}"""
self._hud_btn.setText({"face": "🧑 HUD: ANIMATED FACE", "core": "◉ HUD: REACTOR CORE"}.get(cur, "🧍 HUD: AVATAR 3D"))
self._hud_btn.setStyleSheet(style)
self._hud_btn.setToolTip(
"An animated head that speaks your words and shows what the assistant is "
"doing. Tap to switch to the reactor core."
if face else
"A reactor core che segue lo stato e la tua voce. "
"Tap to switch to the animated head.")
def _toggle_hud_style(self):
"""Swap the centrepiece. Both objects stay in memory, so the change is
instant and switching back costs nothing."""
from memory.config_manager import get_hud_style, save_hud_style
order = ["face", "core", "3d"]
cur = get_hud_style()
want = order[(order.index(cur) + 1) % len(order)] if cur in order else "face"
if want == "3d":
try:
self._ensure_avatar3d()
except Exception as e:
self._log.append_log(f"ERR: Avatar 3D non disponibile — {e}")
want = "face"
save_hud_style(want)
if want != "3d":
self._hud_cam_stack.setCurrentIndex(0)
try:
self.hud.hud_style = want if want != "3d" else "face"
self.hud.update()
except Exception:
pass
self._refresh_hud_btn()
self._log.append_log({"face": "SYS: HUD switched to the animated face.",
"core": "SYS: HUD switched to the reactor core."}.get(want, "SYS: HUD: avatar 3D."))
def _toggle_ptt(self):
from memory.config_manager import (get_push_to_talk_enabled,
save_push_to_talk_enabled)
want = not get_push_to_talk_enabled()
save_push_to_talk_enabled(want)
scope = None
if self.on_push_to_talk:
try:
scope = self.on_push_to_talk(want)
except Exception as e:
self._log.append_log(f"ERR: Push-to-talk failed — {e}")
save_push_to_talk_enabled(False)
want = False
self._apply_ptt_shortcut(want and scope != "global")
self._refresh_talk_btns()
def _apply_ptt_shortcut(self, needed: bool):
"""Bind the chord inside the window when no global hook is available.
On macOS and Linux there is no dependency-free way to read global key
state, so the chord is at least live whenever this window has focus.
Qt gives no key-release for a QShortcut, so a press latches the mic open
and a short timer closes it; held down, auto-repeat keeps pushing that
timer out, which behaves like holding a key.
"""
from PyQt6.QtGui import QKeySequence, QShortcut
from core.hotkey import qt_sequence
if not needed:
sc = getattr(self, "_ptt_sc", None)
if sc is not None:
sc.setEnabled(False)
self._ptt_sc = None
self._ptt_hold(False)
return
if getattr(self, "_ptt_sc", None) is not None:
return
self._ptt_release = QTimer(self)
self._ptt_release.setSingleShot(True)
self._ptt_release.setInterval(420)
self._ptt_release.timeout.connect(lambda: self._ptt_hold(False))
def _press():
self._ptt_hold(True)
self._ptt_release.start()
self._ptt_sc = QShortcut(QKeySequence(qt_sequence()), self)
self._ptt_sc.setAutoRepeat(True)
self._ptt_sc.activated.connect(_press)
def _ptt_hold(self, held: bool):
"""Report a windowed press/release to whoever owns the microphone."""
cb = getattr(self, "ptt_hold", None)
if cb:
try:
cb(bool(held))
except Exception:
pass
def _toggle_wake_word(self):
st = self._wake_state()
if not st["ready"]:
# First time: download openwakeword + model in a worker thread.
self._wake_btn.setText("⬇ DOWNLOADING… (one-time)")
self._wake_btn.setEnabled(False)
def _work():
try:
from core.wake_word import install_and_download
ok, msg = install_and_download(
logger=lambda m: self._log_sig.emit(f"SYS: {m}"))
except Exception as e:
ok, msg = False, str(e)
if ok and self.on_wake_toggle:
try:
self.on_wake_toggle(True) # auto-enable after a successful download
except Exception:
pass
self._wake_dl_sig.emit(ok, msg)
threading.Thread(target=_work, daemon=True).start()
return
# Already downloaded → just flip enabled/disabled through JarvisLive.
if self.on_wake_toggle:
try:
self.on_wake_toggle(not st["enabled"])
except Exception:
pass
self._refresh_wake_btns()
def _on_wake_install_done(self, ok: bool, msg: str):
self._log_sig.emit(f"SYS: {'Wake word ready.' if ok else 'Wake word setup failed: ' + msg}")
self._refresh_wake_btns()
def _tap_wake_manual(self):
if self.on_wake_manual:
try:
self.on_wake_manual()
except Exception:
pass
self._refresh_wake_btns()
def _update_brief_btn(self, enabled: bool):
if not hasattr(self, '_brief_btn'):
return
if enabled:
self._brief_btn.setText("☀ MORNING BRIEF: ON")
self._brief_btn.setStyleSheet(f"""
QPushButton {{
background: #001a08; color: {C.GREEN};
border: 1px solid {C.GREEN_D}; border-radius: 3px;
text-align: left; padding: 0 8px;
}}
QPushButton:hover {{ background: #002010; }}
""")
else:
self._brief_btn.setText("☀ MORNING BRIEF: OFF")
self._brief_btn.setStyleSheet(f"""
QPushButton {{
background: transparent; color: {C.TEXT_DIM};
border: 1px solid {C.BORDER}; border-radius: 3px;
text-align: left; padding: 0 8px;
}}
QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }}
""")
# ── Customization ────────────────────────────────────────────────────────────
def _open_customize(self):
cfg = _read_full_config()
if self._customize_overlay:
self._customize_overlay.hide()
cw = self.centralWidget()
ov = CustomizeOverlay(
cfg.get("assistant_name", "JARVIS") or "JARVIS",
cfg.get("user_name", ""),
cfg.get("ui_color", "") or DEFAULT_UI_COLOR,
cfg.get("voice_name", ""),
parent=cw,
)
ow, oh = CustomizeOverlay._OW, CustomizeOverlay._OH
oh = min(oh, cw.height() - 16)
ov.setGeometry(
(cw.width() - ow) // 2,
(cw.height() - oh) // 2,
ow, oh,
)
ov.on_preview = self._preview_ui_color
ov.saved.connect(self._apply_name_update)
ov.show()
self._customize_overlay = ov
def _preview_ui_color(self, hex_color: str):
"""Live preview — paints the whole interface the new colour (does NOT write to config)."""
old = current_palette()
if apply_ui_accent(hex_color):
retheme_all_widgets(old, current_palette())
def _apply_name_update(self, name: str, user_name: str, ui_color: str = "",
voice: str = ""):
"""Update all name/theme-dependent UI elements and persist to config."""
self._assistant_name = name.strip() or "JARVIS"
display = self._assistant_name.upper()
self.setWindowTitle(display if display.lower() == APP_VERSION.lower() else f"{display} — {APP_VERSION}")
self._title_lbl.setText(display)
if display in ("JARVIS", "J.A.R.V.I.S"):
self._sub_lbl.setText("Just A Rather Very Intelligent System")
else:
self._sub_lbl.setText("Assistente personale")
self._log._ai_name_lc = self._assistant_name.lower()
self.hud._assistant_name = display
color_changed = False
if ui_color:
old = current_palette()
if apply_ui_accent(ui_color):
# Live-paint the whole interface (panels, buttons, borders, HUD)
retheme_all_widgets(old, current_palette())
color_changed = old["PRI"] != C.PRI
# Voice change → persist and, if it actually changed, rebuild the Live
# session so the new voice takes effect (it's fixed at connect time).
voice_changed = False
if voice:
from memory.config_manager import get_voice, save_voice
if voice != get_voice():
save_voice(voice)
voice_changed = True
try:
data = _read_full_config()
data["assistant_name"] = self._assistant_name
data["user_name"] = user_name.strip()
if ui_color:
data["ui_color"] = ui_color.strip().lower()
API_FILE.write_text(json.dumps(data, indent=4), encoding="utf-8")
self._log.append_log(f"SYS: Identity updated — {display}")
if color_changed:
self._log.append_log(f"SYS: UI colour applied — {ui_color}")
if voice_changed:
self._log.append_log(f"SYS: Voice set — {voice}")
except Exception as e:
self._log.append_log(f"ERR: Config save failed — {e}")
if voice_changed and self.on_voice_change:
self.on_voice_change()
def _centre_overlay(self, ov) -> None:
"""Place a floating overlay in the middle of the HUD and show it."""
cw = self.centralWidget()
ov.adjustSize()
ov.setGeometry(
max(0, (cw.width() - ov.width()) // 2),
max(0, (cw.height() - ov.height()) // 2),
ov.width(), ov.height(),
)
ov.show()
ov.raise_()
# ── Audio devices ────────────────────────────────────────────────────────
def _open_audio_devices(self):
ov = AudioDeviceOverlay(parent=self.centralWidget())
ov.picked.connect(self._on_audio_devices_applied)
self._centre_overlay(ov)
self._audio_overlay = ov # keep a reference so it isn't GC'd
def _on_audio_devices_applied(self):
self._log.append_log("SYS: Audio devices updated.")
if self.on_audio_device_change:
self.on_audio_device_change()
# ── Memory panel ─────────────────────────────────────────────────────────
def _open_memory_panel(self):
ov = MemoryOverlay(parent=self.centralWidget())
self._centre_overlay(ov)
self._memory_overlay = ov
# ── Irreversible-action confirmation ─────────────────────────────────────
def _show_confirm_banner(self, title: str, detail: str):
self._hide_confirm_banner()
ov = ConfirmBanner(title, detail, parent=self.centralWidget())
ov.answered.connect(self._on_confirm_answered)
self._centre_overlay(ov)
self._confirm_overlay = ov
def _hide_confirm_banner(self):
ov = getattr(self, "_confirm_overlay", None)
if ov is not None:
ov.hide()
ov.deleteLater()
self._confirm_overlay = None
def _on_confirm_answered(self, accepted: bool):
# Tear the banner down first: core.confirm.resolve() may be about to
# shut the machine down, and a live widget mid-callback is not where you
# want to be when that happens.
self._hide_confirm_banner()
try:
from core.confirm import resolve
resolve(bool(accepted))
except Exception as e:
self._log.append_log(f"ERR: Confirmation failed — {e}")
def _open_plugin_manager(self):
plugins = self.get_plugins() if self.get_plugins else []
cw = self.centralWidget()
ov = PluginManagerOverlay(plugins, parent=cw)
ov.adjustSize()
ov.setGeometry(
(cw.width() - ov.width()) // 2,
(cw.height() - ov.height()) // 2,
ov.width(), ov.height(),
)
ov.show()
ov.raise_()
self._plugin_manager_overlay = ov # keep a reference so it isn't GC'd
def _open_plugin_settings(self):
sections = self.get_plugin_settings() if self.get_plugin_settings else []
cw = self.centralWidget()
ov = PluginSettingsOverlay(sections, parent=cw)
ow = PluginSettingsOverlay._OW
oh = min(560, cw.height() - 16)
ov.setGeometry(
(cw.width() - ow) // 2,
(cw.height() - oh) // 2,
ow, oh,
)
ov.show()
ov.raise_()
self._plugin_settings_overlay = ov # keep a reference so it isn't GC'd
# ── Clipboard intelligence ───────────────────────────────────────────────────
def _on_clipboard_changed(self):
try:
text = QApplication.clipboard().text().strip()
if len(text) >= 10:
self._clipboard_sig.emit(text)
except Exception:
pass
def _show_clipboard_panel(self, text: str):
self._clipboard_panel.show_clipboard(text)
self._position_clipboard_panel()
def _position_clipboard_panel(self):
cw = self.centralWidget()
pw = ClipboardPanel._W
ph = self._clipboard_panel.sizeHint().height() or ClipboardPanel._H
x = (cw.width() - pw) // 2
y = cw.height() - ph - 6
self._clipboard_panel.setGeometry(x, y, pw, ph)
self._clipboard_panel.raise_()
def _on_clipboard_action(self, cmd: str):
if self.on_text_command:
threading.Thread(target=self.on_text_command, args=(cmd,), daemon=True).start()
# ────────────────────────────────────────────────────────────────────────────
def _do_interrupt(self):
if self.on_interrupt:
self.on_interrupt()
def _toggle_mute(self):
self._muted = not self._muted
self.hud.muted = self._muted
self._style_mute_btn()
if self._muted:
self._apply_state("MUTED")
self._log.append_log("SYS: Microphone muted.")
else:
self._apply_state("LISTENING")
self._log.append_log("SYS: Microphone active.")
def _style_mute_btn(self):
if self._muted:
self._mute_btn.setText("🔇 MICROPHONE MUTED")
self._mute_btn.setStyleSheet(f"""
QPushButton {{
background: #140006; color: {C.MUTED_C};
border: 1px solid {C.MUTED_C}; border-radius: 3px;
}}
""")
else:
self._mute_btn.setText("🎙 MICROPHONE ACTIVE")
self._mute_btn.setStyleSheet(f"""
QPushButton {{
background: #00140a; color: {C.GREEN};
border: 1px solid {C.GREEN}; border-radius: 3px;
}}
QPushButton:hover {{ background: #001f10; }}
""")
def _send(self):
txt = self._input.text().strip()
if not txt: return
self._input.clear()
self._log.append_log(f"You: {txt}")
if self.on_text_command:
threading.Thread(target=self.on_text_command, args=(txt,), daemon=True).start()
def _apply_state(self, state: str):
self.hud.state = state
self.hud.speaking = (state == "SPEAKING")
if self.avatar3d is not None:
self.avatar3d.set_state(state)
def _check_config(self) -> bool:
# AvatarPy: nessuna chiave Gemini; basta il file d'identità creato da main.py.
return API_FILE.exists()
def _show_setup(self):
ov = SetupOverlay(self.centralWidget())
cw = self.centralWidget()
ow, oh = 460, 390
ov.setGeometry(
(cw.width() - ow) // 2,
(cw.height() - oh) // 2,
ow, oh,
)
ov.done.connect(self._on_setup_done)
ov.show()
self._overlay = ov
def _on_setup_done(self, key: str, os_name: str):
os.makedirs(CONFIG_DIR, exist_ok=True)
API_FILE.write_text(
json.dumps({"gemini_api_key": key, "os_system": os_name}, indent=4),
encoding="utf-8",
)
self._ready = True
if self._overlay:
self._overlay.hide()
self._overlay = None
self._apply_state("LISTENING")
self._assistant_name = _read_full_config().get("assistant_name", "JARVIS") or "JARVIS"
self._log.append_log(f"SYS: Initialised. OS={os_name.upper()}. {self._assistant_name} online.")
class _RootShim:
def __init__(self, app: QApplication):
self._app = app
def mainloop(self):
self._app.exec()
def protocol(self, *_):
pass
class JarvisUI:
def __init__(self, face_path: str, size=None):
self._app = QApplication.instance() or QApplication(sys.argv)
self._app.setStyle("Fusion")
self._win = MainWindow(face_path)
self.root = _RootShim(self._app)
self._win.show()
@property
def muted(self) -> bool:
return self._win._muted
@muted.setter
def muted(self, v: bool):
if v != self._win._muted:
self._win._toggle_mute()
@property
def current_file(self) -> str | None:
return self._win._drop_zone.current_file()
@property
def on_text_command(self):
return self._win.on_text_command
@on_text_command.setter
def on_text_command(self, cb):
self._win.on_text_command = cb
@property
def on_remote_clicked(self):
return self._win.on_remote_clicked
@on_remote_clicked.setter
def on_remote_clicked(self, cb):
self._win.on_remote_clicked = cb
@property
def on_interrupt(self):
return self._win.on_interrupt
@on_interrupt.setter
def on_interrupt(self, cb):
self._win.on_interrupt = cb
@property
def on_voice_change(self):
return self._win.on_voice_change
@on_voice_change.setter
def on_voice_change(self, cb):
self._win.on_voice_change = cb
@property
def on_audio_device_change(self):
return self._win.on_audio_device_change
@on_audio_device_change.setter
def on_audio_device_change(self, cb):
self._win.on_audio_device_change = cb
def show_confirm(self, title: str, detail: str) -> None:
"""Thread-safe: raise the irreversible-action gate. Called from action
handlers running in executor threads, so it goes through a signal."""
self._win._confirm_sig.emit(str(title)[:120], str(detail)[:300])
def hide_confirm(self) -> None:
"""Thread-safe: take the gate down."""
self._win._confirm_hide_sig.emit()
@property
def get_plugins(self):
return self._win.get_plugins
@get_plugins.setter
def get_plugins(self, cb):
self._win.get_plugins = cb
@property
def get_plugin_settings(self):
return self._win.get_plugin_settings
@get_plugin_settings.setter
def get_plugin_settings(self, cb):
self._win.get_plugin_settings = cb
@property
def on_wake_toggle(self):
return self._win.on_wake_toggle
@on_wake_toggle.setter
def on_wake_toggle(self, cb):
self._win.on_wake_toggle = cb
@property
def on_wake_manual(self):
return self._win.on_wake_manual
@on_wake_manual.setter
def on_wake_manual(self, cb):
self._win.on_wake_manual = cb
@property
def wake_get_state(self):
return self._win.wake_get_state
@wake_get_state.setter
def wake_get_state(self, cb):
self._win.wake_get_state = cb
def set_audio_level(self, level: float) -> None:
"""Thread-safe: feed a 0.0–1.0 live audio level to the HUD waveform.
Called from the audio threads; a plain float store is atomic under the
GIL, so no signal/lock is needed for this cosmetic value."""
try:
self._win.hud.set_audio_level(level)
if self._win.avatar3d is not None:
self._win.avatar3d.set_audio_level(level)
except Exception:
pass
def glance(self, dx: float, dy: float, hold: float = 1.1) -> None:
"""Ask the avatar to look somewhere for a moment (see HoloAvatar.glance)."""
try:
if self._avatar is not None:
self._avatar.glance(dx, dy, hold)
except Exception:
pass
@property
def ptt_hold(self):
return self._win.ptt_hold
@ptt_hold.setter
def ptt_hold(self, cb):
self._win.ptt_hold = cb
@property
def on_push_to_talk(self):
return self._win.on_push_to_talk
@on_push_to_talk.setter
def on_push_to_talk(self, cb):
self._win.on_push_to_talk = cb
def push_visemes(self, frames, hop: float, at: float) -> None:
"""Thread-safe: post a schedule of (level, openness, width) mouth frames
for JARVIS's own speech. `at` is the wall-clock time the batch begins to
sound, not the time of the call. See HudCanvas.push_visemes()."""
try:
self._win.hud.push_visemes(frames, hop, at)
if self._win.avatar3d is not None:
self._win.avatar3d.push_visemes(frames, hop, at)
except Exception:
pass
def notify_phone_connected(self) -> None:
self._win.notify_phone_connected()
def set_state(self, state: str):
self._win._state_sig.emit(state)
def write_log(self, text: str):
self._win._log_sig.emit(text)
def wait_for_api_key(self):
while not self._win._ready:
time.sleep(0.1)
def show_content(self, title: str, text: str):
"""Thread-safe: display content in the panel below the HUD."""
self._win._content_sig.emit(title[:48], text[:4000])
def show_quiz(self, topic: str, questions, grade=None) -> None:
"""Thread-safe: put an interactive quiz on the board.
`grade(question, given)` decides each answer — the plugin supplies it so
the marking rules live with the questions rather than being duplicated
here. Returning None from it means "JARVIS should judge this one", which
is how open answers and near-miss gap-fills are handled.
Returns immediately: the user answers at their own pace and the finished
result is delivered back through on_text_command.
"""
self._win._quiz_sig.emit(str(topic or ""), list(questions or []), grade)
def hide_quiz(self) -> None:
"""Thread-safe: clear any quiz currently on the board."""
self._win._quiz_hide_sig.emit()
def show_review(self, title: str, summary: str, findings, unclear=None) -> None:
"""Thread-safe: lay a document review into the panel below the HUD.
`findings` is a list of {heading, detail, severity, quote, suggestion};
severity is one of 'serious' / 'caution' / 'note' and decides colour and
order here, so the caller supplies no styling of its own.
"""
self._win._review_sig.emit(str(title or ""), str(summary or ""),
list(findings or []), list(unclear or []))
def prompt_reconfig(self):
"""Thread-safe: show the API key setup overlay (e.g. after an auth error)."""
self._win._ready = False
self._win._reconfig_sig.emit()
def show_camera_frame(self, img_bytes: bytes):
"""Thread-safe: show a webcam frame in the small overlay (screen captures)."""
self._win._camera_sig.emit(img_bytes)
def start_camera_stream(self) -> None:
"""Thread-safe: start live camera feed in the full HUD area."""
self._win.start_camera_stream()
def stop_camera_stream(self) -> None:
"""Thread-safe: stop the live camera feed."""
self._win.stop_camera_stream()
@property
def assistant_name(self) -> str:
return self._win._assistant_name
def start_speaking(self):
self.set_state("SPEAKING")
def stop_speaking(self):
if not self.muted:
self.set_state("LISTENING")