3481 lines
138 KiB
Python
3481 lines
138 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, QConicalGradient, 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"
|
||
|
||
|
||
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 {}
|
||
|
||
|
||
_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"
|
||
|
||
|
||
# Ana renge (accent) bağlı anahtarlar — durum renkleri (ACC, GREEN, RED…) sabit kalır
|
||
_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:
|
||
"""
|
||
Seçilen accent rengine göre tüm turkuaz-ailesi paleti yeniden türetir
|
||
(hue kaydırma — parlaklık/doygunluk oranları korunur, tasarım bozulmaz).
|
||
Boyanan öğeler (HUD, dalga formu, metrikler) bir sonraki karede yeni
|
||
rengi alır; stylesheet tabanlı paneller yeniden kurulduklarında alır.
|
||
"""
|
||
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 # griye yakın accent → tüm tema desaturize edilir
|
||
|
||
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]:
|
||
"""C sınıfındaki accent'e bağlı renklerin anlık kopyası."""
|
||
return {k: getattr(C, k) for k in _HUE_LINKED}
|
||
|
||
|
||
def retheme_all_widgets(old: dict[str, str], new: dict[str, str]) -> None:
|
||
"""
|
||
CANLI tam tema değişimi. Uygulamadaki HER widget'ın stylesheet'inde eski
|
||
palet renklerini yenileriyle değiştirir ve yeniden çizdirir. Böylece renk
|
||
değişimi yalnızca boyanan öğelerde değil, panel/buton/kenarlık dahil tüm
|
||
arayüzde ANINDA uygulanır — yeniden başlatma gerekmez.
|
||
"""
|
||
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
|
||
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, 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
|
||
|
||
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, self._assistant_name)
|
||
|
||
# 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._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": 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 HueWheel(QWidget):
|
||
"""
|
||
Dairesel renk seçici. Kullanıcı tutamacı (küçük beyaz daire) çarkın
|
||
çevresinde sürükleyerek TÜM renk tonları arasından seçim yapar.
|
||
Merkezdeki dolu daire seçilen rengin canlı önizlemesidir.
|
||
"""
|
||
|
||
hue_picked = pyqtSignal(str) # sürükleme sırasında (canlı)
|
||
hue_committed = pyqtSignal(str) # tutamaç bırakıldığında
|
||
|
||
_RING = 16 # halka kalınlığı (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()
|
||
|
||
# ── geometri yardımcıları ────────────────────────────────────────────────
|
||
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() # ekran y'si aşağı — matematiksel eksene çevir
|
||
ang = math.atan2(dy, dx) # [-π, π], saat yönünün tersi
|
||
return (ang / (2 * math.pi)) % 1.0
|
||
|
||
# ── çizim ────────────────────────────────────────────────────────────────
|
||
def paintEvent(self, _):
|
||
p = QPainter(self)
|
||
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)
|
||
|
||
# merkez önizleme dairesi
|
||
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)
|
||
|
||
# sürüklenen tutamaç
|
||
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)
|
||
|
||
# ── 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 and UI colour."""
|
||
|
||
saved = pyqtSignal(str, str, str) # assistant_name, user_name, ui_color
|
||
_OW, _OH = 400, 500
|
||
|
||
def __init__(self, assistant_name="JARVIS", user_name="",
|
||
ui_color=DEFAULT_UI_COLOR, 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("Courier New", 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", 12, 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", 8, color=C.TEXT_DIM,
|
||
align=Qt.AlignmentFlag.AlignLeft))
|
||
self._name_input = QLineEdit(assistant_name)
|
||
self._name_input.setFont(QFont("Courier New", 10))
|
||
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)", 8,
|
||
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("Courier New", 10))
|
||
self._user_input.setFixedHeight(32)
|
||
self._user_input.setStyleSheet(_fs)
|
||
lay.addWidget(self._user_input)
|
||
|
||
# ── UI colour — renk çarkı ───────────────────────────────────────────
|
||
lay.addSpacing(4)
|
||
clr_hdr = QHBoxLayout()
|
||
clr_hdr.addWidget(_lbl("UI COLOUR — drag the handle", 8,
|
||
color=C.TEXT_DIM, align=Qt.AlignmentFlag.AlignLeft))
|
||
clr_hdr.addStretch()
|
||
df_btn = QPushButton("DEFAULT")
|
||
df_btn.setFixedSize(64, 20)
|
||
df_btn.setFont(QFont("Courier New", 7, 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) — canlı önizleme; MainWindow bağlar
|
||
|
||
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("Courier New", 10))
|
||
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("Courier New", 9, 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("Courier New", 9))
|
||
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)
|
||
|
||
# ── renk akışı ───────────────────────────────────────────────────────────
|
||
def _set_color(self, hx: str, update_wheel: bool = True, preview: bool = True):
|
||
"""Seçili rengi günceller; hex kutusu + çark senkron kalır, tema canlı önizlenir."""
|
||
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):
|
||
# Sürükleme sırasında: hex kutusunu güncelle, temayı henüz uygulama
|
||
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):
|
||
# Tutamaç bırakıldı → tüm arayüzü canlı önizle
|
||
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):
|
||
# Önizleme uygulandıysa açılıştaki renge geri dön
|
||
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 "JARVIS"
|
||
user = self._user_input.text().strip()
|
||
self.saved.emit(name, user, self._sel_color or DEFAULT_UI_COLOR)
|
||
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("Courier New", 12, 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("Courier New", 8))
|
||
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("Courier New", 9))
|
||
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("Courier New", 8))
|
||
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("Courier New", 7, 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 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("Courier New", 7, 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("Courier New", 8))
|
||
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("Courier New", 8))
|
||
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("Courier New", 7, 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 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
|
||
_clipboard_sig = pyqtSignal(str) # clipboard text changed (thread-safe)
|
||
|
||
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 "JARVIS").strip()
|
||
_display = self._assistant_name.upper()
|
||
|
||
# Kayıtlı UI rengini panel/stylesheet'ler kurulmadan ÖNCE uygula
|
||
_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(f"{_display} — MARK LI")
|
||
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.get_plugins = None # callable: () -> list[dict], set by JarvisLive
|
||
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()
|
||
|
||
# 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())
|
||
|
||
# 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()
|
||
|
||
# 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._clipboard_sig.connect(self._show_clipboard_panel)
|
||
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()
|
||
|
||
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
|
||
|
||
@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 / "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,
|
||
)
|
||
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("Courier New", 8))
|
||
l.setStyleSheet(f"color: {color}; background: transparent;")
|
||
return l
|
||
|
||
lay.addWidget(_badge("MARK LI", C.PRI_DIM))
|
||
lay.addSpacing(8)
|
||
self._drawer_btn = QPushButton("⚙")
|
||
self._drawer_btn.setFixedSize(26, 26)
|
||
self._drawer_btn.setFont(QFont("Courier New", 11))
|
||
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("Courier New", 17, QFont.Weight.Bold))
|
||
self._title_lbl.setStyleSheet(f"color: {C.PRI}; background: transparent;")
|
||
mid.addWidget(self._title_lbl)
|
||
_sub_text = ("Just A Rather Very Intelligent System"
|
||
if _disp in ("JARVIS", "J.A.R.V.I.S")
|
||
else "Personal AI Assistant")
|
||
self._sub_lbl = QLabel(_sub_text)
|
||
self._sub_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self._sub_lbl.setFont(QFont("Courier New", 7))
|
||
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("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\nXLIX", 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)
|
||
|
||
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("Courier New", 7, 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("Courier New", 8, 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(26)
|
||
fs_btn.setFont(QFont("Courier New", 7))
|
||
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(26)
|
||
sc_btn.setFont(QFont("Courier New", 7))
|
||
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(26)
|
||
self._autostart_btn.setFont(QFont("Courier New", 7))
|
||
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(26)
|
||
cust_btn.setFont(QFont("Courier New", 7))
|
||
cust_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
cust_btn.setStyleSheet(_BTN_STYLE_DIM)
|
||
cust_btn.clicked.connect(self._open_customize)
|
||
lay.addWidget(cust_btn)
|
||
|
||
self._brief_btn = QPushButton()
|
||
self._brief_btn.setFixedHeight(26)
|
||
self._brief_btn.setFont(QFont("Courier New", 7))
|
||
self._brief_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self._brief_btn.clicked.connect(self._toggle_brief)
|
||
lay.addWidget(self._brief_btn)
|
||
|
||
plugin_btn = QPushButton("🧩 PLUGINS")
|
||
plugin_btn.setFixedHeight(26)
|
||
plugin_btn.setFont(QFont("Courier New", 7))
|
||
plugin_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
plugin_btn.setStyleSheet(_BTN_STYLE_DIM)
|
||
plugin_btn.clicked.connect(self._open_plugin_manager)
|
||
lay.addWidget(plugin_btn)
|
||
|
||
w.adjustSize()
|
||
return w
|
||
|
||
def _toggle_drawer(self, checked: bool):
|
||
if checked:
|
||
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("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("By FatihMakes", 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>com.jarvis.assistant</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)
|
||
|
||
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,
|
||
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):
|
||
"""Canlı önizleme — tüm arayüzü yeni renge boyar (config'e YAZMAZ)."""
|
||
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 = ""):
|
||
"""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(f"{display} — MARK LI")
|
||
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("Personal AI Assistant")
|
||
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):
|
||
# Tüm arayüzü (paneller, butonlar, kenarlıklar, HUD) canlı boya
|
||
retheme_all_widgets(old, current_palette())
|
||
color_changed = old["PRI"] != C.PRI
|
||
|
||
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}")
|
||
except Exception as e:
|
||
self._log.append_log(f"ERR: Config save 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
|
||
|
||
# ── 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")
|
||
|
||
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._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._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
|
||
|
||
@property
|
||
def get_plugins(self):
|
||
return self._win.get_plugins
|
||
|
||
@get_plugins.setter
|
||
def get_plugins(self, cb):
|
||
self._win.get_plugins = 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()
|
||
|
||
@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") |