Files
KH_Clock/display.py

283 lines
9.5 KiB
Python

import mmap
import os
import threading
import time
from pathlib import Path
import pygame
from config import AppConfig
from state import MessageState
# ── Font discovery ────────────────────────────────────────────────────────────
# Monospace for the clock so digit widths are consistent (no layout shift)
_CLOCK_FONT_CANDIDATES = [
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationMono-Bold.ttf",
"/usr/share/fonts/truetype/freefont/FreeMonoBold.ttf",
"/usr/share/fonts/truetype/noto/NotoSansMono-Bold.ttf",
]
# Bold sans-serif for messages — maximises readability of arbitrary text
_MESSAGE_FONT_CANDIDATES = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
]
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
def _find_font(configured: str, candidates: list[str]) -> str | None:
if configured and Path(configured).exists():
return configured
for p in candidates:
if Path(p).exists():
return p
return None # fall back to pygame's built-in bitmap font
def _load_font(path: str | None, size: int) -> pygame.font.Font:
if path:
try:
return pygame.font.Font(path, size)
except Exception:
pass
return pygame.font.Font(None, size) # pygame built-in fallback
# ── Text layout helpers ───────────────────────────────────────────────────────
def _wrap_text(font: pygame.font.Font, text: str, max_width: int) -> list[str]:
"""Word-wrap text to fit within max_width pixels. Returns list of lines."""
words = text.split()
lines: list[str] = []
current: list[str] = []
for word in words:
test = " ".join(current + [word])
if font.size(test)[0] <= max_width:
current.append(word)
else:
if current:
lines.append(" ".join(current))
current = [word]
else:
lines.append(word) # single word too wide — add it anyway
if current:
lines.append(" ".join(current))
return lines or [""]
def _fit_message_font(
font_path: str | None,
text: str,
max_w: int,
max_h: int,
) -> tuple[pygame.font.Font, list[str]]:
"""
Binary-search for the largest font size where the word-wrapped text fits
within (max_w, max_h). Returns (font, wrapped_lines).
"""
lo, hi = 16, 500
best_font = _load_font(font_path, lo)
best_lines = _wrap_text(best_font, text, max_w)
while lo <= hi:
mid = (lo + hi) // 2
f = _load_font(font_path, mid)
lines = _wrap_text(f, text, max_w)
total_h = len(lines) * f.get_linesize()
max_line_w = max((f.size(ln)[0] for ln in lines), default=0)
if total_h <= max_h and max_line_w <= max_w:
best_font, best_lines = f, lines
lo = mid + 1
else:
hi = mid - 1
return best_font, best_lines
def _build_clock_font(font_path: str | None, screen_w: int, screen_h: int) -> pygame.font.Font:
"""
Find the largest font size where the widest possible time string fits within
88 % of the screen width and 50 % of the screen height.
"""
target_w = int(screen_w * 0.88)
target_h = int(screen_h * 0.50)
sample = "12:00:00 AM" # widest realistic string
lo, hi = 16, 600
best = lo
while lo <= hi:
mid = (lo + hi) // 2
f = _load_font(font_path, mid)
tw, th = f.size(sample)
if tw <= target_w and th <= target_h:
best = mid
lo = mid + 1
else:
hi = mid - 1
return _load_font(font_path, best)
# ── Display thread ────────────────────────────────────────────────────────────
class DisplayThread(threading.Thread):
def __init__(self, state: MessageState, config: AppConfig, dev_mode: bool = False):
super().__init__(daemon=True, name="display")
self.state = state
self.config = config
self.dev_mode = dev_mode
self._stop_event = threading.Event()
def stop(self) -> None:
self._stop_event.set()
def run(self) -> None:
# SDL environment must be set before pygame.init()
if self.dev_mode:
os.environ.setdefault("SDL_VIDEODRIVER", "x11")
else:
# SDL kmsdrm is incompatible with the Amlogic MESON DRM driver.
# Use offscreen rendering and blit pixels directly to /dev/fb0.
os.environ["SDL_VIDEODRIVER"] = "offscreen"
try:
pygame.init()
except Exception as e:
print(f"[display] pygame.init() failed: {e}")
return
try:
screen = self._create_surface()
except Exception as e:
print(f"[display] Failed to create display surface: {e}")
pygame.quit()
return
pygame.mouse.set_visible(False)
clock = pygame.time.Clock()
screen_w, screen_h = screen.get_size()
# Open /dev/fb0 for direct pixel writes (framebuffer mode only)
fb0_mmap = None
fb0_file = None
if not self.dev_mode:
try:
bpp = int(Path("/sys/class/graphics/fb0/bits_per_pixel").read_text())
fb0_file = open("/dev/fb0", "rb+")
fb0_mmap = mmap.mmap(fb0_file.fileno(), screen_w * screen_h * (bpp // 8))
except Exception as e:
print(f"[display] Failed to open /dev/fb0: {e}")
pygame.quit()
return
clock_font_path = _find_font(self.config.clock_font_path, _CLOCK_FONT_CANDIDATES)
msg_font_path = _find_font(self.config.message_font_path, _MESSAGE_FONT_CANDIDATES)
if not clock_font_path:
print("[display] Warning: no TTF font found; falling back to pygame built-in (may look pixelated at large sizes)")
if not msg_font_path:
msg_font_path = clock_font_path # use same font if none found
clock_font = _build_clock_font(clock_font_path, screen_w, screen_h)
# Cache message layout — recomputed only when message text changes
last_msg_text: str | None = None
msg_font: pygame.font.Font | None = None
msg_lines: list[str] = []
while not self._stop_event.is_set():
for event in pygame.event.get():
if event.type == pygame.QUIT:
self._stop_event.set()
break
# Allow Escape to exit in dev mode
if self.dev_mode and event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
self._stop_event.set()
break
text, _ = self.state.get()
screen.fill(BLACK)
if text:
if text != last_msg_text:
max_w = int(screen_w * 0.88)
max_h = int(screen_h * 0.88)
msg_font, msg_lines = _fit_message_font(msg_font_path, text, max_w, max_h)
last_msg_text = text
self._draw_message(screen, msg_font, msg_lines, screen_w, screen_h)
else:
last_msg_text = None
self._draw_clock(screen, clock_font, screen_w, screen_h)
if fb0_mmap is not None:
fb0_mmap.seek(0)
fb0_mmap.write(pygame.image.tostring(screen, "BGRA"))
else:
pygame.display.flip()
clock.tick(self.config.fps)
if fb0_mmap:
fb0_mmap.close()
if fb0_file:
fb0_file.close()
pygame.quit()
def _create_surface(self) -> pygame.Surface:
if self.dev_mode:
w = self.config.width or 1280
h = self.config.height or 720
surface = pygame.display.set_mode((w, h))
pygame.display.set_caption("KH Clock [DEV]")
return surface
# Read actual framebuffer dimensions from sysfs
size_str = Path("/sys/class/graphics/fb0/virtual_size").read_text().strip()
w, h = map(int, size_str.split(","))
return pygame.display.set_mode((w, h))
@staticmethod
def _draw_clock(
screen: pygame.Surface,
font: pygame.font.Font,
screen_w: int,
screen_h: int,
) -> None:
now = time.localtime()
hour = now.tm_hour % 12 or 12
ampm = "AM" if now.tm_hour < 12 else "PM"
time_str = f"{hour}:{now.tm_min:02d}:{now.tm_sec:02d} {ampm}"
surf = font.render(time_str, True, WHITE)
rect = surf.get_rect(center=(screen_w // 2, screen_h // 2))
screen.blit(surf, rect)
@staticmethod
def _draw_message(
screen: pygame.Surface,
font: pygame.font.Font,
lines: list[str],
screen_w: int,
screen_h: int,
) -> None:
line_h = font.get_linesize()
total_h = len(lines) * line_h
y = (screen_h - total_h) // 2
for line in lines:
surf = font.render(line, True, WHITE)
x = (screen_w - surf.get_width()) // 2
screen.blit(surf, (x, y))
y += line_h