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