Files
Teletext-Editor/src/teletext/renderer.py

497 lines
19 KiB
Python
Raw Normal View History

from PyQt6.QtWidgets import QWidget
from PyQt6.QtGui import QPainter, QColor, QFont, QImage, QBrush, QPen
from PyQt6.QtCore import Qt, QRect, QSize, pyqtSignal
from .models import Page, Packet
from .charsets import get_char, get_byte_from_char
# Helper to create a blank packet
def create_blank_packet(magazine: int, row: int) -> Packet:
# 42 bytes
# Bytes 0-1: Address (Hamming encoded)
# We need to compute Hamming for M and R.
# For now, let's just make a placeholder that "looks" okay or use 0x00 and let a future saver fix it.
# But wait, `Packet` expects 42 bytes input.
# Ideally we should implement the hamming encoder from `io.py` or similar.
# For now, we will create a Packet with dummy address bytes and correct mag/row properties set manually if needed,
# OR we just construct the bytes.
# Let's rely on the Packet class parsing... which is annoying if we can't easily encode.
# Hack: default to 0x00 0x00, then manually set .magazine and .row
data = bytearray(42)
# Set spaces
data[2:] = b'\x20' * 40
p = Packet(bytes(data))
# Override
p.magazine = magazine
p.row = row
p.data = bytearray(b'\x20' * 40)
# We really should set the address bytes correctly so saving works "naturally".
# But `save_t42` uses `packet.original_data[:2]`.
# So we MUST encode properly or `save_t42` needs to regenerate headers.
# Let's defer proper Hamming encoding to the Save step (as noted in TODO in io.py).
# For in-memory editing, this is fine.
return p
# Teletext Palette
COLORS = [
QColor(0, 0, 0), # Black
QColor(255, 0, 0), # Red
QColor(0, 255, 0), # Green
QColor(255, 255, 0), # Yellow
QColor(0, 0, 255), # Blue
QColor(255, 0, 255), # Magenta
QColor(0, 255, 255), # Cyan
QColor(255, 255, 255) # White
]
class TeletextCanvas(QWidget):
cursorChanged = pyqtSignal(int, int, int, bool) # x, y, byte_val, is_graphics
def __init__(self, parent=None):
super().__init__(parent)
self.setMouseTracking(True) # Just in case
self.setMinimumSize(800, 600) # 40x20 * 25x24
self.page: Page = None
self.subset_idx = 0 # Default English
# Teletext is 40 columns x 25 rows
# We will render to a fixed size QImage and scale it
self.cols = 40
self.rows = 25
self.cell_w = 20
self.cell_h = 24
self.img_w = self.cols * self.cell_w
self.img_h = self.rows * self.cell_h
self.buffer = QImage(self.img_w, self.img_h, QImage.Format.Format_RGB32)
self.buffer.fill(Qt.GlobalColor.black)
# Font for text
self.font = QFont("Courier New", 18)
self.font.setStyleHint(QFont.StyleHint.Monospace)
self.font.setBold(True)
# Cursor state
self.cursor_x = 0
self.cursor_y = 0
self.cursor_visible = True
self.cursor_is_graphics = False # Tracked during draw
# Blinking cursor timer could be added, for now static inverted is fine or toggle on timer elsewhere
def get_byte_at(self, x, y):
if not self.page: return 0
# Row 0 special logic (header) - returning what is displayed or raw?
# User probably wants RAW byte for editing.
packet = None
for p in self.page.packets:
if p.row == y:
packet = p
break
if packet and 0 <= x < 40:
return packet.data[x]
return 0
def emit_cursor_change(self):
val = self.get_byte_at(self.cursor_x, self.cursor_y)
self.cursorChanged.emit(self.cursor_x, self.cursor_y, val, self.cursor_is_graphics)
def set_cursor(self, x, y):
self.cursor_x = max(0, min(self.cols - 1, x))
self.cursor_y = max(0, min(self.rows - 1, y))
self.redraw()
self.update()
self.emit_cursor_change()
def move_cursor(self, dx, dy):
self.cursor_x = max(0, min(self.cols - 1, self.cursor_x + dx))
self.cursor_y = max(0, min(self.rows - 1, self.cursor_y + dy))
self.redraw()
self.update()
self.emit_cursor_change()
def set_byte_at_cursor(self, byte_val):
if not self.page: return
packet = None
for p in self.page.packets:
if p.row == self.cursor_y:
packet = p
break
if packet:
if 0 <= self.cursor_x < 40:
packet.data[self.cursor_x] = byte_val
self.redraw()
self.emit_cursor_change()
else:
# Create packet if missing
self.handle_input(chr(byte_val)) # Lazy reuse or duplicate create logic
def set_page(self, page: Page):
self.page = page
# Set language from page header
if page:
self.subset_idx = page.language
else:
self.subset_idx = 0
self.cursor_x = 0
self.cursor_y = 0
self.redraw()
self.update()
self.emit_cursor_change()
def handle_input(self, text):
if not self.page:
return
# Find packet for current row
packet = None
for p in self.page.packets:
if p.row == self.cursor_y:
packet = p
break
if packet:
# Edit the data
# Data bytes start at index 0 of packet.data corresponding to Col 0?
# Standard Teletext row is 40 chars. Packet data is 40 bytes.
if 0 <= self.cursor_x < 40:
# Get ASCII value
# We need to Reverse Map chars to Teletext Bytes if we want full suppport
# For now, just taking ord() for basic ASCII
# TODO: reverse lookup for National Chars
# Check if text is a single char
if len(text) == 1:
byte_val = get_byte_from_char(text, self.subset_idx)
# Simple filter
if byte_val > 255: byte_val = 0x3F # ?
packet.data[self.cursor_x] = byte_val
self.redraw()
self.move_cursor(1, 0)
else:
# Create a packet if it doesn't exist for this row
if 0 <= self.cursor_x < 40:
# Check if we should create it
# Only if input is valid char?
if len(text) == 1:
# Create new packet
new_packet = create_blank_packet(self.page.magazine, self.cursor_y)
self.page.packets.append(new_packet)
# Sort packets by row? Or just append. Renderer handles unordered.
# But for sanity, let's just append.
# Write the char
byte_val = get_byte_from_char(text, self.subset_idx)
if byte_val > 255: byte_val = 0x3F
new_packet.data[self.cursor_x] = byte_val
self.redraw()
self.move_cursor(1, 0)
def redraw(self):
self.buffer.fill(Qt.GlobalColor.black)
painter = QPainter(self.buffer)
painter.setFont(self.font)
if not self.page:
painter.setPen(Qt.GlobalColor.white)
painter.drawText(10, 20, "No Page Loaded")
painter.end()
return
# Draw each packet
# Initialize a grid of empty chars
grid = [None] * 26 # 0-25
for p in self.page.packets:
if 0 <= p.row <= 25:
grid[p.row] = p
# Pass 1: Backgrounds
occlusion_mask = [False] * 40
for r in range(25):
packet = grid[r]
occlusion_mask = self.draw_row(painter, r, packet, draw_bg=True, draw_fg=False, occlusion_mask=occlusion_mask)
# Pass 2: Foregrounds
occlusion_mask = [False] * 40
for r in range(25):
packet = grid[r]
occlusion_mask = self.draw_row(painter, r, packet, draw_bg=False, draw_fg=True, occlusion_mask=occlusion_mask)
painter.end()
def draw_row(self, painter, row, packet, draw_bg=True, draw_fg=True, occlusion_mask=None):
if occlusion_mask is None:
occlusion_mask = [False] * 40
# Output mask for the next row
next_occlusion_mask = [False] * 40
# Default State at start of row
fg = COLORS[7] # White
bg = COLORS[0] # Black
graphics_mode = False
contiguous = True # Mosaic
hold_graphics = False
held_char = 0x20 # Space
double_height = False
y = row * self.cell_h
data = b''
if packet:
data = packet.data # 40 bytes
else:
data = b'\x20' * 40
# Header string for Row 0 columns 0-7
header_prefix = ""
if row == 0 and self.page:
header_prefix = f"P{self.page.magazine}{self.page.page_number:02d}"
# Pad to 8 chars
header_prefix = header_prefix.ljust(8)
for c in range(40):
x = c * self.cell_w
# If this cell is occluded by the row above, skip drawing and attribute processing?
# Spec says "The characters in the row below are ignored."
# Ideally we shouldn't even process attributes, but for simple renderer we just skip draw.
# However, if we skip attribute processing, state (fg/bg) won't update.
# Teletext attributes are serial.
# BUT, if the row above covers it, the viewer sees the row above.
# Does the hidden content affect the *rest* of the row?
# Likely yes, attributes usually propagate.
# But the spec says "ignored". Let's assume we skip *everything* for this cell visually,
# but maybe we should technically maintain state?
# For "Double Height" visual correctness, skipping drawing is the key.
# We will Process attributes (to keep state consistent) but Skip Drawing if occluded.
# Wait, if we process attributes, we might set double_height=True for the NEXT row?
# If this cell is occluded, it shouldn't trigger DH for the next row.
is_occluded = occlusion_mask[c]
# Decide byte value
if row == 0 and c < 8:
# Use generated header prefix
byte_val = ord(header_prefix[c])
else:
byte_val = data[c] if c < len(data) else 0x20
byte_val &= 0x7F # Strip parity
# Control Codes
is_control = False
if byte_val < 0x20:
is_control = True
# Handle control codes
if 0x00 <= byte_val <= 0x07: # Alpha Color
fg = COLORS[byte_val]
graphics_mode = False
elif 0x10 <= byte_val <= 0x17: # Mosaic Color
fg = COLORS[byte_val - 0x10]
graphics_mode = True
elif byte_val == 0x1C: # Black BG
bg = COLORS[0]
elif byte_val == 0x1D: # New BG
bg = fg
elif byte_val == 0x0C: # Normal Height
double_height = False
elif byte_val == 0x0D: # Double Height
double_height = True
elif byte_val == 0x19: # Contiguous Graphics
contiguous = True
elif byte_val == 0x1A: # Separated Graphics
contiguous = False
elif byte_val == 0x1E: # Hold Graphics
hold_graphics = True
elif byte_val == 0x1F: # Release Graphics
hold_graphics = False
# Record Double Height for next row
if double_height and not is_occluded:
next_occlusion_mask[c] = True
# Capture cursor state if this is the cursor position
if c == self.cursor_x and row == self.cursor_y:
self.cursor_is_graphics = graphics_mode
# If occluded, do not draw anything for this cell
if is_occluded:
continue
# Draw Background
if draw_bg:
# If double height, draw taller background
h_bg = self.cell_h * 2 if double_height else self.cell_h
painter.fillRect(x, y, self.cell_w, h_bg, bg)
# Draw Foreground
if draw_fg:
# Calculate height
# For Mosaics, we use the height param.
# For Alphanumerics, we scale the painter.
if is_control:
# "Set-at" spacing attribute? Teletext control codes occupy a space
# unless "Hold Graphics" replaces it with previous graphic char.
if hold_graphics and graphics_mode:
if double_height:
self.draw_mosaic(painter, x, y, held_char, fg, contiguous, height=self.cell_h * 2)
else:
self.draw_mosaic(painter, x, y, held_char, fg, contiguous)
else:
# Draw space (nothing, since we filled BG)
pass
else:
if graphics_mode:
# Mosaic Graphics
h_mos = self.cell_h * 2 if double_height else self.cell_h
if (0x20 <= byte_val <= 0x3F) or (0x60 <= byte_val <= 0x7F):
self.draw_mosaic(painter, x, y, byte_val, fg, contiguous, height=h_mos)
held_char = byte_val
else:
# Capital letter in graphics mode? Usually shows char?
char = get_char(byte_val, self.subset_idx)
painter.setPen(fg)
if double_height:
painter.save()
painter.translate(x, y)
painter.scale(1, 2)
painter.drawText(QRect(0, 0, self.cell_w, self.cell_h), Qt.AlignmentFlag.AlignCenter, char)
painter.restore()
else:
painter.drawText(QRect(x, y, self.cell_w, self.cell_h), Qt.AlignmentFlag.AlignCenter, char)
held_char = 0x20
else:
# Alphanumeric
char = get_char(byte_val, self.subset_idx)
painter.setPen(fg)
if double_height:
painter.save()
painter.translate(x, y)
painter.scale(1, 2)
painter.drawText(QRect(0, 0, self.cell_w, self.cell_h), Qt.AlignmentFlag.AlignCenter, char)
painter.restore()
else:
painter.drawText(QRect(x, y, self.cell_w, self.cell_h), Qt.AlignmentFlag.AlignCenter, char)
# Draw Cursor
# Invert the cell at cursor position
if draw_fg and self.cursor_visible and c == self.cursor_x and row == self.cursor_y:
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Difference)
# Difference with white creates inversion
# Note: Cursor follows double height? Probably just the active cell.
painter.fillRect(x, y, self.cell_w, self.cell_h, QColor(255, 255, 255))
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
return next_occlusion_mask
def mousePressEvent(self, event):
self.setFocus()
# Calculate cell from mouse position
# Need to account for scaling?
# The widget sizes to fit, but we render to a fixed buffer then scale up.
# paintEvent scales buffer to self.rect().
# This is a bit tricky because paintEvent uses aspect-ratio scaling and centering.
# We need to reverse the mapping.
target_rect = self.rect()
# Re-calc scale and offset
# Note: This logic duplicates paintEvent, commonize?
# Simple approximation for MVP:
# If the window is roughly aspect ratio correct, direct mapping works.
# If not, we need offset.
# Let's get the exact rect used in paintEvent
scale_x = target_rect.width() / self.img_w
scale_y = target_rect.height() / self.img_h
scale = min(scale_x, scale_y)
dw = self.img_w * scale
dh = self.img_h * scale
ox = (target_rect.width() - dw) / 2
oy = (target_rect.height() - dh) / 2
mx = event.pos().x() - ox
my = event.pos().y() - oy
if 0 <= mx < dw and 0 <= my < dh:
col = int(mx / (self.cell_w * scale))
row = int(my / (self.cell_h * scale))
self.set_cursor(col, row)
def draw_mosaic(self, painter, x, y, char_code, color, contiguous, height=None):
if height is None:
height = self.cell_h
val = char_code & 0x7F
bits = 0
if val >= 0x20:
bits = val - 0x20
# Grid definitions for 2x3 grid
x_splits = [0, int(self.cell_w / 2), self.cell_w]
y_splits = [0, int(height / 3), int(2 * height / 3), height]
# Block indices (col, row) for the 6 bits
block_indices = [
(0, 0), (1, 0), # Top
(0, 1), (1, 1), # Mid
(0, 2), (1, 2) # Bot
]
bit_mask = [1, 2, 4, 8, 16, 64] # 64 is bit 6
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QBrush(color))
for i in range(6):
if bits & bit_mask[i]:
c, r = block_indices[i]
bx_local = x_splits[c]
by_local = y_splits[r]
bw = x_splits[c+1] - x_splits[c]
bh = y_splits[r+1] - y_splits[r]
bx = x + bx_local
by = y + by_local
if not contiguous:
bx += 1
by += 1
bw -= 1
bh -= 1
painter.drawRect(QRect(int(bx), int(by), int(bw), int(bh)))
def paintEvent(self, event):
painter = QPainter(self)
target_rect = self.rect()
scaled = self.buffer.scaled(target_rect.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.FastTransformation)
ox = (target_rect.width() - scaled.width()) // 2
oy = (target_rect.height() - scaled.height()) // 2
painter.drawImage(ox, oy, scaled)