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)