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.split(" ")[0] 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.split(" ")[0] in ("THINKING", "PROCESSING"):
return qcol(C.PRI), qcol(C.ACC2)
if self.state.split(" ")[0] == "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.split(" ")[0] 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.split(" ")[0] in ("THINKING", "PROCESSING"):
_acc = qcol(C.ACC2)
elif self.state.split(" ")[0] == "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
# Lo stato può avere un dettaglio dopo " · " (es. "THINKING · genero la risposta · 120 token · 59/s").
base, _, detail = self.state.partition(" · ")
detail = f" · {detail}" if detail else ""
if self.speaking:
txt, col = "● SPEAKING", qcol(C.ACC)
elif base == "THINKING":
sym = "◈" if self._blink else "◇"
txt, col = f"{sym} THINKING{detail}", qcol(C.ACC2)
elif base == "PROCESSING":
sym = "▷" if self._blink else "▶"
txt, col = f"{sym} PROCESSING{detail}", qcol(C.ACC2)
elif base == "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)
if self.muted:
# microfono spento: a riposo si vede MUTED, durante il lavoro resta il dettaglio con il segno ⊘
if base in ("LISTENING", "SLEEPING") and not self.speaking:
txt, col = "⊘ MUTED", qcol(C.MUTED_C)
else:
txt = "⊘ " + txt
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)
# AvatarPy: la voce si sceglie in «Motore e Voce»; qui resta solo il valore corrente.
self._sel_voice = voice or ""
self._voice_btns: dict[str, QPushButton] = {}
# ── 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.
Elenco scorrevole (non supera l'altezza della finestra), nome leggibile e descrizione sotto."""
_OW = 560
def __init__(self, plugins: list[dict], parent=None, max_height: int = 0):
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")
hdr.setFont(QFont("Menlo", 15, QFont.Weight.Bold))
hdr.setStyleSheet(f"color: {C.PRI}; background: transparent;")
lay.addWidget(hdr)
sub = QLabel(f"{sum(1 for p in plugins if p.get('enabled') and p.get('valid'))} attivi su {len(plugins)} · clic su ON/OFF per cambiare")
sub.setFont(QFont("Menlo", 10)); sub.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;")
lay.addWidget(sub)
sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet(f"color: {C.BORDER}; margin: 2px 0;")
lay.addWidget(sep)
body = QWidget(); body.setStyleSheet("background: transparent;")
blay = QVBoxLayout(body); blay.setContentsMargins(0, 0, 8, 0); blay.setSpacing(4)
if not plugins:
empty = QLabel("Nessun plugin trovato nella cartella plugins/.")
empty.setFont(QFont("Menlo", 11))
empty.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;")
blay.addWidget(empty)
locali = sorted((p for p in plugins if not str(p["name"]).startswith("mcp:")), key=lambda p: p["name"])
esterni = sorted((p for p in plugins if str(p["name"]).startswith("mcp:")), key=lambda p: p["name"])
for p in locali:
blay.addLayout(self._build_row(p))
if esterni:
t = QLabel("SERVER MCP"); t.setFont(QFont("Menlo", 10, QFont.Weight.Bold))
t.setStyleSheet(f"color: {C.PRI_DIM}; background: transparent; margin-top: 8px;")
blay.addWidget(t)
for p in esterni:
blay.addLayout(self._build_row(p))
blay.addStretch(1)
scroll = QScrollArea(); scroll.setWidget(body); scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame); scroll.setStyleSheet("QScrollArea { background: transparent; } QScrollArea > QWidget > QWidget { background: transparent; }")
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
if max_height:
avail = max(160, max_height - 150)
scroll.setMaximumHeight(avail)
scroll.setMinimumHeight(min(avail, body.sizeHint().height() + 8)) # usa l'altezza disponibile prima di scorrere
lay.addWidget(scroll, 1)
lay.addSpacing(4)
close_btn = QPushButton("CHIUDI")
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()
if max_height:
self.setMaximumHeight(max_height)
@staticmethod
def _titolo(name: str) -> str:
if name.startswith("mcp:"):
return name[4:]
speciali = {"home_assistant": "Casa (Home Assistant)", "server_assistenza": "Assistenza server", "whatsapp_archivio": "WhatsApp archivio",
"whatsapp_live": "WhatsApp in tempo reale", "comandi_rapidi": "Comandi rapidi", "sms": "SMS", "mac": "Mac", "app": "App"}
return speciali.get(name, name.replace("_", " ").capitalize())
def _build_row(self, p: dict) -> QHBoxLayout:
row = QHBoxLayout(); row.setSpacing(10)
col = QVBoxLayout(); col.setSpacing(0)
lbl = QLabel(self._titolo(p["name"]) if p["valid"] else f"{self._titolo(p['name'])} (⚠ non caricato)")
lbl.setFont(QFont("Menlo", 12, QFont.Weight.Bold))
lbl.setStyleSheet(f"color: {C.TEXT if p['valid'] else C.TEXT_DIM}; background: transparent;")
col.addWidget(lbl)
desc = (p["description"] if p["valid"] else p["error"]) or ""
desc = " ".join(desc.split())
d = QLabel(desc); d.setFont(QFont("Menlo", 10)); d.setWordWrap(True)
d.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;")
d.setMaximumWidth(self._OW - 150)
col.addWidget(d)
row.addLayout(col, stretch=1)
btn = QPushButton()
btn.setFixedSize(72, 24)
btn.setFont(QFont("Menlo", 10, QFont.Weight.Bold))
if not p["valid"]:
btn.setText("ERRORE")
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, alignment=Qt.AlignmentFlag.AlignTop)
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"{r['key'].replace('_', ' ')} "
f"— {r['value']}")
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(
'\n'
'\n'
'
"))
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'