2467 lines
95 KiB
Python
2467 lines
95 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, QMimeData, QObject, QPointF, QRectF, QSize, Qt,
|
||
QTimer, QUrl, pyqtSignal,
|
||
)
|
||
from PyQt6.QtGui import (
|
||
QBrush, QColor, QDragEnterEvent, QDropEvent, QFont, QFontDatabase,
|
||
QKeySequence, QLinearGradient, QPainter, QPainterPath, QPen, QPixmap,
|
||
QRadialGradient, QShortcut,
|
||
)
|
||
from PyQt6.QtWidgets import (
|
||
QApplication, QFileDialog, QFrame, QHBoxLayout, QLabel, QLineEdit,
|
||
QMainWindow, QPushButton, QScrollArea, QSizePolicy, QSplitter,
|
||
QStackedWidget, QTextEdit, QVBoxLayout, QWidget, QProgressBar,
|
||
)
|
||
|
||
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"
|
||
|
||
_DEFAULT_W, _DEFAULT_H = 980, 700
|
||
_MIN_W, _MIN_H = 820, 580
|
||
_LEFT_W = 148
|
||
_RIGHT_W = 340
|
||
|
||
_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 = "#007a99"
|
||
PRI_GHO = "#001f2e"
|
||
ACC = "#ff6b00"
|
||
ACC2 = "#ffcc00"
|
||
GREEN = "#00ff88"
|
||
GREEN_D = "#00aa55"
|
||
RED = "#ff3355"
|
||
MUTED_C = "#ff3366"
|
||
TEXT = "#8ffcff"
|
||
TEXT_DIM = "#3a8a9a"
|
||
TEXT_MED = "#5ab8cc"
|
||
WHITE = "#d8f8ff"
|
||
DARK = "#000d14"
|
||
BAR_BG = "#011520"
|
||
|
||
|
||
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
|
||
t = threading.Thread(target=self._loop, daemon=True)
|
||
t.start()
|
||
|
||
def _loop(self):
|
||
while self._running:
|
||
try:
|
||
self._update()
|
||
except Exception:
|
||
pass
|
||
time.sleep(1.5)
|
||
|
||
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 = self._get_gpu()
|
||
|
||
tmp = self._get_temp()
|
||
|
||
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, works on all platforms if installed
|
||
try:
|
||
import pynvml # type: ignore
|
||
pynvml.nvmlInit()
|
||
h = pynvml.nvmlDeviceGetHandleByIndex(0)
|
||
return float(pynvml.nvmlDeviceGetUtilizationRates(h).gpu)
|
||
except Exception:
|
||
pass
|
||
|
||
# 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
|
||
try:
|
||
import ctypes
|
||
_lib = "libnvidia-ml.so.1" if _OS == "Linux" else "libnvidia-ml.dylib"
|
||
|
||
class _Util(ctypes.Structure):
|
||
_fields_ = [("gpu", ctypes.c_uint), ("memory", ctypes.c_uint)]
|
||
|
||
nv = ctypes.CDLL(_lib)
|
||
nv.nvmlInit_v2()
|
||
dev = ctypes.c_void_p()
|
||
nv.nvmlDeviceGetHandleByIndex_v2(0, ctypes.byref(dev))
|
||
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)
|
||
if _OS == "Windows":
|
||
try:
|
||
import wmi # type: ignore
|
||
w = wmi.WMI(namespace="root/wmi")
|
||
tz = w.MSAcpi_ThermalZoneTemperature()
|
||
if tz:
|
||
return (tz[0].CurrentTemperature / 10.0) - 273.15
|
||
except Exception:
|
||
pass
|
||
|
||
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, 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._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._scan = 0.0
|
||
self._scan2 = 180.0
|
||
self._rings = [0.0, 120.0, 240.0]
|
||
self._pulses: list[float] = [0.0, 50.0, 100.0]
|
||
self._blink = True
|
||
self._blink_tick = 0
|
||
self._particles: list[list[float]] = []
|
||
self._face_px: QPixmap | None = None
|
||
self._load_face(face_path)
|
||
|
||
self._tmr = QTimer(self)
|
||
self._tmr.timeout.connect(self._step)
|
||
self._tmr.start(16)
|
||
|
||
def _load_face(self, path: str):
|
||
try:
|
||
from PIL import Image, ImageDraw
|
||
import io
|
||
img = Image.open(path).convert("RGBA")
|
||
sz = min(img.size)
|
||
img = img.resize((sz, sz), Image.LANCZOS)
|
||
mk = Image.new("L", (sz, sz), 0)
|
||
ImageDraw.Draw(mk).ellipse((2, 2, sz - 2, sz - 2), fill=255)
|
||
img.putalpha(mk)
|
||
buf = io.BytesIO()
|
||
img.save(buf, format="PNG")
|
||
px = QPixmap(); px.loadFromData(buf.getvalue())
|
||
self._face_px = px
|
||
except Exception:
|
||
self._face_px = None
|
||
|
||
def _step(self):
|
||
self._tick += 1
|
||
now = time.time()
|
||
if now - self._last_t > (0.12 if self.speaking else 0.5):
|
||
if self.speaking:
|
||
self._tgt_scale = random.uniform(1.06, 1.14)
|
||
self._tgt_halo = random.uniform(145, 190)
|
||
elif self.muted:
|
||
self._tgt_scale = random.uniform(0.998, 1.002)
|
||
self._tgt_halo = random.uniform(15, 28)
|
||
else:
|
||
self._tgt_scale = random.uniform(1.001, 1.008)
|
||
self._tgt_halo = random.uniform(48, 68)
|
||
self._last_t = now
|
||
|
||
sp = 0.38 if self.speaking else 0.15
|
||
self._scale += (self._tgt_scale - self._scale) * sp
|
||
self._halo += (self._tgt_halo - self._halo) * sp
|
||
|
||
speeds = [1.3, -0.9, 2.0] if self.speaking else [0.55, -0.35, 0.9]
|
||
for i, spd in enumerate(speeds):
|
||
self._rings[i] = (self._rings[i] + spd) % 360
|
||
|
||
self._scan = (self._scan + (3.0 if self.speaking else 1.3)) % 360
|
||
self._scan2 = (self._scan2 + (-2.0 if self.speaking else -0.75)) % 360
|
||
|
||
fw = min(self.width(), self.height())
|
||
lim = fw * 0.74
|
||
spd = 4.2 if self.speaking else 2.0
|
||
self._pulses = [r + spd for r in self._pulses if r + spd < lim]
|
||
if len(self._pulses) < 3 and random.random() < (0.07 if self.speaking else 0.025):
|
||
self._pulses.append(0.0)
|
||
|
||
if self.speaking and random.random() < 0.28:
|
||
cx, cy = self.width() / 2, self.height() / 2
|
||
ang = random.uniform(0, 2 * math.pi)
|
||
r_s = fw * 0.28
|
||
self._particles.append([
|
||
cx + math.cos(ang) * r_s, cy + math.sin(ang) * r_s,
|
||
math.cos(ang) * random.uniform(0.9, 2.4),
|
||
math.sin(ang) * random.uniform(0.9, 2.4) - 0.4, 1.0,
|
||
])
|
||
self._particles = [
|
||
[p[0]+p[2], p[1]+p[3], p[2]*0.97, p[3]*0.97, p[4]-0.028]
|
||
for p in self._particles if p[4] > 0
|
||
]
|
||
|
||
self._blink_tick += 1
|
||
if self._blink_tick >= 38:
|
||
self._blink = not self._blink
|
||
self._blink_tick = 0
|
||
self.update()
|
||
|
||
def paintEvent(self, _):
|
||
p = QPainter(self)
|
||
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
|
||
p.setPen(QPen(qcol(C.PRI_GHO), 1))
|
||
for x in range(0, W, 48):
|
||
for y in range(0, H, 48):
|
||
p.drawPoint(x, y)
|
||
|
||
r_face = fw * 0.31
|
||
|
||
# halo glow
|
||
for i in range(10):
|
||
r = r_face * (1.8 - i * 0.08)
|
||
frc = 1.0 - i / 10
|
||
a = max(0, min(255, int(self._halo * 0.085 * frc)))
|
||
col = qcol(C.MUTED_C if self.muted else C.PRI, a)
|
||
p.setPen(QPen(col, 1.5)); p.setBrush(Qt.BrushStyle.NoBrush)
|
||
p.drawEllipse(QRectF(cx - r, cy - r, r * 2, r * 2))
|
||
|
||
# pulse rings
|
||
for pr in self._pulses:
|
||
a = max(0, int(230 * (1.0 - pr / (fw * 0.74))))
|
||
col = qcol(C.MUTED_C if self.muted else C.PRI, a)
|
||
p.setPen(QPen(col, 1.5)); p.setBrush(Qt.BrushStyle.NoBrush)
|
||
p.drawEllipse(QRectF(cx - pr, cy - pr, pr * 2, pr * 2))
|
||
|
||
# spinning arc rings
|
||
for idx, (r_frac, w_r, arc_l, gap) in enumerate(
|
||
[(0.48, 3, 115, 78), (0.40, 2, 78, 55), (0.32, 1, 56, 40)]
|
||
):
|
||
ring_r = fw * r_frac
|
||
base = self._rings[idx]
|
||
a_val = max(0, min(255, int(self._halo * (1.0 - idx * 0.18))))
|
||
col = qcol(C.MUTED_C if self.muted else C.PRI, a_val)
|
||
p.setPen(QPen(col, w_r)); p.setBrush(Qt.BrushStyle.NoBrush)
|
||
angle = base
|
||
rect = QRectF(cx - ring_r, cy - ring_r, ring_r * 2, ring_r * 2)
|
||
while angle < base + 360:
|
||
p.drawArc(rect, int(angle * 16), int(arc_l * 16))
|
||
angle += arc_l + gap
|
||
|
||
# scanners
|
||
sr = fw * 0.50
|
||
sa = min(255, int(self._halo * 1.5))
|
||
ex = 75 if self.speaking else 44
|
||
p.setPen(QPen(qcol(C.MUTED_C if self.muted else C.PRI, sa), 2.5))
|
||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||
srect = QRectF(cx - sr, cy - sr, sr * 2, sr * 2)
|
||
p.drawArc(srect, int(self._scan * 16), int(ex * 16))
|
||
p.setPen(QPen(qcol(C.ACC, sa // 2), 1.5))
|
||
p.drawArc(srect, int(self._scan2 * 16), int(ex * 16))
|
||
|
||
# tick marks
|
||
t_out, t_in = fw * 0.497, fw * 0.474
|
||
p.setPen(QPen(qcol(C.PRI, 140), 1))
|
||
for deg in range(0, 360, 10):
|
||
rad = math.radians(deg)
|
||
inn = t_in if deg % 30 == 0 else t_in + 6
|
||
p.drawLine(
|
||
QPointF(cx + t_out * math.cos(rad), cy - t_out * math.sin(rad)),
|
||
QPointF(cx + inn * math.cos(rad), cy - inn * math.sin(rad)),
|
||
)
|
||
|
||
# crosshair
|
||
ch_r, gap_h = fw * 0.51, fw * 0.16
|
||
p.setPen(QPen(qcol(C.PRI, int(self._halo * 0.5)), 1))
|
||
p.drawLine(QPointF(cx - ch_r, cy), QPointF(cx - gap_h, cy))
|
||
p.drawLine(QPointF(cx + gap_h, cy), QPointF(cx + ch_r, cy))
|
||
p.drawLine(QPointF(cx, cy - ch_r), QPointF(cx, cy - gap_h))
|
||
p.drawLine(QPointF(cx, cy + gap_h), QPointF(cx, cy + ch_r))
|
||
|
||
# corner brackets
|
||
bl = 24
|
||
bc = qcol(C.PRI, 210)
|
||
hl, hr = cx - fw // 2, cx + fw // 2
|
||
ht, hb = cy - fw // 2, cy + fw // 2
|
||
p.setPen(QPen(bc, 2))
|
||
for bx, by, dx, dy in [(hl,ht,1,1),(hr,ht,-1,1),(hl,hb,1,-1),(hr,hb,-1,-1)]:
|
||
p.drawLine(QPointF(bx, by), QPointF(bx + dx * bl, by))
|
||
p.drawLine(QPointF(bx, by), QPointF(bx, by + dy * bl))
|
||
|
||
# face
|
||
if self._face_px:
|
||
fsz = int(fw * 0.62 * self._scale)
|
||
scaled = self._face_px.scaled(
|
||
fsz, fsz,
|
||
Qt.AspectRatioMode.KeepAspectRatio,
|
||
Qt.TransformationMode.SmoothTransformation,
|
||
)
|
||
p.drawPixmap(int(cx - fsz / 2), int(cy - fsz / 2), scaled)
|
||
else:
|
||
orb_r = int(fw * 0.27 * self._scale)
|
||
oc = (200, 0, 50) if self.muted else (0, 60, 110)
|
||
for i in range(8, 0, -1):
|
||
r2 = int(orb_r * i / 8)
|
||
frc = i / 8
|
||
a = max(0, min(255, int(self._halo * 1.1 * frc)))
|
||
p.setBrush(QBrush(QColor(int(oc[0]*frc), int(oc[1]*frc), int(oc[2]*frc), a)))
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.drawEllipse(QRectF(cx - r2, cy - r2, r2 * 2, r2 * 2))
|
||
p.setPen(QPen(qcol(C.PRI, min(255, int(self._halo * 2))), 1))
|
||
p.setFont(QFont("Courier New", 13, QFont.Weight.Bold))
|
||
p.drawText(QRectF(cx - 80, cy - 14, 160, 28),
|
||
Qt.AlignmentFlag.AlignCenter, "J.A.R.V.I.S")
|
||
|
||
# particles
|
||
for pt in self._particles:
|
||
a = max(0, min(255, int(pt[4] * 255)))
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.setBrush(QBrush(qcol(C.PRI, a)))
|
||
p.drawEllipse(QPointF(pt[0], pt[1]), 2.5, 2.5)
|
||
|
||
# status text
|
||
sy = cy + fw * 0.40
|
||
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("Courier New", 11, QFont.Weight.Bold))
|
||
p.drawText(QRectF(0, sy, W, 26), Qt.AlignmentFlag.AlignCenter, txt)
|
||
|
||
# waveform
|
||
wy = sy + 30
|
||
N, bw = 36, 8
|
||
wx0 = (W - N * bw) / 2
|
||
for i in range(N):
|
||
if self.muted:
|
||
hgt, cl = 2, qcol(C.MUTED_C)
|
||
elif self.speaking:
|
||
hgt = random.randint(3, 20)
|
||
cl = qcol(C.PRI) if hgt > 12 else qcol(C.PRI_DIM)
|
||
else:
|
||
hgt = int(3 + 2 * math.sin(self._tick * 0.09 + i * 0.6))
|
||
cl = qcol(C.BORDER_B)
|
||
p.fillRect(QRectF(wx0 + i * bw, wy + 20 - hgt, bw - 1, hgt), cl)
|
||
|
||
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):
|
||
self._value = max(0.0, min(100.0, pct))
|
||
self._text = text
|
||
self.update()
|
||
|
||
def paintEvent(self, _):
|
||
p = QPainter(self)
|
||
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("Courier New", 7, 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("Courier New", 9, 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)
|
||
|
||
class LogWidget(QTextEdit):
|
||
_sig = pyqtSignal(str)
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setReadOnly(True)
|
||
self.setFont(QFont("Courier New", 9))
|
||
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._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()
|
||
if tl.startswith("you:"): self._tag = "you"
|
||
elif 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": qcol(C.ACC2),
|
||
}.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):
|
||
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, "Select a file for JARVIS", 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)
|
||
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)
|
||
|
||
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("Courier New", 8))
|
||
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("Courier New", 7))
|
||
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("Courier New", 20))
|
||
p.setPen(QPen(qcol(C.PRI), 1))
|
||
p.drawText(QRectF(0, cy - 24, W, 32), Qt.AlignmentFlag.AlignCenter, "⬇")
|
||
p.setFont(QFont("Courier New", 8, 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("Courier New", 8, 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("Courier New", 7))
|
||
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("Courier New", 6))
|
||
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("Courier New", 9, 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("Courier New", 7, 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("Courier New", 8))
|
||
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=9, bold=False, color=C.PRI,
|
||
align=Qt.AlignmentFlag.AlignCenter):
|
||
w = QLabel(txt)
|
||
w.setAlignment(align)
|
||
w.setFont(QFont("Courier New", 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", 13, True))
|
||
layout.addWidget(_lbl("Configure J.A.R.V.I.S. before first boot.", 9, 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", 8, 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("Courier New", 10))
|
||
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", 8, 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("Courier New", 9, 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("Courier New", 10, 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 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("Courier New", 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", 12, 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", 8, 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:", 7, color=C.TEXT_DIM,
|
||
align=Qt.AlignmentFlag.AlignLeft))
|
||
|
||
self._url_lbl = QLabel(self._manual_url)
|
||
self._url_lbl.setFont(QFont("Courier New", 8))
|
||
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("Courier New", 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("Courier New", 8))
|
||
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("Courier New", 8, 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("Courier New", 8, 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("Courier New", 8))
|
||
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("Courier New", 7))
|
||
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("Courier New", 54, QFont.Weight.Bold))
|
||
self._qr_label.setStyleSheet(
|
||
"color: #00ff88; background: #001a0d; border-radius: 10px;"
|
||
)
|
||
self._timer_lbl.setText("Phone connected — JARVIS ready")
|
||
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
|
||
|
||
def __init__(self, face_path: str):
|
||
super().__init__()
|
||
self._face_path = face_path
|
||
self.setWindowTitle("J.A.R.V.I.S — MARK XLVIII")
|
||
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._muted = False
|
||
self._current_file: str | None = None
|
||
self._remote_overlay: RemoteKeyOverlay | 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)
|
||
self.hud.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||
self._content_panel = self._build_content_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("Courier New", 8, 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("Courier New", 8, 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._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.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())
|
||
|
||
self._clock_tmr = QTimer(self)
|
||
self._clock_tmr.timeout.connect(self._tick_clock)
|
||
self._clock_tmr.start(1000)
|
||
self._tick_clock()
|
||
|
||
# Metrik güncelleme 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._cam_stream_sig.connect(self._on_cam_stream)
|
||
self._cam_frame_sig.connect(self._on_cam_frame)
|
||
self._cam_stop = threading.Event()
|
||
|
||
# Camera preview overlay (child of central widget, positioned in resizeEvent)
|
||
self._cam_preview = _CameraPreview(self.centralWidget())
|
||
|
||
self._overlay: SetupOverlay | None = None
|
||
self._ready = self._check_config()
|
||
if not self._ready:
|
||
self._show_setup()
|
||
|
||
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._hud_cam_stack.setCurrentIndex(0)
|
||
self._cam_live_lbl.clear()
|
||
|
||
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 = "J.A.R.V.I.S AI Assistant"
|
||
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 = "J.A.R.V.I.S AI Assistant"',
|
||
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
|
||
|
||
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 = Path.home() / "Desktop"
|
||
|
||
# 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 / "J.A.R.V.I.S.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 / "J.A.R.V.I.S.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 / "JARVIS"
|
||
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>JARVIS</string>\n'
|
||
' <key>CFBundleIdentifier</key>'
|
||
'<string>com.jarvis.assistant</string>\n'
|
||
' <key>CFBundleName</key><string>J.A.R.V.I.S</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 / "J.A.R.V.I.S.desktop"
|
||
desk.write_text(
|
||
"[Desktop Entry]\n"
|
||
"Name=J.A.R.V.I.S\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,
|
||
)
|
||
# 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,
|
||
)
|
||
|
||
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("Courier New", 8))
|
||
l.setStyleSheet(f"color: {color}; background: transparent;")
|
||
return l
|
||
|
||
lay.addWidget(_badge("MARK XLVIII", C.PRI_DIM))
|
||
lay.addStretch()
|
||
|
||
mid = QVBoxLayout(); mid.setSpacing(1)
|
||
title = QLabel("J.A.R.V.I.S")
|
||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
title.setFont(QFont("Courier New", 17, QFont.Weight.Bold))
|
||
title.setStyleSheet(f"color: {C.PRI}; background: transparent;")
|
||
mid.addWidget(title)
|
||
sub = QLabel("Just A Rather Very Intelligent System")
|
||
sub.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
sub.setFont(QFont("Courier New", 7))
|
||
sub.setStyleSheet(f"color: {C.PRI_DIM}; background: transparent;")
|
||
mid.addWidget(sub)
|
||
lay.addLayout(mid)
|
||
lay.addStretch()
|
||
|
||
right_col = QVBoxLayout(); right_col.setSpacing(2)
|
||
self._clock_lbl = QLabel("00:00:00")
|
||
self._clock_lbl.setFont(QFont("Courier New", 14, 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("Courier New", 7))
|
||
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("Courier New", 7, 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("Courier New", 8, 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("Courier New", 8))
|
||
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("Courier New", 8))
|
||
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\nXLVIII", C.TEXT_DIM),
|
||
]:
|
||
lbl = QLabel(txt)
|
||
lbl.setFont(QFont("Courier New", 7, 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("Courier New", 7, 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("Courier New", 7))
|
||
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("Courier New", 8, 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("Courier New", 8, 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)
|
||
|
||
remote_btn = QPushButton("◉ REMOTE CONTROL")
|
||
remote_btn.setFixedHeight(30)
|
||
remote_btn.setFont(QFont("Courier New", 8, QFont.Weight.Bold))
|
||
remote_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
remote_btn.setStyleSheet(f"""
|
||
QPushButton {{
|
||
background: #00091a; color: {C.PRI};
|
||
border: 1px solid {C.PRI_DIM}; border-radius: 3px;
|
||
}}
|
||
QPushButton:hover {{
|
||
background: {C.PRI_GHO}; border: 1px solid {C.PRI};
|
||
}}
|
||
""")
|
||
remote_btn.clicked.connect(self._open_remote)
|
||
lay.addWidget(remote_btn)
|
||
|
||
fs_btn = QPushButton("⛶ FULLSCREEN [F11]")
|
||
fs_btn.setFixedHeight(26)
|
||
fs_btn.setFont(QFont("Courier New", 7))
|
||
fs_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
fs_btn.setStyleSheet(f"""
|
||
QPushButton {{
|
||
background: transparent; color: {C.TEXT_MED};
|
||
border: 1px solid {C.BORDER}; border-radius: 3px;
|
||
}}
|
||
QPushButton:hover {{
|
||
color: {C.PRI}; border: 1px solid {C.BORDER_B};
|
||
}}
|
||
""")
|
||
fs_btn.clicked.connect(self._toggle_fullscreen)
|
||
lay.addWidget(fs_btn)
|
||
|
||
sc_btn = QPushButton("⊞ CREATE DESKTOP SHORTCUT")
|
||
sc_btn.setFixedHeight(26)
|
||
sc_btn.setFont(QFont("Courier New", 7))
|
||
sc_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
sc_btn.setStyleSheet(f"""
|
||
QPushButton {{
|
||
background: transparent; color: {C.TEXT_DIM};
|
||
border: 1px solid {C.BORDER}; border-radius: 3px;
|
||
}}
|
||
QPushButton:hover {{
|
||
color: {C.ACC2}; border: 1px solid {C.BORDER_B};
|
||
}}
|
||
""")
|
||
sc_btn.clicked.connect(self._create_desktop_shortcut)
|
||
lay.addWidget(sc_btn)
|
||
|
||
return w
|
||
|
||
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("Courier New", 9))
|
||
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("Courier New", 11, 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("Courier New", 9, 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("Courier New", 8, 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("Courier New", 7))
|
||
self._content_ts_lbl.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;")
|
||
hdr.addWidget(self._content_ts_lbl)
|
||
|
||
dismiss = QPushButton("DISMISS ✕")
|
||
dismiss.setFont(QFont("Courier New", 7))
|
||
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("Courier New", 8))
|
||
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
|
||
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])
|
||
|
||
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("Courier New", 7))
|
||
l.setStyleSheet(f"color: {color}; background: transparent;")
|
||
return l
|
||
|
||
lay.addWidget(_fl("[F4] Mute · [F11] Fullscreen"))
|
||
lay.addStretch()
|
||
lay.addWidget(_fl("FatihMakes Industries · MARK XLVIII · CLASSIFIED"))
|
||
lay.addStretch()
|
||
lay.addWidget(_fl("© STARK INDUSTRIES", 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 JARVIS 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}")
|
||
|
||
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")
|
||
|
||
def _check_config(self) -> bool:
|
||
if not API_FILE.exists(): return False
|
||
try:
|
||
d = json.loads(API_FILE.read_text(encoding="utf-8"))
|
||
return bool(d.get("gemini_api_key")) and bool(d.get("os_system"))
|
||
except Exception:
|
||
return False
|
||
|
||
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._log.append_log(f"SYS: Initialised. OS={os_name.upper()}. JARVIS 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._win.show()
|
||
self.root = _RootShim(self._app)
|
||
|
||
@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
|
||
|
||
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 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()
|
||
|
||
def start_speaking(self):
|
||
self.set_state("SPEAKING")
|
||
|
||
def stop_speaking(self):
|
||
if not self.muted:
|
||
self.set_state("LISTENING") |