Initial commit: Core Teletext Editor functionality
This commit is contained in:
274
src/teletext/renderer.py
Normal file
274
src/teletext/renderer.py
Normal 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)
|
||||
Reference in New Issue
Block a user