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 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 XLIX")
self.setMinimumSize(_MIN_W, _MIN_H)
self.resize(_DEFAULT_W, _DEFAULT_H)
screen = QApplication.primaryScreen().availableGeometry()
self.move(
(screen.width() - _DEFAULT_W) // 2,
(screen.height() - _DEFAULT_H) // 2,
)
self.on_text_command = None
self.on_remote_clicked = None # callable: () -> (url, key) | None
self.on_interrupt = None # callable: () -> None — stop JARVIS mid-speech
self._muted = False
self._current_file: str | None = None
self._remote_overlay: RemoteKeyOverlay | None = None
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 _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"
@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.
Raises on failure so the caller can log a useful error.
"""
# ── Option 1: pywin32 (pure Python COM, zero subprocess) ──────────
com_err: Exception | None = None
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
except Exception as e: # COM error — still try VBScript
com_err = e
# ── Option 2: wscript.exe + VBScript (always available on Windows,
# GUI-mode executable — never opens a console window) ────────────
def q(s: str) -> str: # escape for a VBScript string literal
return s.replace('"', '""')
vbs = "\n".join([
'On Error Resume Next',
'Set ws = CreateObject("WScript.Shell")',
f'Set sc = ws.CreateShortcut("{q(lnk)}")',
f'sc.TargetPath = "{q(target)}"',
f'sc.Arguments = Chr(34) & "{q(args)}" & Chr(34)',
f'sc.WorkingDirectory = "{q(work_dir)}"',
'sc.Description = "J.A.R.V.I.S AI Assistant"',
f'sc.IconLocation = "{q(icon_loc)}"',
'sc.Save',
'If Err.Number <> 0 Then WScript.Quit 1',
])
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
if not Path(lnk).exists():
raise RuntimeError(
f"could not create '{lnk}'"
+ (f" ({com_err})" if com_err else "")
)
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()
desktop.mkdir(parents=True, exist_ok=True)
# ── 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(
'\n'
'\n'
'\n'
' CFBundleExecutableJARVIS\n'
' CFBundleIdentifier'
'com.jarvis.assistant\n'
' CFBundleNameJ.A.R.V.I.S\n'
' CFBundlePackageTypeAPPL\n'
' CFBundleVersion1.0\n'
'\n'
)
# Optional: copy icon as .icns (skip silently if Pillow is missing)
try:
import PIL.Image
icns = res_dir / "AppIcon.icns"
PIL.Image.open(ico_path).save(icns, format="ICNS")
# Inject icon reference into plist
plist = app / "Contents" / "Info.plist"
txt = plist.read_text()
plist.write_text(
txt.replace(
'',
' CFBundleIconFile'
'AppIcon\n\n',
)
)
except Exception:
pass # icon is optional
# ── Linux — .desktop file (Terminal=false, no console) ────────────
else:
# Export .ico → .png for better desktop integration
png_path = ico_path.with_suffix(".png")
if not png_path.exists() and ico_path.exists():
try:
import PIL.Image
PIL.Image.open(ico_path).resize(
(256, 256), PIL.Image.LANCZOS
).save(png_path, format="PNG")
except Exception:
png_path = ico_path # fallback to .ico
icon_line = f"Icon={png_path}\n" if png_path.exists() else ""
desk = desktop / "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)
# GNOME refuses to launch desktop files until they are
# marked trusted ("Allow Launching") — do it automatically.
try:
subprocess.run(
["gio", "set", str(desk),
"metadata::trusted", "true"],
capture_output=True, timeout=5,
)
except Exception:
pass # non-GNOME desktops don't need (or have) gio
self._log.append_log(f"SYS: Desktop shortcut created in '{desktop}'.")
except Exception as e:
self._log.append_log(
f"ERR: Shortcut failed — {e} (desktop dir: '{desktop}')"
)
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 XLIX", 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)
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(
'\n'
'\n'
'\n'
' Labelcom.jarvis.assistant\n'
' ProgramArguments\n'
f' {sys.executable}\n'
f' {script}\n'
' \n'
' RunAtLoad\n'
'\n'
)
else:
desk_dir = Path.home() / ".config" / "autostart"
desk_dir.mkdir(parents=True, exist_ok=True)
desk = desk_dir / "jarvis.desktop"
if currently_on:
desk.unlink(missing_ok=True)
else:
desk.write_text(
"[Desktop Entry]\n"
f"Name={self._assistant_name}\n"
f"Exec={sys.executable} {script}\n"
"Type=Application\nTerminal=false\n"
"X-GNOME-Autostart-enabled=true\n"
)
enabled = not currently_on
self._update_autostart_btn(enabled)
self._log.append_log(
f"SYS: Auto-start {'enabled' if enabled else 'disabled'}.")
except Exception as e:
self._log.append_log(f"ERR: Auto-start failed — {e}")
def _update_autostart_btn(self, enabled: bool):
if not hasattr(self, '_autostart_btn'):
return
if enabled:
self._autostart_btn.setText("◉ AUTO-START: ON")
self._autostart_btn.setStyleSheet(f"""
QPushButton {{
background: #001a08; color: {C.GREEN};
border: 1px solid {C.GREEN_D}; border-radius: 3px;
}}
QPushButton:hover {{ background: #002010; }}
""")
else:
self._autostart_btn.setText("◉ AUTO-START: OFF")
self._autostart_btn.setStyleSheet(f"""
QPushButton {{
background: transparent; color: {C.TEXT_DIM};
border: 1px solid {C.BORDER}; border-radius: 3px;
}}
QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }}
""")
def _toggle_brief(self):
from memory.config_manager import get_brief_enabled, save_brief_enabled
new_val = not get_brief_enabled()
save_brief_enabled(new_val)
self._update_brief_btn(new_val)
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 XLIX")
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}")
# ── 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
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")