275 lines
9.8 KiB
Python
275 lines
9.8 KiB
Python
|
|
|
||
|
|
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)
|