Initial commit: Core Teletext Editor functionality

This commit is contained in:
2025-12-28 21:38:21 +01:00
commit 6000897578
4494 changed files with 537255 additions and 0 deletions

274
src/teletext/renderer.py Normal file
View File

@@ -0,0 +1,274 @@
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
# 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):
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumSize(480, 500) # 40x12 * 25x20 approx
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 = 12
self.cell_h = 20
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", 14)
self.font.setStyleHint(QFont.StyleHint.Monospace)
self.font.setBold(True)
# Cursor state
self.cursor_x = 0
self.cursor_y = 0
self.cursor_visible = True
# Blinking cursor timer could be added, for now static inverted is fine or toggle on timer elsewhere
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.update()
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.update()
def set_page(self, page: Page):
self.page = page
self.cursor_x = 0
self.cursor_y = 0
self.redraw()
self.update()
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 = ord(text)
# 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?
# Creating new packets in a T42 stream is tricky (insertion).
# For now, only edit existing rows.
pass
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
for r in range(25):
packet = grid[r]
self.draw_row(painter, r, packet)
painter.end()
def draw_row(self, painter, row, packet):
# 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
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
# 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
pass
elif byte_val == 0x0D: # Double Height
pass # Not implemented yet
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
# Draw Background
painter.fillRect(x, y, self.cell_w, self.cell_h, bg)
# Draw Foreground
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:
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
if (0x20 <= byte_val <= 0x3F) or (0x60 <= byte_val <= 0x7F):
self.draw_mosaic(painter, x, y, byte_val, fg, contiguous)
held_char = byte_val
else:
# Capital letter in graphics mode? Usually shows char?
char = get_char(byte_val, self.subset_idx)
painter.setPen(fg)
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)
painter.drawText(QRect(x, y, self.cell_w, self.cell_h), Qt.AlignmentFlag.AlignCenter, char)
# Draw Cursor
# Invert the cell at cursor position
if self.cursor_visible and c == self.cursor_x and row == self.cursor_y:
painter.setCompositionMode(QPainter.CompositionMode.RasterOp_NotSourceXorDestination)
# XOR with white (creates inversion)
painter.fillRect(x, y, self.cell_w, self.cell_h, QColor(255, 255, 255))
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
def draw_mosaic(self, painter, x, y, char_code, color, contiguous):
val = char_code & 0x7F
bits = 0
if val >= 0x20:
bits = val - 0x20
blocks = [
(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
bw = self.cell_w / 2
bh = self.cell_h / 3
if not contiguous:
bw -= 1
bh -= 1
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QBrush(color))
for i in range(6):
if bits & bit_mask[i]:
bx = x + blocks[i][0] * (self.cell_w / 2)
by = y + blocks[i][1] * (self.cell_h / 3)
if not contiguous:
bx += 1
by += 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)