Files
JARVIS/display.py
David Rice 9ccc02a2c9 cunt
2026-04-16 18:56:21 +01:00

255 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
JARVIS Display
──────────────
Renders the JARVIS UI overlay via X11.
Development: python display.py
Kiosk: DISPLAY=:0 python display.py --fullscreen
"""
import os
import math
import argparse
from datetime import datetime
_parser = argparse.ArgumentParser(description='JARVIS display', add_help=True)
_parser.add_argument(
'--fullscreen', '-fs', action='store_true',
help='Run fullscreen under X11/Wayland')
_parser.add_argument('--width', type=int, default=1280,
help='Screen width (default 1280)')
_parser.add_argument('--height', type=int, default=800,
help='Screen height (default 800)')
_args, _ = _parser.parse_known_args()
import pygame
# ── Colour palette ───────────────────────────────────────────────────────────
BLACK = ( 0, 0, 0)
WHITE = (255, 255, 255)
GRAY = (160, 160, 160)
DIM_GRAY = ( 80, 80, 80)
WIRE_COL = ( 58, 58, 58) # subdued wireframe line colour
# ── Wireframe face ────────────────────────────────────────────────────────────
# Pure 2-D face drawing. Face shape is defined by an explicit width profile;
# eye sockets, nose bridge and chin are drawn as distinct features so the
# brain immediately reads "face" rather than a geometric solid.
WIRE_EYE = ( 95, 95, 95) # slightly brighter for eye/feature lines
def _hw(t: float, rx: float) -> float:
"""
Face half-width in pixels at normalised height t.
t = -1 → crown (top)
t = 0 → cheek level (widest, ~= rx)
t = +1 → chin tip
Upper half: rounded ellipse.
Lower half: faster taper toward chin.
"""
if t <= 0.0:
return rx * math.sqrt(max(0.0, 1.0 - t * t))
else:
return rx * math.sqrt(max(0.0, 1.0 - t * t)) * (1.0 - 0.30 * t)
def draw_wireframe_face(surface: pygame.Surface,
cx: int, cy: int,
rx: float, ry: float) -> None:
"""
Draw a 2-D face wireframe centred at (cx, cy).
Coordinate system: t ∈ [1, +1] (1=crown, 0=cheeks, +1=chin)
x_fraction ∈ [1, +1] (fraction of half-width at t)
"""
N_H = 13 # horizontal grid lines
N_V = 11 # vertical grid lines (including edges)
PTS = 40 # interpolation steps per curve
# Helpers ─────────────────────────────────────────────────────────────────
def sc(xf: float, t: float) -> tuple[int, int]:
"""xf (fraction of half-width) + height t → screen pixel."""
w = _hw(t, rx)
# Slight forward bow: face curves toward viewer at cheek level
bow = int(0.06 * w * (1.0 - t * t) * (1.0 - xf * xf))
return (int(cx + xf * w), int(cy + t * ry) - bow)
def draw(pts, close=False, col=WIRE_COL):
if len(pts) >= 2:
pygame.draw.lines(surface, col, close, pts, 1)
# Face outline (left and right silhouette) ────────────────────────────────
t_vals = [(-1.0 + 2.0 * i / (PTS - 1)) for i in range(PTS)]
draw([sc(-1.0, t) for t in t_vals]) # left edge
draw([sc( 1.0, t) for t in t_vals]) # right edge
# Crown arc (straight line across the very top)
draw([sc(-1.0, -1.0), sc(1.0, -1.0)])
# Chin arc
draw([sc(-0.6, 1.0), sc(0.0, 1.02), sc(0.6, 1.0)])
# Horizontal grid lines ───────────────────────────────────────────────────
for i in range(1, N_H):
t = -1.0 + 2.0 * i / N_H
draw([sc(-1.0 + 2.0 * j / (PTS - 1), t) for j in range(PTS)])
# Vertical grid lines ─────────────────────────────────────────────────────
for i in range(1, N_V):
xf = -1.0 + 2.0 * i / N_V
draw([sc(xf, t) for t in t_vals])
# ── Eye sockets ───────────────────────────────────────────────────────────
# Each eye is a small ellipse drawn in a slightly brighter colour so it
# immediately reads as a facial feature.
eye_t = -0.38 # height: upper face (~35% from crown)
eye_xf = 0.35 # lateral offset (fraction of half-width at eye_t)
eye_w = _hw(eye_t, rx) # face half-width at eye level
e_rx = int(eye_w * 0.24)
e_ry = int(ry * 0.09)
for sign in (-1, +1):
ecx = int(cx + sign * eye_xf * eye_w)
ecy = int(cy + eye_t * ry)
eye_pts = [
(int(ecx + e_rx * math.cos(2 * math.pi * k / 28)),
int(ecy + e_ry * math.sin(2 * math.pi * k / 28)))
for k in range(28)
]
draw(eye_pts + [eye_pts[0]], col=WIRE_EYE)
# ── Nose bridge ───────────────────────────────────────────────────────────
# Two short lines running from inner eye corners down to nose tip
inner = int(eye_w * 0.12)
e_btm = int(cy + eye_t * ry) + e_ry
nose_y = int(cy + 0.08 * ry)
nose_w = int(eye_w * 0.07)
draw([(cx - inner, e_btm), (cx - nose_w, nose_y)], col=WIRE_COL)
draw([(cx + inner, e_btm), (cx + nose_w, nose_y)], col=WIRE_COL)
# ── Clock / date ─────────────────────────────────────────────────────────────
def draw_clock(surface: pygame.Surface, fonts: dict,
x: int, y: int) -> None:
now = datetime.now()
date_str = now.strftime('%A, %B %-d, %Y')
time_str = now.strftime('%-H:%M')
secs_str = now.strftime('%S')
# Date row
surface.blit(fonts['small'].render(date_str, True, GRAY), (x, y))
# Large time + superscript seconds
t_surf = fonts['large'].render(time_str, True, WHITE)
s_surf = fonts['small'].render(secs_str, True, DIM_GRAY)
ty = y + fonts['small'].get_height() + 4
surface.blit(t_surf, (x, ty))
surface.blit(s_surf, (x + t_surf.get_width() + 2,
ty + t_surf.get_height() - s_surf.get_height() - 8))
# ── Status line ───────────────────────────────────────────────────────────────
def draw_status(surface: pygame.Surface, fonts: dict,
text: str, cx: int, y: int) -> None:
surf = fonts['small'].render(text, True, GRAY)
surface.blit(surf, (cx - surf.get_width() // 2, y))
def draw_dot(surface: pygame.Surface, cx: int, y: int,
radius: int = 9) -> None:
pygame.draw.circle(surface, WHITE, (cx, y), radius)
# ── Font helper ───────────────────────────────────────────────────────────────
def _load_font(size: int, bold: bool = False) -> pygame.font.Font:
"""Try a list of clean system fonts, fall back to pygame built-in."""
for name in ('DejaVuSans', 'FreeSans', 'LiberationSans',
'Helvetica', 'Arial', None):
try:
f = pygame.font.SysFont(name, size, bold=bold)
if f:
return f
except Exception:
pass
return pygame.font.Font(None, size)
# ── Main loop ─────────────────────────────────────────────────────────────────
def main() -> None:
pygame.init()
W, H = _args.width, _args.height
if _args.fullscreen:
screen = pygame.display.set_mode((W, H), pygame.FULLSCREEN | pygame.NOFRAME)
else:
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption('JARVIS')
pygame.mouse.set_visible(False)
fonts = {
'large': _load_font(38),
'small': _load_font(13),
}
# Layout anchors — all relative to screen size so they adapt to resolution
face_rx = int(min(W, H) * 0.085) # half-width at cheek level
face_ry = int(min(W, H) * 0.100) # half-height crown→chin
face_cx = W // 2
face_cy = int(face_ry * 1.2) + 8 # nearly touches the top edge
status_y = int(H * 0.82)
dot_y = int(H * 0.895)
# ── Load face wireframe PNG if present ────────────────────────────────────
# Place any face wireframe PNG at assets/face_wire.png and it will be
# used automatically. Black background is composited away via BLEND_ADD
# so only the bright lines show. Falls back to parametric drawing.
_here = os.path.dirname(os.path.abspath(__file__))
_png_path = os.path.join(_here, 'assets', 'face_wire.png')
face_img = None
if os.path.exists(_png_path):
raw = pygame.image.load(_png_path).convert()
img_src_w, img_src_h = raw.get_size()
# Scale to fit face slot while preserving aspect ratio
scale = min((face_rx * 2.2) / img_src_w,
(face_ry * 2.4) / img_src_h)
img_w = max(1, int(img_src_w * scale))
img_h = max(1, int(img_src_h * scale))
face_img = pygame.transform.smoothscale(raw, (img_w, img_h))
status_text = 'Initializing...'
clock = pygame.time.Clock()
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
return
if event.type == pygame.KEYDOWN:
if event.key in (pygame.K_ESCAPE, pygame.K_q):
pygame.quit()
return
screen.fill(BLACK)
if face_img:
screen.blit(face_img,
face_img.get_rect(center=(face_cx, face_cy)),
special_flags=pygame.BLEND_ADD)
else:
draw_wireframe_face(screen, face_cx, face_cy, face_rx, face_ry)
clock_block_h = fonts['small'].get_height() + 4 + fonts['large'].get_height()
draw_clock(screen, fonts, 20, face_cy - clock_block_h // 2)
draw_status(screen, fonts, status_text, W // 2, status_y)
draw_dot(screen, W // 2, dot_y)
pygame.display.flip()
clock.tick(10) # 10 fps is plenty for a status screen
if __name__ == '__main__':
main()