diff --git a/src/teletext/__pycache__/io.cpython-312.pyc b/src/teletext/__pycache__/io.cpython-312.pyc index 83c8588..f39a174 100644 Binary files a/src/teletext/__pycache__/io.cpython-312.pyc and b/src/teletext/__pycache__/io.cpython-312.pyc differ diff --git a/src/teletext/__pycache__/renderer.cpython-312.pyc b/src/teletext/__pycache__/renderer.cpython-312.pyc index 3cae7cc..228e105 100644 Binary files a/src/teletext/__pycache__/renderer.cpython-312.pyc and b/src/teletext/__pycache__/renderer.cpython-312.pyc differ diff --git a/src/teletext/__pycache__/ui.cpython-312.pyc b/src/teletext/__pycache__/ui.cpython-312.pyc index f2056a0..e0a4503 100644 Binary files a/src/teletext/__pycache__/ui.cpython-312.pyc and b/src/teletext/__pycache__/ui.cpython-312.pyc differ diff --git a/src/teletext/io.py b/src/teletext/io.py index a9db7ff..82001e5 100644 --- a/src/teletext/io.py +++ b/src/teletext/io.py @@ -62,24 +62,74 @@ def load_t42(file_path: str) -> TeletextService: return service + +def encode_hamming_8_4(value): + # Value is 4 bits (0-15) + d1 = (value >> 0) & 1 + d2 = (value >> 1) & 1 + d3 = (value >> 2) & 1 + d4 = (value >> 3) & 1 + + # Parity bits (Odd parity default? Or standard Hamming?) + # Teletext spec: + # P1 = 1 + D1 + D2 + D4 (mod 2) -> Inverse of even parity check? + # Actually, simpler to look up or calculate. + # Let's match typical implementation: + # P1 (b0) covers 1,3,7 (D1, D2, D4) + # P2 (b2) covers 1,5,7 (D1, D3, D4) + # P3 (b4) covers 3,5,7 (D2, D3, D4) + # P4 (b6) covers all. + # Teletext uses ODD parity for the hamming bits usually? + # "Hamming 8/4 with odd parity" + + p1 = 1 ^ d1 ^ d2 ^ d4 + p2 = 1 ^ d1 ^ d3 ^ d4 + p3 = 1 ^ d2 ^ d3 ^ d4 + + res = (p1 << 0) | (d1 << 1) | \ + (p2 << 2) | (d2 << 3) | \ + (p3 << 4) | (d3 << 5) | \ + (d4 << 7) + + # P4 (bit 6) makes total bits odd + # Count set bits so far + set_bits = bin(res).count('1') + p4 = 1 if (set_bits % 2 == 0) else 0 + + res |= (p4 << 6) + + return res + def save_t42(file_path: str, service: TeletextService): with open(file_path, 'wb') as f: - # User requirement: "without rearranging the order of the packets" - # Implies we should iterate the original list. - # However, if we edit data, we modify the Packet objects in place. - # If we Add/Delete packets, we need to handle that. - for packet in service.all_packets: - # Reconstruct the 42 bytes from the packet fields - # The packet.data (bytearray) should be mutable and edited by the UI. - # packet.original_data (first 2 bytes) + packet.data + # Reconstruct header bytes from packet.magazine and packet.row + # Byte 1: M1 M2 M3 R1 + # Byte 2: R2 R3 R4 R5 - # Note: If we changed Magazine or Row, we'd need to re-encode the first 2 bytes. - # For now, assume we primarily edit content (bytes 2-41). + mag = packet.magazine + if mag == 8: mag = 0 # 0 encoded as 8 - header = packet.original_data[:2] # Keep original address for now - # TODO: regenerating header if Mag/Row changed + # Bits: + # B1 data: M1(0) M2(1) M3(2) R1(3) + m1 = (mag >> 0) & 1 + m2 = (mag >> 1) & 1 + m3 = (mag >> 2) & 1 + r1 = (packet.row >> 0) & 1 + b1_val = m1 | (m2 << 1) | (m3 << 2) | (r1 << 3) + b1_enc = encode_hamming_8_4(b1_val) + + # B2 data: R2(0) R3(1) R4(2) R5(3) + r2 = (packet.row >> 1) & 1 + r3 = (packet.row >> 2) & 1 + r4 = (packet.row >> 3) & 1 + r5 = (packet.row >> 4) & 1 + + b2_val = r2 | (r3 << 1) | (r4 << 2) | (r5 << 3) + b2_enc = encode_hamming_8_4(b2_val) + + header = bytes([b1_enc, b2_enc]) f.write(header + packet.data) def decode_hamming_8_4(byte_val): diff --git a/src/teletext/renderer.py b/src/teletext/renderer.py index fdf8cb9..54824fa 100644 --- a/src/teletext/renderer.py +++ b/src/teletext/renderer.py @@ -6,6 +6,40 @@ from PyQt6.QtCore import Qt, QRect, QSize, pyqtSignal from .models import Page, Packet from .charsets import get_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 @@ -19,8 +53,11 @@ COLORS = [ ] class TeletextCanvas(QWidget): + cursorChanged = pyqtSignal(int, int, int) # x, y, byte_val + def __init__(self, parent=None): super().__init__(parent) + self.setMouseTracking(True) # Just in case self.setMinimumSize(480, 500) # 40x12 * 25x20 approx self.page: Page = None self.subset_idx = 0 # Default English @@ -48,22 +85,65 @@ class TeletextCanvas(QWidget): self.cursor_visible = True # 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) + 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 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: @@ -96,10 +176,24 @@ class TeletextCanvas(QWidget): 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 + # 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 = ord(text) + 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) @@ -223,11 +317,48 @@ class TeletextCanvas(QWidget): # 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.setCompositionMode(QPainter.CompositionMode.CompositionMode_Difference) + # Difference 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 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): val = char_code & 0x7F bits = 0 diff --git a/src/teletext/ui.py b/src/teletext/ui.py index 4890052..e32b2dd 100644 --- a/src/teletext/ui.py +++ b/src/teletext/ui.py @@ -1,7 +1,10 @@ -import os from PyQt6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, - QListWidget, QListWidgetItem, QComboBox, QLabel, QFileDialog, QMenuBar, QMenu, QMessageBox) + QListWidget, QListWidgetItem, QComboBox, QLabel, QLineEdit, QPushButton, QFileDialog, QMenuBar, QMenu, QMessageBox) + +# ... (imports remain) + +# ... (imports remain) from PyQt6.QtGui import QAction, QKeyEvent from PyQt6.QtCore import Qt @@ -34,6 +37,19 @@ class MainWindow(QMainWindow): self.page_list.itemClicked.connect(self.on_page_selected) left_layout.addWidget(self.page_list) + # Hex Inspector + hex_layout = QVBoxLayout() + hex_label = QLabel("Hex Value:") + self.hex_input = QLineEdit() + self.hex_input.setMaxLength(2) + self.hex_input.setPlaceholderText("00") + self.hex_input.returnPressed.connect(self.on_hex_entered) + hex_layout.addWidget(hex_label) + hex_layout.addWidget(self.hex_input) + left_layout.addLayout(hex_layout) + + left_layout.addStretch() + self.layout.addLayout(left_layout) # Center Area Layout (Top Bar + Canvas) @@ -51,8 +67,30 @@ class MainWindow(QMainWindow): center_layout.addLayout(top_bar) + # Color Shortcuts + color_layout = QHBoxLayout() + colors = [ + ("Red", 0x01, "#FF0000"), + ("Green", 0x02, "#00FF00"), + ("Yellow", 0x03, "#FFFF00"), + ("Blue", 0x04, "#0000FF"), + ("Magenta", 0x05, "#FF00FF"), + ("Cyan", 0x06, "#00FFFF"), + ("White", 0x07, "#FFFFFF"), + ] + + for name, code, hex_color in colors: + btn = QPushButton(name) + btn.setStyleSheet(f"background-color: {hex_color}; font-weight: bold; color: black;") + btn.clicked.connect(lambda checked, c=code: self.insert_char(c)) + color_layout.addWidget(btn) + + color_layout.addStretch() + center_layout.addLayout(color_layout) + # Canvas self.canvas = TeletextCanvas() + self.canvas.cursorChanged.connect(self.on_cursor_changed) center_layout.addWidget(self.canvas, 1) # Expand self.layout.addLayout(center_layout, 1) @@ -164,6 +202,28 @@ class MainWindow(QMainWindow): self.current_page = page self.canvas.set_page(page) self.canvas.setFocus() + + def insert_char(self, char_code): + self.canvas.set_byte_at_cursor(char_code) + # Advance cursor + self.canvas.move_cursor(1, 0) + self.canvas.setFocus() + + def on_cursor_changed(self, x, y, val): + self.hex_input.setText(f"{val:02X}") + + def on_hex_entered(self): + text = self.hex_input.text() + try: + val = int(text, 16) + if 0 <= val <= 255: + # Update canvas input + # We can call handle_input with char, OR set byte directly. + # Direct byte set is safer for non-printable. + self.canvas.set_byte_at_cursor(val) + self.canvas.setFocus() # Return focus to canvas + except ValueError: + pass # Ignore invalid hex # Input Handling (Editor Logic) def keyPressEvent(self, event: QKeyEvent): @@ -182,6 +242,10 @@ class MainWindow(QMainWindow): self.canvas.move_cursor(-1, 0) elif key == Qt.Key.Key_Right: self.canvas.move_cursor(1, 0) + elif key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter: + # Move to start of next line + self.canvas.cursor_x = 0 + self.canvas.move_cursor(0, 1) else: # Typing # Filter non-printable