from PyQt6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QComboBox, QLabel, QLineEdit, QPushButton, QFileDialog, QMenuBar, QMenu, QMessageBox, QStatusBar, QProgressBar, QApplication ) # ... (imports remain) # ... (imports remain) from PyQt6.QtGui import QAction, QKeyEvent from PyQt6.QtCore import Qt from .io import load_t42, save_t42 from .renderer import TeletextCanvas, create_blank_packet import copy import sys import os from .models import TeletextService, Page, Packet class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Teletext Editor") self.resize(1024, 768) self.service = TeletextService() self.current_page: Page = None self.clipboard = [] # List of (row, data_bytes) self.undo_stack = [] self.redo_stack = [] self.is_modified = False # UI Components self.central_widget = QWidget() self.setCentralWidget(self.central_widget) self.layout = QHBoxLayout(self.central_widget) # Left Panel: Page List left_layout = QVBoxLayout() left_label = QLabel("Pages") left_layout.addWidget(left_label) self.page_list = QListWidget() self.page_list.setFixedWidth(150) 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) center_layout = QVBoxLayout() # Top Bar: Subpage Selector top_bar = QHBoxLayout() self.subpage_label = QLabel("Subpage:") self.subpage_combo = QComboBox() self.subpage_combo.setMinimumWidth(250) self.subpage_combo.currentIndexChanged.connect(self.on_subpage_changed) self.btn_prev_sub = QPushButton("<") self.btn_prev_sub.setFixedWidth(30) self.btn_prev_sub.clicked.connect(self.prev_subpage) self.btn_next_sub = QPushButton(">") self.btn_next_sub.setFixedWidth(30) self.btn_next_sub.clicked.connect(self.next_subpage) top_bar.addWidget(self.subpage_label) top_bar.addWidget(self.btn_prev_sub) top_bar.addWidget(self.subpage_combo) top_bar.addWidget(self.btn_next_sub) top_bar.addStretch() 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) # Status Bar self.status_bar = QStatusBar() self.setStatusBar(self.status_bar) self.progress_bar = QProgressBar() self.progress_bar.setFixedWidth(200) self.progress_bar.setVisible(False) self.status_bar.addPermanentWidget(self.progress_bar) self.status_label = QLabel("Ready") self.status_bar.addWidget(self.status_label) self.language_label = QLabel("Lang: English") self.status_bar.addPermanentWidget(self.language_label) self.language_names = ["English", "German", "Swedish/Finnish", "Italian", "French", "Portuguese/Spanish", "Turkish", "Romania"] # Menus self.create_menus() def update_language_label(self): idx = self.canvas.subset_idx if 0 <= idx < len(self.language_names): self.language_label.setText(f"Lang: {self.language_names[idx]}") else: self.language_label.setText(f"Lang: Unknown ({idx})") def set_modified(self, modified: bool): self.is_modified = modified title = "Teletext Editor" if self.current_file_path: title += f" - {os.path.basename(self.current_file_path)}" else: title += " - Untitled" if self.is_modified: title += " *" self.setWindowTitle(title) def maybe_save_changes(self) -> bool: if not self.is_modified: return True ret = QMessageBox.warning(self, "Unsaved Changes", "The document has been modified.\nDo you want to save your changes?", QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel) if ret == QMessageBox.StandardButton.Save: return self.save_file() elif ret == QMessageBox.StandardButton.Discard: return True else: return False # Cancel def closeEvent(self, event): if self.maybe_save_changes(): event.accept() else: event.ignore() def update_progress(self, current, total): self.progress_bar.setMaximum(total) self.progress_bar.setValue(current) QApplication.processEvents() # Force UI update def create_menus(self): menu_bar = self.menuBar() file_menu = menu_bar.addMenu("File") open_action = QAction("Open T42...", self) open_action.triggered.connect(self.open_file) file_menu.addAction(open_action) save_action = QAction("Save T42...", self) save_action.triggered.connect(self.save_file) file_menu.addAction(save_action) save_as_action = QAction("Save As...", self) save_as_action.triggered.connect(self.save_as_file) file_menu.addAction(save_as_action) close_action = QAction("Close File", self) close_action.triggered.connect(self.close_file) file_menu.addAction(close_action) file_menu.addSeparator() exit_action = QAction("Exit", self) exit_action.triggered.connect(self.close) file_menu.addAction(exit_action) # Edit Menu edit_menu = menu_bar.addMenu("Edit") copy_action = QAction("Copy Page Content", self) copy_action.triggered.connect(self.copy_page_content) edit_menu.addAction(copy_action) paste_action = QAction("Paste Page Content", self) paste_action.triggered.connect(self.paste_page_content) edit_menu.addAction(paste_action) edit_menu.addSeparator() undo_action = QAction("Undo", self) undo_action.setShortcut("Ctrl+Z") undo_action.triggered.connect(self.undo) edit_menu.addAction(undo_action) redo_action = QAction("Redo", self) redo_action.setShortcut("Ctrl+Y") redo_action.triggered.connect(self.redo) edit_menu.addAction(redo_action) view_menu = menu_bar.addMenu("View") lang_menu = view_menu.addMenu("Language") for i, lang in enumerate(self.language_names): action = QAction(lang, self) action.setData(i) action.triggered.connect(self.set_language) lang_menu.addAction(action) def set_language(self): action = self.sender() if action: idx = action.data() self.canvas.subset_idx = idx self.canvas.redraw() self.canvas.update() self.update_language_label() def prev_subpage(self): count = self.subpage_combo.count() if count <= 1: return current = self.subpage_combo.currentIndex() new_index = (current - 1) % count self.subpage_combo.setCurrentIndex(new_index) def next_subpage(self): count = self.subpage_combo.count() if count <= 1: return current = self.subpage_combo.currentIndex() new_index = (current + 1) % count self.subpage_combo.setCurrentIndex(new_index) def close_file(self): if not self.maybe_save_changes(): return # Reset everything self.service = TeletextService() self.current_page = None self.current_file_path = None self.populate_list() self.subpage_combo.clear() self.page_groups = {} # Clear canvas self.canvas.set_page(None) # Maybe reset text of hex input self.hex_input.clear() self.undo_stack.clear() self.redo_stack.clear() QMessageBox.information(self, "Closed", "File closed.") self.status_label.setText("Ready") self.set_modified(False) def open_file(self): if not self.maybe_save_changes(): return fname, _ = QFileDialog.getOpenFileName(self, "Open T42", "", "Teletext Files (*.t42);;All Files (*)") if fname: try: self.progress_bar.setVisible(True) self.status_label.setText(f"Loading {os.path.basename(fname)}...") self.progress_bar.setValue(0) self.service = load_t42(fname, progress_callback=self.update_progress) self.current_file_path = fname self.populate_list() self.progress_bar.setVisible(False) self.status_label.setText(f"Loaded {len(self.service.pages)} pages from {os.path.basename(fname)}") self.undo_stack.clear() self.redo_stack.clear() self.set_modified(False) except Exception as e: self.progress_bar.setVisible(False) QMessageBox.critical(self, "Error", f"Failed to load file: {e}") self.status_label.setText("Error loading file") def save_as_file(self): fname, _ = QFileDialog.getSaveFileName(self, "Save T42 As...", "", "Teletext Files (*.t42)") if not fname: return self.current_file_path = fname self.save_file() def save_file(self) -> bool: if not self.current_file_path: fname, _ = QFileDialog.getSaveFileName(self, "Save T42", "", "Teletext Files (*.t42)") if not fname: return False self.current_file_path = fname try: self.progress_bar.setVisible(True) self.status_label.setText(f"Saving {os.path.basename(self.current_file_path)}...") # Rebuild all_packets from pages to ensure edits/undos/new packets are included. # This serializes the pages in order, effectively "cleaning" the stream of orphans # and ensuring the file matches the editor state. new_all_packets = [] for page in self.service.pages: new_all_packets.extend(page.packets) self.service.all_packets = new_all_packets save_t42(self.current_file_path, self.service, progress_callback=self.update_progress) self.progress_bar.setVisible(False) self.status_label.setText(f"Saved {len(self.service.pages)} pages to {os.path.basename(self.current_file_path)}") self.set_modified(False) return True except Exception as e: self.progress_bar.setVisible(False) QMessageBox.critical(self, "Error", f"Failed to save file: {e}") self.status_label.setText("Error saving file") return False def copy_page_content(self): if not self.current_page: return self.clipboard = [] # Copy rows 1 to 24 (or up to 25 if exists) # Skip row 0 (Header) for packet in self.current_page.packets: if 1 <= packet.row <= 24: # Store row number and copy of data self.clipboard.append((packet.row, bytearray(packet.data))) self.status_label.setText(f"Copied {len(self.clipboard)} rows to clipboard.") def paste_page_content(self): if not self.current_page: return if not self.clipboard: self.status_label.setText("Clipboard is empty.") return self.push_undo_state() # Paste content # Strategy: For each copied row, update existing packet or create new one. # Create a map of existing packets by row for quick lookup existing_packets = {p.row: p for p in self.current_page.packets} modified_count = 0 for row, data in self.clipboard: if row in existing_packets: # Update existing packet = existing_packets[row] packet.data = bytearray(data) # Clone again to be safe # Note: Packet header (original_data) is now stale if we just update .data # but save functionality reconstructs header from .magazine and .row # so it should be fine. modified_count += 1 else: # Create new packet new_packet = create_blank_packet(self.current_page.magazine, row) new_packet.data = bytearray(data) self.current_page.packets.append(new_packet) # Update lookup map just in case (though we won't hit it again usually) existing_packets[row] = new_packet modified_count += 1 # Force redraw self.canvas.redraw() self.canvas.update() self.status_label.setText(f"Pasted {modified_count} rows.") self.push_undo_state() # Push state after paste? NO, before! # Wait, usually we push before modifying. # But here I just modified it. # Correct pattern: Push state BEFORE modifying. # So I need to refactor paste_page_content to call push_undo_state() first. # For now, I'll add the methods here. def push_undo_state(self): if not self.current_page: return # Push deep copy of current page snapshot = copy.deepcopy(self.current_page) self.undo_stack.append(snapshot) self.redo_stack.clear() # Clear redo on new edit # Limit stack size if len(self.undo_stack) > 50: self.undo_stack.pop(0) self.set_modified(True) def undo(self): if not self.undo_stack: self.status_label.setText("Nothing to undo.") return # Push current state to redo if self.current_page: self.redo_stack.append(copy.deepcopy(self.current_page)) snapshot = self.undo_stack.pop() self.restore_snapshot(snapshot) self.status_label.setText("Undone.") self.set_modified(True) def redo(self): if not self.redo_stack: self.status_label.setText("Nothing to redo.") return # Push current state to undo if self.current_page: self.undo_stack.append(copy.deepcopy(self.current_page)) snapshot = self.redo_stack.pop() self.restore_snapshot(snapshot) self.status_label.setText("Redone.") self.set_modified(True) def restore_snapshot(self, snapshot: Page): # We need to update self.current_page content # AND update the usage in service.pages? # Actually, self.current_page IS the object in service.pages for now (referenced). # But we need to make sure we are modifying the SAME object or replacing it in the list. # Best way: Update attributes of self.current_page matches snapshot # snapshot is a Page object. if not self.current_page: return # Verify it's the same page ID just in case if (self.current_page.magazine != snapshot.magazine or self.current_page.page_number != snapshot.page_number): # This is tricky if we changed pages. Undo should typically track page switches? # For now, we assume undo is local to editing the CURRENT page content. # If user switched pages, we might prevent undo or warn. # But let's just restoring content. pass self.current_page.packets = snapshot.packets # Also attributes self.current_page.sub_code = snapshot.sub_code self.canvas.set_page(self.current_page) self.canvas.redraw() self.canvas.update() def populate_list(self): self.page_list.clear() # Group pages by Mag+PageNum # We want unique list items self.page_groups = {} # Key: (mag, page_num) -> List[Page] for p in self.service.pages: key = (p.magazine, p.page_number) if key not in self.page_groups: self.page_groups[key] = [] self.page_groups[key].append(p) # Sort keys sorted_keys = sorted(self.page_groups.keys()) for mag, pnum in sorted_keys: label = f"{mag}{pnum:02d}" item = QListWidgetItem(label) item.setData(Qt.ItemDataRole.UserRole, (mag, pnum)) self.page_list.addItem(item) def on_page_selected(self, item): mag, pnum = item.data(Qt.ItemDataRole.UserRole) pages = self.page_groups.get((mag, pnum), []) # Populate Subpage Combo self.subpage_combo.blockSignals(True) self.subpage_combo.clear() for i, p in enumerate(pages): # Display format: Index or Subcode? # Subcode is often 0000. Index 1/N is clearer for editing. label = f"{i+1}/{len(pages)} (Sub {p.sub_code:04X})" self.subpage_combo.addItem(label, p) self.subpage_combo.blockSignals(False) if pages: self.subpage_combo.setCurrentIndex(0) # Trigger update (manual because blockSignals) self.on_subpage_changed(0) def on_subpage_changed(self, index): if index < 0: return page = self.subpage_combo.itemData(index) if isinstance(page, Page): self.current_page = page self.canvas.set_page(page) self.update_language_label() self.canvas.setFocus() def insert_char(self, char_code): self.push_undo_state() 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.push_undo_state() 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): if not self.current_page: return key = event.key() text = event.text() # Navigation if key == Qt.Key.Key_Up: self.canvas.move_cursor(0, -1) elif key == Qt.Key.Key_Down: self.canvas.move_cursor(0, 1) elif key == Qt.Key.Key_Left: self.canvas.move_cursor(-1, 0) elif key == Qt.Key.Key_Right: self.canvas.move_cursor(1, 0) elif key == Qt.Key.Key_Home: # Move to start of line self.canvas.set_cursor(0, self.canvas.cursor_y) elif key == Qt.Key.Key_End: # Move to end of line (39) self.canvas.set_cursor(39, self.canvas.cursor_y) 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 # Allow wider range of chars for national support if text and len(text) == 1 and ord(text) >= 32: self.push_undo_state() self.canvas.handle_input(text) elif key == Qt.Key.Key_Backspace: self.push_undo_state() # Move back and delete self.canvas.move_cursor(-1, 0) self.canvas.handle_input(' ') self.canvas.move_cursor(-1, 0) # Compensate for the auto-advance logic if any