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' '\n' ' CFBundleExecutableLuZa\n' ' CFBundleIdentifier' 'it.luza.assistente\n' ' CFBundleNameLuZa\n' ' CFBundlePackageTypeAPPL\n' ' CFBundleVersion1.0\n' '\n' ) # Optional: copy icon as .icns (skip silently if Pillow is missing) try: import PIL.Image icns = res_dir / "AppIcon.icns" PIL.Image.open(ico_path).save(icns, format="ICNS") # Inject icon reference into plist plist = app / "Contents" / "Info.plist" txt = plist.read_text() plist.write_text( txt.replace( '', ' CFBundleIconFile' 'AppIcon\n\n', ) ) except Exception: pass # icon is optional # ── Linux — .desktop file (Terminal=false, no console) ──────────── else: # Export .ico → .png for better desktop integration png_path = ico_path.with_suffix(".png") if not png_path.exists() and ico_path.exists(): try: import PIL.Image PIL.Image.open(ico_path).resize( (256, 256), PIL.Image.LANCZOS ).save(png_path, format="PNG") except Exception: png_path = ico_path # fallback to .ico icon_line = f"Icon={png_path}\n" if png_path.exists() else "" desk = desktop / "LuZa.desktop" desk.write_text( "[Desktop Entry]\n" "Name=LuZa\n" f"Exec={python} {script}\n" f"Path={script.parent}\n" "Type=Application\n" "Terminal=false\n" "Categories=Utility;\n" + icon_line ) desk.chmod(desk.stat().st_mode | 0o755) self._log.append_log("SYS: Desktop shortcut created.") except Exception as e: self._log.append_log(f"ERR: Shortcut failed — {e}") def _toggle_fullscreen(self): if self.isFullScreen(): self.showNormal() else: self.showFullScreen() def resizeEvent(self, event): super().resizeEvent(event) cw = self.centralWidget() if self._overlay and self._overlay.isVisible(): ow, oh = 460, 390 self._overlay.setGeometry( (cw.width() - ow) // 2, (cw.height() - oh) // 2, ow, oh, ) if self._remote_overlay and self._remote_overlay.isVisible(): ow, oh = RemoteKeyOverlay._OW, RemoteKeyOverlay._OH self._remote_overlay.setGeometry( (cw.width() - ow) // 2, (cw.height() - oh) // 2, ow, oh, ) if self._customize_overlay and self._customize_overlay.isVisible(): ow, oh = CustomizeOverlay._OW, CustomizeOverlay._OH self._customize_overlay.setGeometry( (cw.width() - ow) // 2, (cw.height() - oh) // 2, ow, oh, ) # Camera preview — bottom-right corner of the center/HUD area pw = _CameraPreview._W ph = self._cam_preview.height() or _CameraPreview._H self._cam_preview.setGeometry( cw.width() - _RIGHT_W - pw - 12, cw.height() - ph - 28, pw, ph, ) # Clipboard panel — bottom-center if hasattr(self, '_clipboard_panel') and self._clipboard_panel.isVisible(): self._position_clipboard_panel() # Quick drawer — reposition if open if hasattr(self, '_quick_drawer') and self._quick_drawer.isVisible(): self._position_quick_drawer() def _update_metrics(self): snap = _metrics.snapshot() # CPU cpu = snap["cpu"] self._bar_cpu.set_value(cpu, f"{cpu:.0f}%") # MEM mem = snap["mem"] self._bar_mem.set_value(mem, f"{mem:.0f}%") # NET net = snap["net"] if net < 1.0: net_str = f"{net*1024:.0f}KB/s" else: net_str = f"{net:.1f}MB/s" net_pct = min(100, net * 10) # 10 MB/s = %100 self._bar_net.set_value(net_pct, net_str) # GPU gpu = snap["gpu"] if gpu >= 0: self._bar_gpu.set_value(gpu, f"{gpu:.0f}%") else: self._bar_gpu.set_value(0, "N/A") # TMP tmp = snap["tmp"] if tmp >= 0: tmp_pct = min(100, (tmp / 100) * 100) self._bar_tmp.set_value(tmp_pct, f"{tmp:.0f}°C") else: self._bar_tmp.set_value(0, "N/A") try: boot_t = psutil.boot_time() elapsed = time.time() - boot_t h = int(elapsed // 3600) m = int((elapsed % 3600) // 60) self._uptime_lbl.setText(f"UP {h:02d}:{m:02d}") except Exception: self._uptime_lbl.setText("UP --:--") try: proc_count = len(psutil.pids()) self._proc_lbl.setText(f"PROC {proc_count}") except Exception: self._proc_lbl.setText("PROC --") def _build_header(self) -> QWidget: w = QWidget() w.setFixedHeight(54) w.setStyleSheet(f"background: {C.DARK}; border-bottom: 1px solid {C.BORDER_B};") lay = QHBoxLayout(w) lay.setContentsMargins(16, 0, 16, 0) def _badge(txt, color=C.TEXT_MED): l = QLabel(txt) l.setFont(QFont("Menlo", 11)) l.setStyleSheet(f"color: {color}; background: transparent;") return l lay.addWidget(_badge(APP_VERSION, C.PRI_DIM)) lay.addSpacing(8) self._drawer_btn = QPushButton("⚙") self._drawer_btn.setFixedSize(26, 26) self._drawer_btn.setFont(QFont("Menlo", 14)) self._drawer_btn.setCursor(Qt.CursorShape.PointingHandCursor) self._drawer_btn.setToolTip("Settings & Controls") self._drawer_btn.setStyleSheet(f""" QPushButton {{ background: transparent; color: {C.TEXT_DIM}; border: 1px solid {C.BORDER}; border-radius: 4px; }} QPushButton:hover {{ color: {C.PRI}; border-color: {C.PRI_DIM}; }} QPushButton:checked {{ color: {C.PRI}; border-color: {C.PRI}; background: {C.PRI_GHO}; }} """) self._drawer_btn.setCheckable(True) self._drawer_btn.clicked.connect(self._toggle_drawer) lay.addWidget(self._drawer_btn) lay.addStretch() mid = QVBoxLayout(); mid.setSpacing(1) _disp = self._assistant_name.upper() self._title_lbl = QLabel(_disp) self._title_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) self._title_lbl.setFont(QFont("Menlo", 17, QFont.Weight.Bold)) self._title_lbl.setStyleSheet(f"color: {C.PRI}; background: transparent;") mid.addWidget(self._title_lbl) _sub_text = ("A Friendly Assistant" if _disp in ("JARVIS", "J.A.R.V.I.S") else "Assistente personale") self._sub_lbl = QLabel(_sub_text) self._sub_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) self._sub_lbl.setFont(QFont("Menlo", 10)) self._sub_lbl.setStyleSheet(f"color: {C.PRI_DIM}; background: transparent;") mid.addWidget(self._sub_lbl) lay.addLayout(mid) lay.addStretch() right_col = QVBoxLayout(); right_col.setSpacing(2) self._clock_lbl = QLabel("00:00:00") self._clock_lbl.setFont(QFont("Menlo", 17, QFont.Weight.Bold)) self._clock_lbl.setStyleSheet(f"color: {C.PRI}; background: transparent;") self._clock_lbl.setAlignment(Qt.AlignmentFlag.AlignRight) right_col.addWidget(self._clock_lbl) self._date_lbl = QLabel("") self._date_lbl.setFont(QFont("Menlo", 10)) self._date_lbl.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;") self._date_lbl.setAlignment(Qt.AlignmentFlag.AlignRight) right_col.addWidget(self._date_lbl) lay.addLayout(right_col) return w def _tick_clock(self): self._clock_lbl.setText(time.strftime("%H:%M:%S")) self._date_lbl.setText(time.strftime("%a %d %b %Y")) def _build_left_panel(self) -> QWidget: w = QWidget() w.setFixedWidth(_LEFT_W) w.setStyleSheet(f"background: {C.DARK}; border-right: 1px solid {C.BORDER};") lay = QVBoxLayout(w) lay.setContentsMargins(8, 10, 8, 10) lay.setSpacing(6) hdr = QLabel("◈ SYS MONITOR") hdr.setFont(QFont("Menlo", 10, QFont.Weight.Bold)) hdr.setStyleSheet(f"color: {C.PRI}; background: transparent; " f"border-bottom: 1px solid {C.BORDER}; padding-bottom: 4px;") lay.addWidget(hdr) lay.addSpacing(2) self._bar_cpu = MetricBar("CPU", C.PRI) self._bar_mem = MetricBar("MEM", C.ACC2) self._bar_net = MetricBar("NET", C.GREEN) self._bar_gpu = MetricBar("GPU", C.ACC) self._bar_tmp = MetricBar("TMP", "#ff6688") for bar in [self._bar_cpu, self._bar_mem, self._bar_net, self._bar_gpu, self._bar_tmp]: lay.addWidget(bar) lay.addSpacing(4) info_panel = QWidget() info_panel.setStyleSheet( f"background: {C.PANEL2}; border: 1px solid {C.BORDER}; border-radius: 4px;" ) ip_lay = QVBoxLayout(info_panel) ip_lay.setContentsMargins(6, 5, 6, 5) ip_lay.setSpacing(3) self._uptime_lbl = QLabel("UP --:--") self._uptime_lbl.setFont(QFont("Menlo", 11, QFont.Weight.Bold)) self._uptime_lbl.setStyleSheet(f"color: {C.GREEN}; background: transparent; border: none;") ip_lay.addWidget(self._uptime_lbl) self._proc_lbl = QLabel("PROC --") self._proc_lbl.setFont(QFont("Menlo", 11)) self._proc_lbl.setStyleSheet(f"color: {C.TEXT_MED}; background: transparent; border: none;") ip_lay.addWidget(self._proc_lbl) os_name = {"Windows": "WIN", "Darwin": "macOS", "Linux": "LINUX"}.get(_OS, _OS.upper()) os_lbl = QLabel(f"OS {os_name}") os_lbl.setFont(QFont("Menlo", 11)) os_lbl.setStyleSheet(f"color: {C.ACC2}; background: transparent; border: none;") ip_lay.addWidget(os_lbl) lay.addWidget(info_panel) lay.addSpacing(4) lay.addStretch() for txt, col in [ ("AI CORE\nACTIVE", C.GREEN), ("SEC\nCLEARED", C.PRI), ("PROTOCOL\n" + APP_PROTOCOL, C.TEXT_DIM), ]: lbl = QLabel(txt) lbl.setFont(QFont("Menlo", 10, QFont.Weight.Bold)) lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) lbl.setStyleSheet( f"color: {col}; background: {C.PANEL2};" f"border: 1px solid {C.BORDER_A}; border-radius: 3px; padding: 4px;" ) lay.addWidget(lbl) return w def _build_right_panel(self) -> QWidget: w = QWidget() w.setFixedWidth(_RIGHT_W) w.setStyleSheet(f"background: {C.DARK}; border-left: 1px solid {C.BORDER};") lay = QVBoxLayout(w) lay.setContentsMargins(8, 8, 8, 8) lay.setSpacing(6) def _sec(txt): l = QLabel(f"▸ {txt}") l.setFont(QFont("Menlo", 10, QFont.Weight.Bold)) l.setStyleSheet(f"color: {C.TEXT_MED}; background: transparent;") return l lay.addWidget(_sec("ACTIVITY LOG")) self._log = LogWidget() lay.addWidget(self._log, stretch=1) sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine) sep.setStyleSheet(f"color: {C.BORDER}; margin: 2px 0;") lay.addWidget(sep) lay.addWidget(_sec("FILE UPLOAD")) self._drop_zone = FileDropZone() self._drop_zone.file_selected.connect(self._on_file_selected) lay.addWidget(self._drop_zone) self._file_hint = QLabel("No file loaded — drop or click above to upload") self._file_hint.setFont(QFont("Menlo", 10)) self._file_hint.setStyleSheet(f"color: {C.TEXT_MED}; background: transparent;") self._file_hint.setWordWrap(True) lay.addWidget(self._file_hint) sep2 = QFrame(); sep2.setFrameShape(QFrame.Shape.HLine) sep2.setStyleSheet(f"color: {C.BORDER}; margin: 2px 0;") lay.addWidget(sep2) lay.addWidget(_sec("COMMAND INPUT")) lay.addLayout(self._build_input_row()) self._interrupt_btn = QPushButton("✋ INTERRUPT [ESC]") self._interrupt_btn.setFixedHeight(34) self._interrupt_btn.setFont(QFont("Menlo", 11, QFont.Weight.Bold)) self._interrupt_btn.setCursor(Qt.CursorShape.PointingHandCursor) self._interrupt_btn.setStyleSheet(f""" QPushButton {{ background: #140008; color: {C.MUTED_C}; border: 1px solid {C.MUTED_C}; border-radius: 3px; }} QPushButton:hover {{ background: #200010; border: 1px solid #ff6688; }} QPushButton:pressed {{ background: #300018; }} """) self._interrupt_btn.clicked.connect(self._do_interrupt) lay.addWidget(self._interrupt_btn) self._mute_btn = QPushButton("🎙 MICROPHONE ACTIVE") self._mute_btn.setFixedHeight(30) self._mute_btn.setFont(QFont("Menlo", 11, QFont.Weight.Bold)) self._mute_btn.setCursor(Qt.CursorShape.PointingHandCursor) self._mute_btn.clicked.connect(self._toggle_mute) self._style_mute_btn() lay.addWidget(self._mute_btn) return w def _build_quick_drawer(self) -> QWidget: """Floating overlay panel shown when the ⚙ header button is toggled.""" _BTN_STYLE_PRI = f""" QPushButton {{ background: #00091a; color: {C.PRI}; border: 1px solid {C.PRI_DIM}; border-radius: 3px; text-align: left; padding: 0 8px; }} QPushButton:hover {{ background: {C.PRI_GHO}; border-color: {C.PRI}; }} """ _BTN_STYLE_DIM = f""" QPushButton {{ background: transparent; color: {C.TEXT_MED}; border: 1px solid {C.BORDER}; border-radius: 3px; text-align: left; padding: 0 8px; }} QPushButton:hover {{ color: {C.PRI}; border-color: {C.BORDER_B}; }} """ w = QWidget(self.centralWidget()) w.setObjectName("QuickDrawer") w.setStyleSheet(f""" QWidget#QuickDrawer {{ background: {C.DARK}; border: 1px solid {C.BORDER_B}; border-top: none; border-radius: 0 0 6px 6px; }} """) w.hide() lay = QVBoxLayout(w) lay.setContentsMargins(10, 8, 10, 10) lay.setSpacing(5) hdr = QLabel("◈ CONTROLS") hdr.setFont(QFont("Menlo", 10, QFont.Weight.Bold)) hdr.setStyleSheet(f"color: {C.PRI_DIM}; background: transparent; " f"border-bottom: 1px solid {C.BORDER}; padding-bottom: 4px;") lay.addWidget(hdr) remote_btn = QPushButton("◉ REMOTE CONTROL") remote_btn.setFixedHeight(30) remote_btn.setFont(QFont("Menlo", 11, QFont.Weight.Bold)) remote_btn.setCursor(Qt.CursorShape.PointingHandCursor) remote_btn.setStyleSheet(_BTN_STYLE_PRI) remote_btn.clicked.connect(self._open_remote) lay.addWidget(remote_btn) fs_btn = QPushButton("⛶ FULLSCREEN [F11]") fs_btn.setFixedHeight(34) fs_btn.setFont(QFont("Menlo", 10)) fs_btn.setCursor(Qt.CursorShape.PointingHandCursor) fs_btn.setStyleSheet(_BTN_STYLE_DIM) fs_btn.clicked.connect(self._toggle_fullscreen) lay.addWidget(fs_btn) sc_btn = QPushButton("⊞ CREATE DESKTOP SHORTCUT") sc_btn.setFixedHeight(34) sc_btn.setFont(QFont("Menlo", 10)) sc_btn.setCursor(Qt.CursorShape.PointingHandCursor) sc_btn.setStyleSheet(_BTN_STYLE_DIM) sc_btn.clicked.connect(self._create_desktop_shortcut) lay.addWidget(sc_btn) self._autostart_btn = QPushButton("◉ AUTO-START: OFF") self._autostart_btn.setFixedHeight(34) self._autostart_btn.setFont(QFont("Menlo", 10)) self._autostart_btn.setCursor(Qt.CursorShape.PointingHandCursor) self._autostart_btn.clicked.connect(self._toggle_autostart) lay.addWidget(self._autostart_btn) cust_btn = QPushButton("⚙ CUSTOMISE ASSISTANT") cust_btn.setFixedHeight(34) cust_btn.setFont(QFont("Menlo", 10)) cust_btn.setCursor(Qt.CursorShape.PointingHandCursor) cust_btn.setStyleSheet(_BTN_STYLE_DIM) cust_btn.clicked.connect(self._open_customize) lay.addWidget(cust_btn) # AvatarPy: motore di conversazione, chiavi e voce engine_btn = QPushButton("🧠 MOTORE E VOCE") engine_btn.setFixedHeight(34) engine_btn.setFont(QFont("Menlo", 10)) engine_btn.setCursor(Qt.CursorShape.PointingHandCursor) engine_btn.setStyleSheet(_BTN_STYLE_DIM) engine_btn.clicked.connect(lambda: self.on_engine_settings() if self.on_engine_settings else None) lay.addWidget(engine_btn) newconv_btn = QPushButton("🗑 NUOVA CONVERSAZIONE") newconv_btn.setFixedHeight(34) newconv_btn.setFont(QFont("Menlo", 10)) newconv_btn.setCursor(Qt.CursorShape.PointingHandCursor) newconv_btn.setStyleSheet(_BTN_STYLE_DIM) newconv_btn.clicked.connect(lambda: self.on_new_conversation() if self.on_new_conversation else None) lay.addWidget(newconv_btn) self._brief_btn = QPushButton() self._brief_btn.setFixedHeight(34) self._brief_btn.setFont(QFont("Menlo", 10)) self._brief_btn.setCursor(Qt.CursorShape.PointingHandCursor) self._brief_btn.clicked.connect(self._toggle_brief) lay.addWidget(self._brief_btn) # ── Wake word ────────────────────────────────────────────────────────── self._wake_btn = QPushButton() self._wake_btn.setFixedHeight(34) self._wake_btn.setFont(QFont("Menlo", 10)) self._wake_btn.setCursor(Qt.CursorShape.PointingHandCursor) self._wake_btn.clicked.connect(self._toggle_wake_word) lay.addWidget(self._wake_btn) self._wake_sleep_btn = QPushButton() self._wake_sleep_btn.setFixedHeight(34) self._wake_sleep_btn.setFont(QFont("Menlo", 10)) self._wake_sleep_btn.setCursor(Qt.CursorShape.PointingHandCursor) self._wake_sleep_btn.clicked.connect(self._tap_wake_manual) lay.addWidget(self._wake_sleep_btn) # Neutral placeholder now; the real state (which may load the model to # check readiness) is resolved lazily the first time the drawer opens. self._wake_btn.setText("🎙 WAKE WORD") self._wake_btn.setStyleSheet(_BTN_STYLE_DIM) self._wake_sleep_btn.hide() self._ptt_btn = QPushButton() self._ptt_btn.setFixedHeight(34) self._ptt_btn.setFont(QFont("Menlo", 10)) self._ptt_btn.setCursor(Qt.CursorShape.PointingHandCursor) self._ptt_btn.clicked.connect(self._toggle_ptt) lay.addWidget(self._ptt_btn) self._refresh_talk_btns() self._hud_btn = QPushButton() self._hud_btn.setFixedHeight(34) self._hud_btn.setFont(QFont("Menlo", 10)) self._hud_btn.setCursor(Qt.CursorShape.PointingHandCursor) self._hud_btn.clicked.connect(self._toggle_hud_style) lay.addWidget(self._hud_btn) self._refresh_hud_btn() audio_btn = QPushButton("🎧 AUDIO DEVICES") audio_btn.setFixedHeight(34) audio_btn.setFont(QFont("Menlo", 10)) audio_btn.setCursor(Qt.CursorShape.PointingHandCursor) audio_btn.setStyleSheet(_BTN_STYLE_DIM) audio_btn.clicked.connect(self._open_audio_devices) lay.addWidget(audio_btn) mem_btn = QPushButton("🧠 MEMORY") mem_btn.setFixedHeight(34) mem_btn.setFont(QFont("Menlo", 10)) mem_btn.setCursor(Qt.CursorShape.PointingHandCursor) mem_btn.setStyleSheet(_BTN_STYLE_DIM) mem_btn.clicked.connect(self._open_memory_panel) lay.addWidget(mem_btn) plugin_btn = QPushButton("🧩 PLUGINS") plugin_btn.setFixedHeight(34) plugin_btn.setFont(QFont("Menlo", 10)) plugin_btn.setCursor(Qt.CursorShape.PointingHandCursor) plugin_btn.setStyleSheet(_BTN_STYLE_DIM) plugin_btn.clicked.connect(self._open_plugin_manager) lay.addWidget(plugin_btn) settings_btn = QPushButton("⚙ PLUGIN SETTINGS") settings_btn.setFixedHeight(34) settings_btn.setFont(QFont("Menlo", 10)) settings_btn.setCursor(Qt.CursorShape.PointingHandCursor) settings_btn.setStyleSheet(_BTN_STYLE_DIM) settings_btn.clicked.connect(self._open_plugin_settings) lay.addWidget(settings_btn) w.adjustSize() return w def _toggle_drawer(self, checked: bool): if checked: self._refresh_wake_btns() # resolve wake state on open (lazy) self._position_quick_drawer() self._quick_drawer.show() self._quick_drawer.raise_() else: self._quick_drawer.hide() def _position_quick_drawer(self): if not hasattr(self, '_quick_drawer'): return _W = 220 self._quick_drawer.setFixedWidth(_W) self._quick_drawer.adjustSize() self._quick_drawer.setGeometry(12, 54, _W, self._quick_drawer.sizeHint().height()) def _build_input_row(self) -> QHBoxLayout: row = QHBoxLayout(); row.setSpacing(5) self._input = QLineEdit() self._input.setPlaceholderText("Type a command or question…") self._input.setFont(QFont("Menlo", 12)) self._input.setFixedHeight(30) self._input.setStyleSheet(f""" QLineEdit {{ background: #000d14; color: {C.WHITE}; border: 1px solid {C.BORDER}; border-radius: 3px; padding: 3px 7px; }} QLineEdit:focus {{ border: 1px solid {C.PRI}; }} """) self._input.returnPressed.connect(self._send) row.addWidget(self._input) send = QPushButton("▸") send.setFixedSize(30, 30) send.setFont(QFont("Menlo", 14, QFont.Weight.Bold)) send.setCursor(Qt.CursorShape.PointingHandCursor) send.setStyleSheet(f""" QPushButton {{ background: {C.PANEL}; color: {C.PRI}; border: 1px solid {C.PRI_DIM}; border-radius: 3px; }} QPushButton:hover {{ background: {C.PRI_GHO}; border: 1px solid {C.PRI}; }} """) send.clicked.connect(self._send) row.addWidget(send) return row def _build_content_panel(self) -> QWidget: """ Collapsible panel below the HUD — shows search results, news, briefings. Hidden by default; appears when show_content() is called. """ w = QWidget() w.setObjectName("ContentPanel") w.setStyleSheet(f""" QWidget#ContentPanel {{ background: {C.PANEL}; border-top: 1px solid {C.BORDER_B}; }} """) w.hide() lay = QVBoxLayout(w) lay.setContentsMargins(12, 7, 12, 8) lay.setSpacing(5) # ── header row ─────────────────────────────────────────────────────── hdr = QHBoxLayout(); hdr.setSpacing(6) dot = QLabel("◈") dot.setFont(QFont("Menlo", 12, QFont.Weight.Bold)) dot.setStyleSheet(f"color: {C.PRI}; background: transparent;") hdr.addWidget(dot) self._content_title_lbl = QLabel("BRIEFING") self._content_title_lbl.setFont(QFont("Menlo", 11, QFont.Weight.Bold)) self._content_title_lbl.setStyleSheet( f"color: {C.PRI}; background: transparent; letter-spacing: 1px;" ) hdr.addWidget(self._content_title_lbl) hdr.addStretch() self._content_ts_lbl = QLabel("") self._content_ts_lbl.setFont(QFont("Menlo", 10)) self._content_ts_lbl.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;") hdr.addWidget(self._content_ts_lbl) dismiss = QPushButton("DISMISS ✕") dismiss.setFont(QFont("Menlo", 10)) dismiss.setFixedHeight(18) dismiss.setCursor(Qt.CursorShape.PointingHandCursor) dismiss.setStyleSheet(f""" QPushButton {{ background: transparent; color: {C.TEXT_DIM}; border: 1px solid {C.BORDER}; border-radius: 2px; padding: 0 5px; }} QPushButton:hover {{ color: {C.TEXT}; border-color: {C.BORDER_B}; }} """) dismiss.clicked.connect(w.hide) hdr.addWidget(dismiss) lay.addLayout(hdr) # ── separator ───────────────────────────────────────────────────────── sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine) sep.setStyleSheet(f"color: {C.BORDER};"); lay.addWidget(sep) # ── text display ────────────────────────────────────────────────────── self._content_display = QTextEdit() self._content_display.setReadOnly(True) self._content_display.setFont(QFont("Menlo", 11)) self._content_display.setMinimumHeight(60) self._content_display.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding ) self._content_display.setStyleSheet(f""" QTextEdit {{ background: {C.DARK}; color: {C.TEXT}; border: 1px solid {C.BORDER}; border-radius: 3px; padding: 6px 8px; selection-background-color: {C.PRI_GHO}; }} QScrollBar:vertical {{ background: {C.BG}; width: 6px; border: none; }} QScrollBar::handle:vertical {{ background: {C.BORDER_B}; border-radius: 3px; min-height: 16px; }} QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; border: none; }} """) lay.addWidget(self._content_display) return w def _show_content(self, title: str, text: str): """Slot — runs on Qt main thread. Updates and shows the content panel.""" import time as _time # The panel opens below the head, so the head looks down at it. It is a # tiny thing that answers "did that land?" before you read a word. self.hud.glance(0.0, -0.85, hold=1.3) self._content_title_lbl.setText(title.upper()[:48]) self._content_ts_lbl.setText(_time.strftime("%H:%M:%S")) self._content_display.setPlainText(text) self._content_display.moveCursor( self._content_display.textCursor().MoveOperation.Start ) first_show = not self._content_panel.isVisible() self._content_panel.show() if first_show: total = self._center_split.height() self._center_split.setSizes([max(total - 220, 120), 220]) # ── document review ────────────────────────────────────────────────────── # Rendered as rich text into the content panel that already exists, rather # than into a panel of its own. A review is read, not clicked, so QTextEdit # gives scrolling, selection and copy for nothing, and the HUD gains no # widget it has to lay out. Severity decides colour and order here because # that is presentation; the plugin supplies no styling and knows no palette, # which is also what lets a re-theme repaint a review correctly. # Severity is marked by a symbol and a colour, not by a word. The findings # themselves are in the user's language, and "[SERIOUS]" sitting inside a # Turkish sentence is the kind of seam this project tries not to have — # while translating the tag would mean a table per language, which is worse. # A shape carries it in every language, and shape plus colour still reads # for someone who cannot separate red from amber. What the marks mean # arrives the way everything else does: JARVIS says it out loud. _REVIEW_MARKS = {"serious": ("RED", "▲"), "caution": ("ACC2", "●"), "note": ("PRI_DIM", "·")} @staticmethod def _esc(s) -> str: return (str(s or "").replace("&", "&").replace("<", "<") .replace(">", ">").replace("\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'
'] if summary: parts.append( f'
{e(summary)}
') for f in (findings or []): key, mark = self._REVIEW_MARKS.get(f.get("severity"), ("PRI_DIM", "·")) colour = getattr(C, key) parts.append(f'
') parts.append( f'{mark} ' f'' f'{e(f.get("heading"))}') if f.get("detail"): parts.append(f'
{e(f["detail"])}
') if f.get("quote"): # The document's own wording, visually separated from the # explanation so the two are never mistaken for each other. parts.append( f'
' f'“{e(f["quote"])}”
') if f.get("suggestion"): parts.append( f'
' f'→ {e(f["suggestion"])}
') parts.append('
') if unclear: parts.append( f'
' 'The document does not settle:
') for u in unclear: parts.append( f'
' f'· {e(u)}
') parts.append('
') import time as _time self.hud.glance(0.0, -0.85, hold=1.3) # Left as written, not upper-cased. The other content-panel titles are # the app's own English labels, but this one is the document's name in # the user's language, and str.upper() applies English casing rules to # it: Turkish "Sözleşmesi" comes back "SÖZLEŞMESI", having lost the # dotted capital İ. Python has no locale-aware upper to reach for, and # imposing one language's rules on all of them is the bug, not the fix. self._content_title_lbl.setText((title or "Document")[:48]) self._content_ts_lbl.setText(_time.strftime("%H:%M:%S")) self._content_display.setHtml("".join(parts)) self._content_display.moveCursor( self._content_display.textCursor().MoveOperation.Start) first_show = not self._content_panel.isVisible() self._content_panel.show() if first_show: total = self._center_split.height() self._center_split.setSizes([max(total - 260, 120), 260, 0]) # ── quiz panel ─────────────────────────────────────────────────────────── # An interactive twin of the content panel. The plugin only ever hands over # questions; everything about asking, marking and reporting happens here, # and the finished result is pushed back into the conversation the same way # a dropped file is — as a message JARVIS reads and responds to. That keeps # the tool call short (it returns the moment the board is up) and leaves the # talking to the assistant, in the user's own language. def _quiz_btn(self, text: str, primary: bool = False) -> QPushButton: b = QPushButton(text) b.setFont(QFont("Menlo", 11)) b.setCursor(Qt.CursorShape.PointingHandCursor) b.setMinimumHeight(24) edge = C.BORDER_B if primary else C.BORDER col = C.PRI if primary else C.TEXT_MED b.setStyleSheet(f""" QPushButton {{ background: {C.PANEL2}; color: {col}; border: 1px solid {edge}; border-radius: 2px; padding: 3px 9px; text-align: left; }} QPushButton:hover {{ color: {C.WHITE}; border-color: {C.PRI_DIM}; }} QPushButton:disabled {{ color: {C.TEXT_DIM}; border-color: {C.BORDER}; }} """) return b def _build_quiz_panel(self) -> QWidget: w = QWidget() w.setObjectName("QuizPanel") w.setStyleSheet(f""" QWidget#QuizPanel {{ background: {C.PANEL}; border-top: 1px solid {C.BORDER_B}; }} """) w.hide() lay = QVBoxLayout(w) lay.setContentsMargins(12, 7, 12, 8) lay.setSpacing(6) hdr = QHBoxLayout(); hdr.setSpacing(6) dot = QLabel("◈") dot.setFont(QFont("Menlo", 12, QFont.Weight.Bold)) dot.setStyleSheet(f"color: {C.PRI}; background: transparent;") hdr.addWidget(dot) self._quiz_title_lbl = QLabel("QUIZ") self._quiz_title_lbl.setFont(QFont("Menlo", 11, QFont.Weight.Bold)) self._quiz_title_lbl.setStyleSheet( f"color: {C.PRI}; background: transparent; letter-spacing: 1px;") hdr.addWidget(self._quiz_title_lbl) hdr.addStretch() self._quiz_count_lbl = QLabel("") self._quiz_count_lbl.setFont(QFont("Menlo", 10)) self._quiz_count_lbl.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;") hdr.addWidget(self._quiz_count_lbl) quit_btn = QPushButton("DISMISS ✕") quit_btn.setFont(QFont("Menlo", 10)) quit_btn.setFixedHeight(18) quit_btn.setCursor(Qt.CursorShape.PointingHandCursor) quit_btn.setStyleSheet(f""" QPushButton {{ background: transparent; color: {C.TEXT_DIM}; border: 1px solid {C.BORDER}; border-radius: 2px; padding: 0 5px; }} QPushButton:hover {{ color: {C.TEXT}; border-color: {C.BORDER_B}; }} """) quit_btn.clicked.connect(self._hide_quiz) hdr.addWidget(quit_btn) lay.addLayout(hdr) rule = QFrame(); rule.setFixedHeight(1) rule.setStyleSheet(f"background: {C.BORDER};") lay.addWidget(rule) self._quiz_q_lbl = QLabel("") self._quiz_q_lbl.setWordWrap(True) self._quiz_q_lbl.setFont(QFont("Menlo", 12)) self._quiz_q_lbl.setStyleSheet(f"color: {C.WHITE}; background: transparent;") lay.addWidget(self._quiz_q_lbl) self._quiz_answers = QWidget() self._quiz_answers.setStyleSheet("background: transparent;") self._quiz_answers_lay = QVBoxLayout(self._quiz_answers) self._quiz_answers_lay.setContentsMargins(0, 2, 0, 0) self._quiz_answers_lay.setSpacing(4) lay.addWidget(self._quiz_answers) self._quiz_note_lbl = QLabel("") self._quiz_note_lbl.setWordWrap(True) self._quiz_note_lbl.setFont(QFont("Menlo", 11)) self._quiz_note_lbl.setStyleSheet(f"color: {C.TEXT_MED}; background: transparent;") self._quiz_note_lbl.hide() lay.addWidget(self._quiz_note_lbl) foot = QHBoxLayout() foot.addStretch() self._quiz_next_btn = self._quiz_btn("NEXT →", primary=True) self._quiz_next_btn.setFixedWidth(110) self._quiz_next_btn.clicked.connect(self._quiz_next) self._quiz_next_btn.hide() foot.addWidget(self._quiz_next_btn) lay.addLayout(foot) self._quiz = None return w def _show_quiz(self, topic: str, questions, grader=None): """Slot — Qt main thread. Puts a fresh quiz on the board.""" if not questions: return self._quiz = { "topic": topic or "", "questions": list(questions), "grader": grader, "i": 0, "results": [], "answered": False, } self._quiz_title_lbl.setText((topic or "quiz").upper()[:48]) self.hud.glance(0.0, -0.85, hold=1.3) first_show = not self._quiz_panel.isVisible() self._quiz_panel.show() if first_show: total = self._center_split.height() self._center_split.setSizes([max(total - 250, 120), 0, 250]) self._quiz_render() def _hide_quiz(self): self._quiz = None self._quiz_panel.hide() def _quiz_clear_answers(self): while self._quiz_answers_lay.count(): item = self._quiz_answers_lay.takeAt(0) child = item.widget() if child is not None: child.setParent(None) child.deleteLater() def _quiz_render(self): q = self._quiz["questions"][self._quiz["i"]] n, total = self._quiz["i"] + 1, len(self._quiz["questions"]) self._quiz_count_lbl.setText(f"{n} / {total}") self._quiz_q_lbl.setText(q.get("question", "")) self._quiz_note_lbl.hide() self._quiz_next_btn.hide() self._quiz["answered"] = False self._quiz_clear_answers() opts = q.get("options") or [] if opts: for text in opts: b = self._quiz_btn(" " + text) b.clicked.connect(lambda _=False, t=text: self._quiz_submit(t)) self._quiz_answers_lay.addWidget(b) else: row = QWidget(); row.setStyleSheet("background: transparent;") h = QHBoxLayout(row); h.setContentsMargins(0, 0, 0, 0); h.setSpacing(6) field = QLineEdit() field.setFont(QFont("Menlo", 12)) field.setPlaceholderText("your answer") field.setStyleSheet(f""" QLineEdit {{ background: {C.PANEL2}; color: {C.WHITE}; border: 1px solid {C.BORDER}; border-radius: 2px; padding: 4px 7px; }} QLineEdit:focus {{ border-color: {C.PRI_DIM}; }} """) send = self._quiz_btn("ANSWER", primary=True) send.setFixedWidth(90) field.returnPressed.connect(lambda: self._quiz_submit(field.text())) send.clicked.connect(lambda: self._quiz_submit(field.text())) h.addWidget(field, stretch=1) h.addWidget(send) self._quiz_answers_lay.addWidget(row) field.setFocus() def _quiz_submit(self, given: str): if self._quiz is None or self._quiz["answered"]: return self._quiz["answered"] = True q = self._quiz["questions"][self._quiz["i"]] grader = self._quiz.get("grader") verdict = None if callable(grader): try: verdict = grader(q, given) except Exception: verdict = None self._quiz["results"].append({ "question": q.get("question", ""), "type": q.get("type", ""), "given": str(given or "").strip(), "answer": q.get("answer", ""), "correct": verdict, }) for i in range(self._quiz_answers_lay.count()): wdg = self._quiz_answers_lay.itemAt(i).widget() if wdg is not None: wdg.setEnabled(False) if verdict is True: mark, colour = "✓ correct", C.GREEN elif verdict is False: mark, colour = "✕ " + str(q.get("answer", "")), C.RED else: # Open answers and near-miss gap-fills are JARVIS's to judge. Saying # so is honest; marking it wrong here would be a guess. mark, colour = "… noted — I'll go over this one with you", C.ACC2 note = q.get("note") or "" self._quiz_note_lbl.setText(mark + (("\n" + note) if note else "")) self._quiz_note_lbl.setStyleSheet(f"color: {colour}; background: transparent;") self._quiz_note_lbl.show() last = self._quiz["i"] >= len(self._quiz["questions"]) - 1 self._quiz_next_btn.setText("FINISH →" if last else "NEXT →") self._quiz_next_btn.show() self._quiz_next_btn.setFocus() def _quiz_next(self): if self._quiz is None: return if self._quiz["i"] >= len(self._quiz["questions"]) - 1: self._quiz_finish() else: self._quiz["i"] += 1 self._quiz_render() def _quiz_finish(self): if self._quiz is None: return topic = self._quiz["topic"] results = self._quiz["results"] right = sum(1 for r in results if r["correct"] is True) unsure = sum(1 for r in results if r["correct"] is None) total = len(results) self._quiz_panel.hide() self._quiz = None self._log.append_log(f"QUIZ: {topic or 'quiz'} — {right}/{total} correct") # Hand it back to JARVIS as a message, not as a tool return: the tool # call ended minutes ago. This is the same channel a dropped file uses. lines = [f"[QUIZ_DONE] topic={topic or 'general'} | " f"auto-marked {right}/{total} correct" + (f", {unsure} still need your marking" if unsure else "")] for i, r in enumerate(results, 1): state = ("correct" if r["correct"] is True else "wrong" if r["correct"] is False else "NEEDS MARKING") lines.append( f"{i}. [{r['type']}] {r['question']} | they answered: " f"{r['given'] or '(blank)'} | expected: {r['answer']} | {state}") lines.append( "Mark every question flagged NEEDS MARKING yourself — accept an answer " "that means the same thing. Then tell them how they did in their own " "language: the score, what they got wrong and why, in a couple of " "sentences. Offer another round only if it fits. " "Remember something only if it would still matter next week — that they " "are working through a subject, or keep missing the same thing. A score " "from one session is not worth a memory, and a memory per quiz would " "bury the things that are.") msg = "\n".join(lines) if self.on_text_command: threading.Thread(target=self.on_text_command, args=(msg,), daemon=True).start() def _build_footer(self) -> QWidget: w = QWidget() w.setFixedHeight(22) w.setStyleSheet(f"background: {C.DARK}; border-top: 1px solid {C.BORDER};") lay = QHBoxLayout(w); lay.setContentsMargins(14, 0, 14, 0) def _fl(txt, color=C.TEXT_MED): l = QLabel(txt); l.setFont(QFont("Menlo", 10)) l.setStyleSheet(f"color: {color}; background: transparent;") return l lay.addWidget(_fl("[F4] Mute · [F11] Fullscreen")) lay.addStretch() lay.addWidget(_fl("By LuZa", C.PRI_DIM)) return w def _on_file_selected(self, path: str): self._current_file = path p = Path(path) cat = _file_category(p) icon, _ = _FILE_ICONS.get(cat, _FILE_ICONS["unknown"]) size = _fmt_size(p.stat().st_size) self._file_hint.setText(f"{icon} {p.name} · {size} · Tell {self._assistant_name} what to do with it") self._log.append_log(f"FILE: {p.name} ({size}) loaded") if self.on_text_command: msg = ( f"[FILE_UPLOADED] path={path} | name={p.name} | " f"type={p.suffix.lstrip('.')} | size={size} | " f"Briefly tell the user you can see the file '{p.name}' " f"({size}) has been uploaded and ask what they'd like to do with it." ) threading.Thread(target=self.on_text_command, args=(msg,), daemon=True).start() def notify_phone_connected(self) -> None: if self._remote_overlay and self._remote_overlay.isVisible(): self._remote_overlay.mark_connected() def _open_remote(self): if not self.on_remote_clicked: self._log.append_log("SYS: Dashboard not running — remote unavailable.") return result = self.on_remote_clicked() if not result: self._log.append_log("SYS: Could not generate remote key.") return url = result[0] key = result[1] auto = result[2] if len(result) >= 3 else "" manual = result[3] if len(result) >= 4 else url if self._remote_overlay: self._remote_overlay._do_close() cw = self.centralWidget() ow, oh = RemoteKeyOverlay._OW, RemoteKeyOverlay._OH ov = RemoteKeyOverlay(url, key, auto_login_url=auto, manual_url=manual, expiry_secs=600, parent=cw) ov.set_new_key_callback(self.on_remote_clicked) ov.setGeometry( (cw.width() - ow) // 2, (cw.height() - oh) // 2, ow, oh, ) ov.closed.connect(lambda: setattr(self, '_remote_overlay', None)) ov.show() self._remote_overlay = ov self._log.append_log(f"SYS: Remote key generated — manual: {manual or url}") # ── Auto-start ────────────────────────────────────────────────────────────── def _check_autostart(self) -> bool: """Returns True if auto-start is currently registered on this OS.""" try: if _OS == "Windows": import winreg key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run", 0, winreg.KEY_READ) try: winreg.QueryValueEx(key, "JARVIS_AI") return True except FileNotFoundError: return False finally: winreg.CloseKey(key) elif _OS == "Darwin": return (Path.home() / "Library" / "LaunchAgents" / "com.jarvis.assistant.plist").exists() else: return (Path.home() / ".config" / "autostart" / "jarvis.desktop").exists() except Exception: return False def _toggle_autostart(self): currently_on = self._check_autostart() try: script = str(Path(__file__).resolve().parent / "main.py") if _OS == "Windows": import winreg reg = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run", 0, winreg.KEY_ALL_ACCESS) if currently_on: winreg.DeleteValue(reg, "JARVIS_AI") else: pythonw = Path(sys.executable).parent / "pythonw.exe" exe = str(pythonw if pythonw.exists() else sys.executable) winreg.SetValueEx(reg, "JARVIS_AI", 0, winreg.REG_SZ, f'"{exe}" "{script}"') winreg.CloseKey(reg) elif _OS == "Darwin": plist_dir = Path.home() / "Library" / "LaunchAgents" plist_dir.mkdir(parents=True, exist_ok=True) plist = plist_dir / "com.jarvis.assistant.plist" if currently_on: plist.unlink(missing_ok=True) else: plist.write_text( '\n' '\n' '\n' ' Labelit.luza.assistente\n' ' ProgramArguments\n' + (' /usr/bin/open\n -a\n /Applications/LuZa.app\n' if Path("/Applications/LuZa.app").exists() else f' {sys.executable}\n {script}\n') + ' \n' ' RunAtLoad\n' '\n' ) else: desk_dir = Path.home() / ".config" / "autostart" desk_dir.mkdir(parents=True, exist_ok=True) desk = desk_dir / "jarvis.desktop" if currently_on: desk.unlink(missing_ok=True) else: desk.write_text( "[Desktop Entry]\n" f"Name={self._assistant_name}\n" f"Exec={sys.executable} {script}\n" "Type=Application\nTerminal=false\n" "X-GNOME-Autostart-enabled=true\n" ) enabled = not currently_on self._update_autostart_btn(enabled) self._log.append_log( f"SYS: Auto-start {'enabled' if enabled else 'disabled'}.") except Exception as e: self._log.append_log(f"ERR: Auto-start failed — {e}") def _update_autostart_btn(self, enabled: bool): if not hasattr(self, '_autostart_btn'): return if enabled: self._autostart_btn.setText("◉ AUTO-START: ON") self._autostart_btn.setStyleSheet(f""" QPushButton {{ background: #001a08; color: {C.GREEN}; border: 1px solid {C.GREEN_D}; border-radius: 3px; }} QPushButton:hover {{ background: #002010; }} """) else: self._autostart_btn.setText("◉ AUTO-START: OFF") self._autostart_btn.setStyleSheet(f""" QPushButton {{ background: transparent; color: {C.TEXT_DIM}; border: 1px solid {C.BORDER}; border-radius: 3px; }} QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }} """) def _toggle_brief(self): from memory.config_manager import get_brief_enabled, save_brief_enabled new_val = not get_brief_enabled() save_brief_enabled(new_val) self._update_brief_btn(new_val) # ── Wake word settings ─────────────────────────────────────────────────── def _wake_state(self) -> dict: """Combined state for the two wake-word buttons. Readiness is a cheap, deterministic on-disk check now (see core.wake_word.is_ready), so there is nothing to cache — the button never flickers to a stale value.""" if self.wake_get_state: try: s = self.wake_get_state() return {"ready": bool(s.get("ready")), "enabled": bool(s.get("enabled")), "awake": bool(s.get("awake"))} except Exception: pass # Before JarvisLive has wired its callback (drawer built at startup). ready, enabled = False, False try: from core.wake_word import is_ready from memory.config_manager import get_wake_word_enabled ready, enabled = is_ready(), get_wake_word_enabled() except Exception: pass return {"ready": ready, "enabled": enabled, "awake": True} def _refresh_wake_btns(self): if not hasattr(self, '_wake_btn'): return st = self._wake_state() _on = f""" QPushButton {{ background: #001a08; color: {C.GREEN}; border: 1px solid {C.GREEN_D}; border-radius: 3px; text-align: left; padding: 0 8px; }} QPushButton:hover {{ background: #002010; }}""" _off = f""" QPushButton {{ background: transparent; color: {C.TEXT_DIM}; border: 1px solid {C.BORDER}; border-radius: 3px; text-align: left; padding: 0 8px; }} QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }}""" self._wake_btn.setEnabled(True) if not st["ready"]: self._wake_btn.setText("⬇ WAKE WORD: DOWNLOAD") self._wake_btn.setStyleSheet(_off) self._wake_sleep_btn.hide() elif st["enabled"]: self._wake_btn.setText("🎙 WAKE WORD: ON") self._wake_btn.setStyleSheet(_on) self._wake_sleep_btn.show() self._wake_sleep_btn.setText("😴 SLEEP NOW" if st["awake"] else "👂 WAKE NOW") self._wake_sleep_btn.setStyleSheet(_off) else: self._wake_btn.setText("🎙 WAKE WORD: OFF") self._wake_btn.setStyleSheet(_off) self._wake_sleep_btn.hide() def _refresh_talk_btns(self): """Repaint the push-to-talk row from the saved setting.""" if not hasattr(self, "_ptt_btn"): return from core.hotkey import chord_label from memory.config_manager import get_push_to_talk_enabled _on = f""" QPushButton {{ background: #001a08; color: {C.GREEN}; border: 1px solid {C.GREEN_D}; border-radius: 3px; text-align: left; padding: 0 8px; }} QPushButton:hover {{ background: #002010; }}""" _off = f""" QPushButton {{ background: transparent; color: {C.TEXT_DIM}; border: 1px solid {C.BORDER}; border-radius: 3px; text-align: left; padding: 0 8px; }} QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }}""" ptt = get_push_to_talk_enabled() self._ptt_btn.setText(f"🎚 PUSH-TO-TALK: {chord_label()}" if ptt else "🎚 PUSH-TO-TALK: OFF") self._ptt_btn.setStyleSheet(_on if ptt else _off) self._ptt_btn.setToolTip( "Microphone stays closed until you hold the key — nothing is sent " "while you are not holding it." if ptt else "Hold a key to talk instead of streaming the mic continuously.") def _refresh_hud_btn(self): from memory.config_manager import get_hud_style cur = get_hud_style() face = cur == "face" # Neither state is "off", so both read as active — this is a choice # between two things, not a switch with a disabled side. style = f""" QPushButton {{ background: {C.PANEL2}; color: {C.PRI}; border: 1px solid {C.BORDER_A}; border-radius: 3px; text-align: left; padding: 0 8px; }} QPushButton:hover {{ color: {C.WHITE}; border: 1px solid {C.BORDER_B}; }}""" self._hud_btn.setText({"face": "🧑 HUD: ANIMATED FACE", "core": "◉ HUD: REACTOR CORE"}.get(cur, "🧍 HUD: AVATAR 3D")) self._hud_btn.setStyleSheet(style) self._hud_btn.setToolTip( "An animated head that speaks your words and shows what the assistant is " "doing. Tap to switch to the reactor core." if face else "A reactor core che segue lo stato e la tua voce. " "Tap to switch to the animated head.") def _toggle_hud_style(self): """Swap the centrepiece. Both objects stay in memory, so the change is instant and switching back costs nothing.""" from memory.config_manager import get_hud_style, save_hud_style order = ["face", "core", "3d"] cur = get_hud_style() want = order[(order.index(cur) + 1) % len(order)] if cur in order else "face" if want == "3d": try: self._ensure_avatar3d() except Exception as e: self._log.append_log(f"ERR: Avatar 3D non disponibile — {e}") want = "face" save_hud_style(want) if want != "3d": self._hud_cam_stack.setCurrentIndex(0) try: self.hud.hud_style = want if want != "3d" else "face" self.hud.update() except Exception: pass self._refresh_hud_btn() self._log.append_log({"face": "SYS: HUD switched to the animated face.", "core": "SYS: HUD switched to the reactor core."}.get(want, "SYS: HUD: avatar 3D.")) def _toggle_ptt(self): from memory.config_manager import (get_push_to_talk_enabled, save_push_to_talk_enabled) want = not get_push_to_talk_enabled() save_push_to_talk_enabled(want) scope = None if self.on_push_to_talk: try: scope = self.on_push_to_talk(want) except Exception as e: self._log.append_log(f"ERR: Push-to-talk failed — {e}") save_push_to_talk_enabled(False) want = False self._apply_ptt_shortcut(want and scope != "global") self._refresh_talk_btns() def _apply_ptt_shortcut(self, needed: bool): """Bind the chord inside the window when no global hook is available. On macOS and Linux there is no dependency-free way to read global key state, so the chord is at least live whenever this window has focus. Qt gives no key-release for a QShortcut, so a press latches the mic open and a short timer closes it; held down, auto-repeat keeps pushing that timer out, which behaves like holding a key. """ from PyQt6.QtGui import QKeySequence, QShortcut from core.hotkey import qt_sequence if not needed: sc = getattr(self, "_ptt_sc", None) if sc is not None: sc.setEnabled(False) self._ptt_sc = None self._ptt_hold(False) return if getattr(self, "_ptt_sc", None) is not None: return self._ptt_release = QTimer(self) self._ptt_release.setSingleShot(True) self._ptt_release.setInterval(420) self._ptt_release.timeout.connect(lambda: self._ptt_hold(False)) def _press(): self._ptt_hold(True) self._ptt_release.start() self._ptt_sc = QShortcut(QKeySequence(qt_sequence()), self) self._ptt_sc.setAutoRepeat(True) self._ptt_sc.activated.connect(_press) def _ptt_hold(self, held: bool): """Report a windowed press/release to whoever owns the microphone.""" cb = getattr(self, "ptt_hold", None) if cb: try: cb(bool(held)) except Exception: pass def _toggle_wake_word(self): st = self._wake_state() if not st["ready"]: # First time: download openwakeword + model in a worker thread. self._wake_btn.setText("⬇ DOWNLOADING… (one-time)") self._wake_btn.setEnabled(False) def _work(): try: from core.wake_word import install_and_download ok, msg = install_and_download( logger=lambda m: self._log_sig.emit(f"SYS: {m}")) except Exception as e: ok, msg = False, str(e) if ok and self.on_wake_toggle: try: self.on_wake_toggle(True) # auto-enable after a successful download except Exception: pass self._wake_dl_sig.emit(ok, msg) threading.Thread(target=_work, daemon=True).start() return # Already downloaded → just flip enabled/disabled through JarvisLive. if self.on_wake_toggle: try: self.on_wake_toggle(not st["enabled"]) except Exception: pass self._refresh_wake_btns() def _on_wake_install_done(self, ok: bool, msg: str): self._log_sig.emit(f"SYS: {'Wake word ready.' if ok else 'Wake word setup failed: ' + msg}") self._refresh_wake_btns() def _tap_wake_manual(self): if self.on_wake_manual: try: self.on_wake_manual() except Exception: pass self._refresh_wake_btns() def _update_brief_btn(self, enabled: bool): if not hasattr(self, '_brief_btn'): return if enabled: self._brief_btn.setText("☀ MORNING BRIEF: ON") self._brief_btn.setStyleSheet(f""" QPushButton {{ background: #001a08; color: {C.GREEN}; border: 1px solid {C.GREEN_D}; border-radius: 3px; text-align: left; padding: 0 8px; }} QPushButton:hover {{ background: #002010; }} """) else: self._brief_btn.setText("☀ MORNING BRIEF: OFF") self._brief_btn.setStyleSheet(f""" QPushButton {{ background: transparent; color: {C.TEXT_DIM}; border: 1px solid {C.BORDER}; border-radius: 3px; text-align: left; padding: 0 8px; }} QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }} """) # ── Customization ──────────────────────────────────────────────────────────── def _open_customize(self): cfg = _read_full_config() if self._customize_overlay: self._customize_overlay.hide() cw = self.centralWidget() ov = CustomizeOverlay( cfg.get("assistant_name", "JARVIS") or "JARVIS", cfg.get("user_name", ""), cfg.get("ui_color", "") or DEFAULT_UI_COLOR, cfg.get("voice_name", ""), parent=cw, ) ow, oh = CustomizeOverlay._OW, CustomizeOverlay._OH oh = min(oh, cw.height() - 16) ov.setGeometry( (cw.width() - ow) // 2, (cw.height() - oh) // 2, ow, oh, ) ov.on_preview = self._preview_ui_color ov.saved.connect(self._apply_name_update) ov.show() self._customize_overlay = ov def _preview_ui_color(self, hex_color: str): """Live preview — paints the whole interface the new colour (does NOT write to config).""" old = current_palette() if apply_ui_accent(hex_color): retheme_all_widgets(old, current_palette()) def _apply_name_update(self, name: str, user_name: str, ui_color: str = "", voice: str = ""): """Update all name/theme-dependent UI elements and persist to config.""" self._assistant_name = name.strip() or "JARVIS" display = self._assistant_name.upper() self.setWindowTitle(display if display.lower() == APP_VERSION.lower() else f"{display} — {APP_VERSION}") self._title_lbl.setText(display) if display in ("JARVIS", "J.A.R.V.I.S"): self._sub_lbl.setText("Just A Rather Very Intelligent System") else: self._sub_lbl.setText("Assistente personale") self._log._ai_name_lc = self._assistant_name.lower() self.hud._assistant_name = display color_changed = False if ui_color: old = current_palette() if apply_ui_accent(ui_color): # Live-paint the whole interface (panels, buttons, borders, HUD) retheme_all_widgets(old, current_palette()) color_changed = old["PRI"] != C.PRI # Voice change → persist and, if it actually changed, rebuild the Live # session so the new voice takes effect (it's fixed at connect time). voice_changed = False if voice: from memory.config_manager import get_voice, save_voice if voice != get_voice(): save_voice(voice) voice_changed = True try: data = _read_full_config() data["assistant_name"] = self._assistant_name data["user_name"] = user_name.strip() if ui_color: data["ui_color"] = ui_color.strip().lower() API_FILE.write_text(json.dumps(data, indent=4), encoding="utf-8") self._log.append_log(f"SYS: Identity updated — {display}") if color_changed: self._log.append_log(f"SYS: UI colour applied — {ui_color}") if voice_changed: self._log.append_log(f"SYS: Voice set — {voice}") except Exception as e: self._log.append_log(f"ERR: Config save failed — {e}") if voice_changed and self.on_voice_change: self.on_voice_change() def _centre_overlay(self, ov) -> None: """Place a floating overlay in the middle of the HUD and show it.""" cw = self.centralWidget() ov.adjustSize() ov.setGeometry( max(0, (cw.width() - ov.width()) // 2), max(0, (cw.height() - ov.height()) // 2), ov.width(), ov.height(), ) ov.show() ov.raise_() # ── Audio devices ──────────────────────────────────────────────────────── def _open_audio_devices(self): ov = AudioDeviceOverlay(parent=self.centralWidget()) ov.picked.connect(self._on_audio_devices_applied) self._centre_overlay(ov) self._audio_overlay = ov # keep a reference so it isn't GC'd def _on_audio_devices_applied(self): self._log.append_log("SYS: Audio devices updated.") if self.on_audio_device_change: self.on_audio_device_change() # ── Memory panel ───────────────────────────────────────────────────────── def _open_memory_panel(self): ov = MemoryOverlay(parent=self.centralWidget()) self._centre_overlay(ov) self._memory_overlay = ov # ── Irreversible-action confirmation ───────────────────────────────────── def _show_confirm_banner(self, title: str, detail: str): self._hide_confirm_banner() ov = ConfirmBanner(title, detail, parent=self.centralWidget()) ov.answered.connect(self._on_confirm_answered) self._centre_overlay(ov) self._confirm_overlay = ov def _hide_confirm_banner(self): ov = getattr(self, "_confirm_overlay", None) if ov is not None: ov.hide() ov.deleteLater() self._confirm_overlay = None def _on_confirm_answered(self, accepted: bool): # Tear the banner down first: core.confirm.resolve() may be about to # shut the machine down, and a live widget mid-callback is not where you # want to be when that happens. self._hide_confirm_banner() try: from core.confirm import resolve resolve(bool(accepted)) except Exception as e: self._log.append_log(f"ERR: Confirmation failed — {e}") def _open_plugin_manager(self): plugins = self.get_plugins() if self.get_plugins else [] cw = self.centralWidget() ov = PluginManagerOverlay(plugins, parent=cw, max_height=cw.height() - 40) ov.adjustSize() ov.setGeometry( (cw.width() - ov.width()) // 2, (cw.height() - ov.height()) // 2, ov.width(), ov.height(), ) ov.show() ov.raise_() self._plugin_manager_overlay = ov # keep a reference so it isn't GC'd def _open_plugin_settings(self): sections = self.get_plugin_settings() if self.get_plugin_settings else [] cw = self.centralWidget() ov = PluginSettingsOverlay(sections, parent=cw) ow = PluginSettingsOverlay._OW oh = min(560, cw.height() - 16) ov.setGeometry( (cw.width() - ow) // 2, (cw.height() - oh) // 2, ow, oh, ) ov.show() ov.raise_() self._plugin_settings_overlay = ov # keep a reference so it isn't GC'd # ── Clipboard intelligence ─────────────────────────────────────────────────── def _on_clipboard_changed(self): try: text = QApplication.clipboard().text().strip() if len(text) >= 10: self._clipboard_sig.emit(text) except Exception: pass def _show_clipboard_panel(self, text: str): self._clipboard_panel.show_clipboard(text) self._position_clipboard_panel() def _position_clipboard_panel(self): cw = self.centralWidget() pw = ClipboardPanel._W ph = self._clipboard_panel.sizeHint().height() or ClipboardPanel._H x = (cw.width() - pw) // 2 y = cw.height() - ph - 6 self._clipboard_panel.setGeometry(x, y, pw, ph) self._clipboard_panel.raise_() def _on_clipboard_action(self, cmd: str): if self.on_text_command: threading.Thread(target=self.on_text_command, args=(cmd,), daemon=True).start() # ──────────────────────────────────────────────────────────────────────────── def _do_interrupt(self): if self.on_interrupt: self.on_interrupt() def _toggle_mute(self): self._muted = not self._muted self.hud.muted = self._muted self._style_mute_btn() if self._muted: self._apply_state("MUTED") self._log.append_log("SYS: Microphone muted.") else: self._apply_state("LISTENING") self._log.append_log("SYS: Microphone active.") def _style_mute_btn(self): if self._muted: self._mute_btn.setText("🔇 MICROPHONE MUTED") self._mute_btn.setStyleSheet(f""" QPushButton {{ background: #140006; color: {C.MUTED_C}; border: 1px solid {C.MUTED_C}; border-radius: 3px; }} """) else: self._mute_btn.setText("🎙 MICROPHONE ACTIVE") self._mute_btn.setStyleSheet(f""" QPushButton {{ background: #00140a; color: {C.GREEN}; border: 1px solid {C.GREEN}; border-radius: 3px; }} QPushButton:hover {{ background: #001f10; }} """) def _send(self): txt = self._input.text().strip() if not txt: return self._input.clear() self._log.append_log(f"You: {txt}") if self.on_text_command: threading.Thread(target=self.on_text_command, args=(txt,), daemon=True).start() def _apply_state(self, state: str): self.hud.state = state self.hud.speaking = (state == "SPEAKING") if self.avatar3d is not None: self.avatar3d.set_state(state) def _check_config(self) -> bool: # AvatarPy: nessuna chiave Gemini; basta il file d'identità creato da main.py. return API_FILE.exists() def _show_setup(self): ov = SetupOverlay(self.centralWidget()) cw = self.centralWidget() ow, oh = 460, 390 ov.setGeometry( (cw.width() - ow) // 2, (cw.height() - oh) // 2, ow, oh, ) ov.done.connect(self._on_setup_done) ov.show() self._overlay = ov def _on_setup_done(self, key: str, os_name: str): os.makedirs(CONFIG_DIR, exist_ok=True) API_FILE.write_text( json.dumps({"gemini_api_key": key, "os_system": os_name}, indent=4), encoding="utf-8", ) self._ready = True if self._overlay: self._overlay.hide() self._overlay = None self._apply_state("LISTENING") self._assistant_name = _read_full_config().get("assistant_name", "JARVIS") or "JARVIS" self._log.append_log(f"SYS: Initialised. OS={os_name.upper()}. {self._assistant_name} online.") class _RootShim: def __init__(self, app: QApplication): self._app = app def mainloop(self): self._app.exec() def protocol(self, *_): pass class JarvisUI: def __init__(self, face_path: str, size=None): self._app = QApplication.instance() or QApplication(sys.argv) self._app.setStyle("Fusion") self._win = MainWindow(face_path) self.root = _RootShim(self._app) self._win.show() @property def muted(self) -> bool: return self._win._muted @muted.setter def muted(self, v: bool): if v != self._win._muted: self._win._toggle_mute() @property def current_file(self) -> str | None: return self._win._drop_zone.current_file() @property def on_text_command(self): return self._win.on_text_command @on_text_command.setter def on_text_command(self, cb): self._win.on_text_command = cb @property def on_remote_clicked(self): return self._win.on_remote_clicked @on_remote_clicked.setter def on_remote_clicked(self, cb): self._win.on_remote_clicked = cb @property def on_interrupt(self): return self._win.on_interrupt @on_interrupt.setter def on_interrupt(self, cb): self._win.on_interrupt = cb @property def on_voice_change(self): return self._win.on_voice_change @on_voice_change.setter def on_voice_change(self, cb): self._win.on_voice_change = cb @property def on_audio_device_change(self): return self._win.on_audio_device_change @on_audio_device_change.setter def on_audio_device_change(self, cb): self._win.on_audio_device_change = cb def show_confirm(self, title: str, detail: str) -> None: """Thread-safe: raise the irreversible-action gate. Called from action handlers running in executor threads, so it goes through a signal.""" self._win._confirm_sig.emit(str(title)[:120], str(detail)[:300]) def hide_confirm(self) -> None: """Thread-safe: take the gate down.""" self._win._confirm_hide_sig.emit() @property def get_plugins(self): return self._win.get_plugins @get_plugins.setter def get_plugins(self, cb): self._win.get_plugins = cb @property def get_plugin_settings(self): return self._win.get_plugin_settings @get_plugin_settings.setter def get_plugin_settings(self, cb): self._win.get_plugin_settings = cb @property def on_wake_toggle(self): return self._win.on_wake_toggle @on_wake_toggle.setter def on_wake_toggle(self, cb): self._win.on_wake_toggle = cb @property def on_wake_manual(self): return self._win.on_wake_manual @on_wake_manual.setter def on_wake_manual(self, cb): self._win.on_wake_manual = cb @property def wake_get_state(self): return self._win.wake_get_state @wake_get_state.setter def wake_get_state(self, cb): self._win.wake_get_state = cb def set_audio_level(self, level: float) -> None: """Thread-safe: feed a 0.0–1.0 live audio level to the HUD waveform. Called from the audio threads; a plain float store is atomic under the GIL, so no signal/lock is needed for this cosmetic value.""" try: self._win.hud.set_audio_level(level) if self._win.avatar3d is not None: self._win.avatar3d.set_audio_level(level) except Exception: pass def glance(self, dx: float, dy: float, hold: float = 1.1) -> None: """Ask the avatar to look somewhere for a moment (see HoloAvatar.glance).""" try: if self._avatar is not None: self._avatar.glance(dx, dy, hold) except Exception: pass @property def ptt_hold(self): return self._win.ptt_hold @ptt_hold.setter def ptt_hold(self, cb): self._win.ptt_hold = cb @property def on_push_to_talk(self): return self._win.on_push_to_talk @on_push_to_talk.setter def on_push_to_talk(self, cb): self._win.on_push_to_talk = cb def push_visemes(self, frames, hop: float, at: float) -> None: """Thread-safe: post a schedule of (level, openness, width) mouth frames for JARVIS's own speech. `at` is the wall-clock time the batch begins to sound, not the time of the call. See HudCanvas.push_visemes().""" try: self._win.hud.push_visemes(frames, hop, at) if self._win.avatar3d is not None: self._win.avatar3d.push_visemes(frames, hop, at) except Exception: pass def notify_phone_connected(self) -> None: self._win.notify_phone_connected() def set_state(self, state: str): self._win._state_sig.emit(state) def write_log(self, text: str): self._win._log_sig.emit(text) def wait_for_api_key(self): while not self._win._ready: time.sleep(0.1) def show_content(self, title: str, text: str): """Thread-safe: display content in the panel below the HUD.""" self._win._content_sig.emit(title[:48], text[:4000]) def show_quiz(self, topic: str, questions, grade=None) -> None: """Thread-safe: put an interactive quiz on the board. `grade(question, given)` decides each answer — the plugin supplies it so the marking rules live with the questions rather than being duplicated here. Returning None from it means "JARVIS should judge this one", which is how open answers and near-miss gap-fills are handled. Returns immediately: the user answers at their own pace and the finished result is delivered back through on_text_command. """ self._win._quiz_sig.emit(str(topic or ""), list(questions or []), grade) def hide_quiz(self) -> None: """Thread-safe: clear any quiz currently on the board.""" self._win._quiz_hide_sig.emit() def show_review(self, title: str, summary: str, findings, unclear=None) -> None: """Thread-safe: lay a document review into the panel below the HUD. `findings` is a list of {heading, detail, severity, quote, suggestion}; severity is one of 'serious' / 'caution' / 'note' and decides colour and order here, so the caller supplies no styling of its own. """ self._win._review_sig.emit(str(title or ""), str(summary or ""), list(findings or []), list(unclear or [])) def prompt_reconfig(self): """Thread-safe: show the API key setup overlay (e.g. after an auth error).""" self._win._ready = False self._win._reconfig_sig.emit() def show_camera_frame(self, img_bytes: bytes): """Thread-safe: show a webcam frame in the small overlay (screen captures).""" self._win._camera_sig.emit(img_bytes) def start_camera_stream(self) -> None: """Thread-safe: start live camera feed in the full HUD area.""" self._win.start_camera_stream() def stop_camera_stream(self) -> None: """Thread-safe: stop the live camera feed.""" self._win.stop_camera_stream() @property def assistant_name(self) -> str: return self._win._assistant_name def start_speaking(self): self.set_state("SPEAKING") def stop_speaking(self): if not self.muted: self.set_state("LISTENING")