283 lines
9.5 KiB
Python
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
|