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