diff --git a/src/teletext/renderer.py b/src/teletext/renderer.py index 4668363..4f4e8b1 100755 --- a/src/teletext/renderer.py +++ b/src/teletext/renderer.py @@ -86,6 +86,25 @@ class TeletextCanvas(QWidget): self.cursor_is_graphics = False # Tracked during draw # Blinking cursor timer could be added, for now static inverted is fine or toggle on timer elsewhere + # Highlight state (for search results) + self.highlight_row = -1 + self.highlight_start_x = -1 + self.highlight_end_x = -1 + + def set_highlight(self, row, start_x, end_x): + self.highlight_row = row + self.highlight_start_x = start_x + self.highlight_end_x = end_x + self.redraw() + self.update() + + def clear_highlight(self): + self.highlight_row = -1 + self.highlight_start_x = -1 + self.highlight_end_x = -1 + self.redraw() + self.update() + def get_byte_at(self, x, y): if not self.page: return 0 @@ -384,7 +403,16 @@ class TeletextCanvas(QWidget): if draw_bg: # If double height, draw taller background h_bg = self.cell_h * 2 if double_height else self.cell_h - painter.fillRect(x, y, self.cell_w, h_bg, bg) + + # Check for highlight + is_highlighted = (row == self.highlight_row and self.highlight_start_x <= c <= self.highlight_end_x) + + if is_highlighted: + # Draw highlight color (e.g. Dark Yellow / Orange to contrast white text) + highlight_color = QColor(180, 150, 0) + painter.fillRect(x, y, self.cell_w, h_bg, highlight_color) + else: + painter.fillRect(x, y, self.cell_w, h_bg, bg) # Draw Foreground if draw_fg: diff --git a/src/teletext/ui.py b/src/teletext/ui.py index f48e248..66d07a9 100644 --- a/src/teletext/ui.py +++ b/src/teletext/ui.py @@ -3,13 +3,14 @@ from PyQt6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QComboBox, QLabel, QLineEdit, QPushButton, QFileDialog, QMenuBar, QMenu, QMessageBox, QStatusBar, QProgressBar, QApplication, - QCheckBox, QDialog, QGridLayout + QCheckBox, QDialog, QGridLayout, QRadioButton, QFrame ) from PyQt6.QtGui import QAction, QKeyEvent, QPainter, QBrush, QColor -from PyQt6.QtCore import Qt, QRect, QTimer +from PyQt6.QtCore import Qt, QRect, QTimer, pyqtSignal from .io import load_t42, save_t42, save_tti, decode_hamming_8_4, encode_hamming_8_4 from .renderer import TeletextCanvas, create_blank_packet +from .charsets import get_char import copy import sys import os @@ -113,6 +114,61 @@ class MosaicDialog(QDialog): close_btn.clicked.connect(self.accept) self.layout().addWidget(close_btn) +class FindDialog(QDialog): + findNext = pyqtSignal(str, bool) # query, is_hex + findPrev = pyqtSignal(str, bool) # query, is_hex + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Find") + self.setFixedWidth(350) + self.setLayout(QVBoxLayout()) + + # Query Input + self.query_input = QLineEdit() + self.query_input.setPlaceholderText("Search text or hex (e.g. 1C 02)...") + self.layout().addWidget(QLabel("Find:")) + self.layout().addWidget(self.query_input) + + # Mode Selection + mode_layout = QHBoxLayout() + self.radio_text = QRadioButton("Text") + self.radio_text.setChecked(True) + self.radio_hex = QRadioButton("Hex") + mode_layout.addWidget(self.radio_text) + mode_layout.addWidget(self.radio_hex) + mode_layout.addStretch() + self.layout().addLayout(mode_layout) + + # Navigation Buttons + nav_layout = QHBoxLayout() + self.btn_prev = QPushButton("Previous") + self.btn_prev.clicked.connect(self.on_find_prev) + self.btn_next = QPushButton("Next") + self.btn_next.clicked.connect(self.on_find_next) + self.btn_next.setDefault(True) + nav_layout.addWidget(self.btn_prev) + nav_layout.addWidget(self.btn_next) + self.layout().addLayout(nav_layout) + + # Status Label + self.status_label = QLabel("") + self.status_label.setStyleSheet("color: gray;") + self.layout().addWidget(self.status_label) + + def on_find_next(self): + query = self.query_input.text() + if query: + self.findNext.emit(query, self.radio_hex.isChecked()) + + def on_find_prev(self): + query = self.query_input.text() + if query: + self.findPrev.emit(query, self.radio_hex.isChecked()) + + def set_status(self, text): + self.status_label.setText(text) + class MainWindow(QMainWindow): def __init__(self): super().__init__() @@ -130,6 +186,11 @@ class MainWindow(QMainWindow): self.language_names = ["English", "German", "Swedish/Finnish", "Italian", "French", "Portuguese/Spanish", "Turkish", "Romania"] + # Search State + self.find_dialog = None + self.search_results = [] # List of (Page, row, col, length) + self.current_search_index = -1 + # UI Components self.central_widget = QWidget() self.setCentralWidget(self.central_widget) @@ -471,6 +532,13 @@ class MainWindow(QMainWindow): paste_action.triggered.connect(self.paste_page_content) edit_menu.addAction(paste_action) + edit_menu.addSeparator() + + find_action = QAction("Find...", self) + find_action.setShortcut("Ctrl+F") + find_action.triggered.connect(self.open_find_dialog) + edit_menu.addAction(find_action) + delete_page_action = QAction("Delete Page", self) delete_page_action.triggered.connect(self.delete_page) edit_menu.addAction(delete_page_action) @@ -684,6 +752,118 @@ class MainWindow(QMainWindow): except Exception as e: QMessageBox.critical(self, "Export Failed", f"Failed to export TTI: {e}") + def open_find_dialog(self): + if not self.find_dialog: + self.find_dialog = FindDialog(self) + self.find_dialog.findNext.connect(lambda q, h: self.execute_search(q, h, forward=True)) + self.find_dialog.findPrev.connect(lambda q, h: self.execute_search(q, h, forward=False)) + self.find_dialog.finished.connect(self.canvas.clear_highlight) + + self.find_dialog.show() + self.find_dialog.raise_() + self.find_dialog.activateWindow() + self.find_dialog.query_input.setFocus() + self.find_dialog.query_input.selectAll() + + def execute_search(self, query, is_hex, forward=True): + # If query or mode changed, restart search + is_new_search = False + if not hasattr(self, '_last_query') or self._last_query != query or self._last_is_hex != is_hex: + is_new_search = True + self._last_query = query + self._last_is_hex = is_hex + self.search_results = [] + + if is_hex: + # Parse hex query: "1C 02" -> b'\x1c\x02' + try: + clean_hex = query.replace(" ", "").replace("0x", "") + search_bytes = bytes.fromhex(clean_hex) + except ValueError: + self.find_dialog.set_status("Invalid hex string") + return + else: + search_text = query.lower() + + # Iterate all pages + for page in self.service.pages: + # Determine language for this page + lang = page.language + key = (page.magazine, page.page_number) + if key in self.language_overrides: + lang = self.language_overrides[key] + + # Check each packet + for packet in page.packets: + if packet.row > 25: continue + + if is_hex: + # Hex search in raw data + # Strip parity for comparison? Teletext usually uses 7-bit + parity. + # The search should probably be parity-agnostic. + raw_data = bytes([b & 0x7F for b in packet.data]) + idx = raw_data.find(search_bytes) + while idx != -1: + self.search_results.append((page, packet.row, idx, len(search_bytes))) + idx = raw_data.find(search_bytes, idx + 1) + else: + # Text search in decoded string + row_str = "" + for b in packet.data: + # Map byte to char, but treat control codes as spaces for search + bv = b & 0x7F + if bv < 0x20: + row_str += " " + else: + row_str += get_char(bv, lang) + + idx = row_str.lower().find(search_text) + while idx != -1: + self.search_results.append((page, packet.row, idx, len(search_text))) + idx = row_str.lower().find(search_text, idx + 1) + + if not self.search_results: + self.find_dialog.set_status("No matches found") + self.current_search_index = -1 + self.canvas.clear_highlight() + return + + self.current_search_index = 0 + else: + # Continue search + if forward: + self.current_search_index = (self.current_search_index + 1) % len(self.search_results) + else: + self.current_search_index = (self.current_search_index - 1) % len(self.search_results) + + self.find_dialog.set_status(f"Match {self.current_search_index + 1} of {len(self.search_results)}") + self.show_search_result(self.search_results[self.current_search_index]) + + def show_search_result(self, result): + page, row, col, length = result + + # Switch to page + mag_pnum_key = (page.magazine, page.page_number) + + # Find and select in list + for i in range(self.page_list.count()): + item = self.page_list.item(i) + if item.data(Qt.ItemDataRole.UserRole) == mag_pnum_key: + self.page_list.setCurrentItem(item) + # This triggers on_page_selected which populates subpages + break + + # Select correct subpage + for i in range(self.subpage_combo.count()): + if self.subpage_combo.itemData(i) == page: + self.subpage_combo.setCurrentIndex(i) + break + + # Highlight on canvas + self.canvas.set_highlight(row, col, col + length - 1) + # Also move cursor to start of match + self.canvas.set_cursor(col, row) + def copy_page_content(self): if not self.current_page: return @@ -778,6 +958,7 @@ class MainWindow(QMainWindow): def push_undo_state(self): if not self.current_page: return + self.canvas.clear_highlight() # Push deep copy of current page snapshot = copy.deepcopy(self.current_page) self.undo_stack.append(snapshot)